diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/CompletionHistorySheet.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/CompletionHistorySheet.kt
index 517dade..8b32cd3 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/CompletionHistorySheet.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/CompletionHistorySheet.kt
@@ -16,6 +16,7 @@ import com.example.casera.models.TaskCompletionResponse
import com.example.casera.models.TaskCompletion
import com.example.casera.network.ApiResult
import com.example.casera.network.APILayer
+import com.example.casera.util.DateUtils
import kotlinx.coroutines.launch
/**
@@ -259,7 +260,7 @@ private fun CompletionHistoryCard(completion: TaskCompletionResponse) {
) {
Column {
Text(
- text = formatCompletionDate(completion.completedAt),
+ text = DateUtils.formatDateMedium(completion.completedAt),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
@@ -377,26 +378,3 @@ private fun CompletionHistoryCard(completion: TaskCompletionResponse) {
)
}
}
-
-private fun formatCompletionDate(dateString: String): String {
- // Try to parse and format the date
- return try {
- val parts = dateString.split("T")
- if (parts.isNotEmpty()) {
- val dateParts = parts[0].split("-")
- if (dateParts.size == 3) {
- val year = dateParts[0]
- val month = dateParts[1].toIntOrNull() ?: 1
- val day = dateParts[2].toIntOrNull() ?: 1
- val monthNames = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
- "${monthNames.getOrElse(month) { "Jan" }} $day, $year"
- } else {
- dateString
- }
- } else {
- dateString
- }
- } catch (e: Exception) {
- dateString
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/SimpleTaskListItem.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/SimpleTaskListItem.kt
index d3fec9d..088c79a 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/SimpleTaskListItem.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/SimpleTaskListItem.kt
@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
+import com.example.casera.util.DateUtils
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@@ -77,7 +78,7 @@ fun SimpleTaskListItem(
)
if (dueDate != null) {
Text(
- text = "Due: $dueDate",
+ text = "Due: ${DateUtils.formatDate(dueDate)}",
style = MaterialTheme.typography.bodySmall,
color = if (isOverdue)
MaterialTheme.colorScheme.error
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt
index c9f368d..9263bed 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/task/TaskCard.kt
@@ -20,6 +20,7 @@ import com.example.casera.models.TaskPriority
import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskStatus
import com.example.casera.models.TaskCompletion
+import com.example.casera.util.DateUtils
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@@ -167,7 +168,7 @@ fun TaskCard(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
- text = task.nextScheduledDate ?: task.dueDate ?: "N/A",
+ text = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: "N/A",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt
index ff0579d..c276225 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt
@@ -1,6 +1,7 @@
package com.example.casera.ui.screens
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -14,12 +15,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.casera.cache.DataCache
import com.example.casera.ui.components.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors
+import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.network.ApiResult
@@ -108,12 +114,15 @@ fun ContractorDetailScreen(
.padding(padding)
.background(Color(0xFFF9FAFB))
) {
+ val uriHandler = LocalUriHandler.current
+ val residences = DataCache.residences.value
+
ApiResultHandler(
state = contractorState,
onRetry = { viewModel.loadContractorDetail(contractorId) },
errorTitle = "Failed to Load Contractor",
loadingContent = {
- CircularProgressIndicator(color = Color(0xFF2563EB))
+ CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
}
) { contractor ->
LazyColumn(
@@ -126,7 +135,7 @@ fun ContractorDetailScreen(
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(containerColor = Color.White),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(
@@ -140,14 +149,14 @@ fun ContractorDetailScreen(
modifier = Modifier
.size(80.dp)
.clip(CircleShape)
- .background(Color(0xFFEEF2FF)),
+ .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Person,
contentDescription = null,
modifier = Modifier.size(48.dp),
- tint = Color(0xFF3B82F6)
+ tint = MaterialTheme.colorScheme.primary
)
}
@@ -157,42 +166,43 @@ fun ContractorDetailScreen(
text = contractor.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
- color = Color(0xFF111827)
+ color = MaterialTheme.colorScheme.onSurface
)
if (contractor.company != null) {
Text(
text = contractor.company,
style = MaterialTheme.typography.titleMedium,
- color = Color(0xFF6B7280)
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (contractor.specialties.isNotEmpty()) {
- Spacer(modifier = Modifier.height(8.dp))
- Row(
- horizontalArrangement = Arrangement.spacedBy(8.dp)
+ Spacer(modifier = Modifier.height(12.dp))
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
) {
contractor.specialties.forEach { specialty ->
Surface(
shape = RoundedCornerShape(20.dp),
- color = Color(0xFFEEF2FF)
+ color = MaterialTheme.colorScheme.primaryContainer
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
- Icons.Default.WorkOutline,
+ Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(16.dp),
- tint = Color(0xFF3B82F6)
+ tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = specialty.name,
style = MaterialTheme.typography.bodyMedium,
- color = Color(0xFF3B82F6),
+ color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium
)
}
@@ -214,10 +224,10 @@ fun ContractorDetailScreen(
}
Spacer(modifier = Modifier.width(8.dp))
Text(
- text = "${(contractor.rating * 10).toInt() / 10.0}",
+ text = ((contractor.rating * 10).toInt() / 10.0).toString(),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
- color = Color(0xFF111827)
+ color = MaterialTheme.colorScheme.onSurface
)
}
}
@@ -227,7 +237,80 @@ fun ContractorDetailScreen(
Text(
text = "${contractor.taskCount} completed tasks",
style = MaterialTheme.typography.bodySmall,
- color = Color(0xFF6B7280)
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+
+ // Quick Actions
+ if (contractor.phone != null || contractor.email != null || contractor.website != null) {
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ contractor.phone?.let { phone ->
+ QuickActionButton(
+ icon = Icons.Default.Phone,
+ label = "Call",
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f),
+ onClick = {
+ try {
+ uriHandler.openUri("tel:${phone.replace(" ", "")}")
+ } catch (e: Exception) { /* Handle error */ }
+ }
+ )
+ }
+
+ contractor.email?.let { email ->
+ QuickActionButton(
+ icon = Icons.Default.Email,
+ label = "Email",
+ color = MaterialTheme.colorScheme.secondary,
+ modifier = Modifier.weight(1f),
+ onClick = {
+ try {
+ uriHandler.openUri("mailto:$email")
+ } catch (e: Exception) { /* Handle error */ }
+ }
+ )
+ }
+
+ contractor.website?.let { website ->
+ QuickActionButton(
+ icon = Icons.Default.Language,
+ label = "Website",
+ color = Color(0xFFF59E0B),
+ modifier = Modifier.weight(1f),
+ onClick = {
+ try {
+ val url = if (website.startsWith("http")) website else "https://$website"
+ uriHandler.openUri(url)
+ } catch (e: Exception) { /* Handle error */ }
+ }
+ )
+ }
+
+ if (contractor.streetAddress != null || contractor.city != null) {
+ QuickActionButton(
+ icon = Icons.Default.Map,
+ label = "Directions",
+ color = Color(0xFFEF4444),
+ modifier = Modifier.weight(1f),
+ onClick = {
+ try {
+ val address = listOfNotNull(
+ contractor.streetAddress,
+ contractor.city,
+ contractor.stateProvince,
+ contractor.postalCode
+ ).joinToString(", ")
+ uriHandler.openUri("geo:0,0?q=$address")
+ } catch (e: Exception) { /* Handle error */ }
+ }
)
}
}
@@ -237,30 +320,55 @@ fun ContractorDetailScreen(
// Contact Information
item {
DetailSection(title = "Contact Information") {
- if (contractor.phone != null) {
- DetailRow(
+ contractor.phone?.let { phone ->
+ ClickableDetailRow(
icon = Icons.Default.Phone,
label = "Phone",
- value = contractor.phone,
- iconTint = Color(0xFF3B82F6)
+ value = phone,
+ iconTint = MaterialTheme.colorScheme.primary,
+ onClick = {
+ try {
+ uriHandler.openUri("tel:${phone.replace(" ", "")}")
+ } catch (e: Exception) { /* Handle error */ }
+ }
)
}
- if (contractor.email != null) {
- DetailRow(
+ contractor.email?.let { email ->
+ ClickableDetailRow(
icon = Icons.Default.Email,
label = "Email",
- value = contractor.email,
- iconTint = Color(0xFF8B5CF6)
+ value = email,
+ iconTint = MaterialTheme.colorScheme.secondary,
+ onClick = {
+ try {
+ uriHandler.openUri("mailto:$email")
+ } catch (e: Exception) { /* Handle error */ }
+ }
)
}
- if (contractor.website != null) {
- DetailRow(
+ contractor.website?.let { website ->
+ ClickableDetailRow(
icon = Icons.Default.Language,
label = "Website",
- value = contractor.website,
- iconTint = Color(0xFFF59E0B)
+ value = website,
+ iconTint = Color(0xFFF59E0B),
+ onClick = {
+ try {
+ val url = if (website.startsWith("http")) website else "https://$website"
+ uriHandler.openUri(url)
+ } catch (e: Exception) { /* Handle error */ }
+ }
+ )
+ }
+
+ if (contractor.phone == null && contractor.email == null && contractor.website == null) {
+ Text(
+ text = "No contact information available",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(16.dp)
)
}
}
@@ -287,56 +395,118 @@ fun ContractorDetailScreen(
}
if (fullAddress.isNotBlank()) {
- DetailRow(
+ ClickableDetailRow(
icon = Icons.Default.LocationOn,
label = "Location",
value = fullAddress,
- iconTint = Color(0xFFEF4444)
+ iconTint = Color(0xFFEF4444),
+ onClick = {
+ try {
+ val address = listOfNotNull(
+ contractor.streetAddress,
+ contractor.city,
+ contractor.stateProvince,
+ contractor.postalCode
+ ).joinToString(", ")
+ uriHandler.openUri("geo:0,0?q=$address")
+ } catch (e: Exception) { /* Handle error */ }
+ }
)
}
}
}
}
- // Notes
- if (contractor.notes != null) {
+ // Associated Property
+ contractor.residenceId?.let { resId ->
+ val residenceName = residences.find { r -> r.id == resId }?.name
+ ?: "Property #$resId"
+
item {
- DetailSection(title = "Notes") {
- Text(
- text = contractor.notes,
- style = MaterialTheme.typography.bodyMedium,
- color = Color(0xFF374151),
- modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
+ DetailSection(title = "Associated Property") {
+ DetailRow(
+ icon = Icons.Default.Home,
+ label = "Property",
+ value = residenceName,
+ iconTint = MaterialTheme.colorScheme.primary
)
}
}
}
- // Task History
+ // Notes
+ if (!contractor.notes.isNullOrBlank()) {
+ item {
+ DetailSection(title = "Notes") {
+ Row(
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Icon(
+ Icons.Default.Notes,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = Color(0xFFF59E0B)
+ )
+ Text(
+ text = contractor.notes,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+ }
+ }
+ }
+
+ // Statistics
item {
- DetailSection(title = "Task History") {
- // Placeholder for task history
+ DetailSection(title = "Statistics") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Center
+ horizontalArrangement = Arrangement.SpaceEvenly
) {
- Icon(
- Icons.Default.CheckCircle,
- contentDescription = null,
- tint = Color(0xFF10B981)
- )
- Spacer(modifier = Modifier.width(8.dp))
- Text(
- text = "${contractor.taskCount} completed tasks",
- color = Color(0xFF6B7280),
- style = MaterialTheme.typography.bodyMedium
+ StatCard(
+ icon = Icons.Default.CheckCircle,
+ value = contractor.taskCount.toString(),
+ label = "Tasks\nCompleted",
+ color = MaterialTheme.colorScheme.primary
)
+
+ if (contractor.rating != null && contractor.rating > 0) {
+ StatCard(
+ icon = Icons.Default.Star,
+ value = ((contractor.rating * 10).toInt() / 10.0).toString(),
+ label = "Average\nRating",
+ color = Color(0xFFF59E0B)
+ )
+ }
}
}
}
+
+ // Metadata
+ item {
+ DetailSection(title = "Info") {
+ contractor.createdBy?.let { createdBy ->
+ DetailRow(
+ icon = Icons.Default.PersonAdd,
+ label = "Added By",
+ value = createdBy.username,
+ iconTint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ }
+
+ DetailRow(
+ icon = Icons.Default.CalendarMonth,
+ label = "Member Since",
+ value = DateUtils.formatDateMedium(contractor.createdAt),
+ iconTint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
}
}
}
@@ -389,7 +559,7 @@ fun DetailSection(
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
- colors = CardDefaults.cardColors(containerColor = Color.White),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
@@ -397,7 +567,7 @@ fun DetailSection(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
- color = Color(0xFF111827),
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(16.dp).padding(bottom = 0.dp)
)
content()
@@ -407,10 +577,10 @@ fun DetailSection(
@Composable
fun DetailRow(
- icon: androidx.compose.ui.graphics.vector.ImageVector,
+ icon: ImageVector,
label: String,
value: String,
- iconTint: Color = Color(0xFF6B7280)
+ iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
modifier = Modifier
@@ -429,14 +599,143 @@ fun DetailRow(
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
- color = Color(0xFF6B7280)
+ color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
- color = Color(0xFF111827),
+ color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium
)
}
}
}
+
+@Composable
+fun ClickableDetailRow(
+ icon: ImageVector,
+ label: String,
+ value: String,
+ iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
+ onClick: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.Top
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = iconTint
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ Icon(
+ Icons.Default.OpenInNew,
+ contentDescription = "Open",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+}
+
+@Composable
+fun QuickActionButton(
+ icon: ImageVector,
+ label: String,
+ color: Color,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit
+) {
+ Card(
+ modifier = modifier.clickable(onClick = onClick),
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(color.copy(alpha = 0.1f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = color
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontWeight = FontWeight.Medium
+ )
+ }
+ }
+}
+
+@Composable
+fun StatCard(
+ icon: ImageVector,
+ value: String,
+ label: String,
+ color: Color
+) {
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Box(
+ modifier = Modifier
+ .size(44.dp)
+ .clip(CircleShape)
+ .background(color.copy(alpha = 0.1f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ icon,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = color
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Text(
+ text = label,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt
index 294e37f..b07ea54 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/DocumentDetailScreen.kt
@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.items
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter
+import com.example.casera.util.DateUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -257,9 +258,9 @@ fun DocumentDetailScreen(
)
Divider()
- document.purchaseDate?.let { DetailRow("Purchase Date", it) }
- document.startDate?.let { DetailRow("Start Date", it) }
- document.endDate?.let { DetailRow("End Date", it) }
+ document.purchaseDate?.let { DetailRow("Purchase Date", DateUtils.formatDateMedium(it)) }
+ document.startDate?.let { DetailRow("Start Date", DateUtils.formatDateMedium(it)) }
+ document.endDate?.let { DetailRow("End Date", DateUtils.formatDateMedium(it)) }
}
}
}
@@ -405,8 +406,8 @@ fun DocumentDetailScreen(
Divider()
document.uploadedByUsername?.let { DetailRow("Uploaded By", it) }
- document.createdAt?.let { DetailRow("Created", it) }
- document.updatedAt?.let { DetailRow("Updated", it) }
+ document.createdAt?.let { DetailRow("Created", DateUtils.formatDateMedium(it)) }
+ document.updatedAt?.let { DetailRow("Updated", DateUtils.formatDateMedium(it)) }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt
index 3e57aca..8e74390 100644
--- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt
@@ -34,6 +34,7 @@ import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache
import com.example.casera.cache.DataCache
+import com.example.casera.util.DateUtils
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -598,7 +599,7 @@ fun ResidenceDetailScreen(
title = "Purchase Information"
) {
residence.purchaseDate?.let {
- DetailRow(Icons.Default.Event, "Purchase Date", it)
+ DetailRow(Icons.Default.Event, "Purchase Date", DateUtils.formatDateMedium(it))
}
residence.purchasePrice?.let {
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")
diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt b/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt
new file mode 100644
index 0000000..4442416
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/example/casera/util/DateUtils.kt
@@ -0,0 +1,159 @@
+package com.example.casera.util
+
+import kotlin.time.Clock
+import kotlinx.datetime.DateTimeUnit
+import kotlinx.datetime.Instant
+import kotlinx.datetime.LocalDate
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.minus
+import kotlinx.datetime.plus
+import kotlinx.datetime.toLocalDateTime
+
+/**
+ * Utility object for formatting dates in a human-readable format
+ */
+@OptIn(kotlin.time.ExperimentalTime::class)
+object DateUtils {
+
+ private fun getToday(): LocalDate {
+ val nowMillis = Clock.System.now().toEpochMilliseconds()
+ val instant = Instant.fromEpochMilliseconds(nowMillis)
+ return instant.toLocalDateTime(TimeZone.currentSystemDefault()).date
+ }
+
+ /**
+ * Format a date string (YYYY-MM-DD) to a human-readable format
+ * Returns "Today", "Tomorrow", "Yesterday", or "Mon, Dec 15" format
+ */
+ fun formatDate(dateString: String?): String {
+ if (dateString.isNullOrBlank()) return ""
+
+ return try {
+ // Parse the date string (handle both "YYYY-MM-DD" and "YYYY-MM-DDTHH:mm:ss" formats)
+ val datePart = dateString.substringBefore("T")
+ val date = LocalDate.parse(datePart)
+ val today = getToday()
+
+ when {
+ date == today -> "Today"
+ date == today.plus(1, DateTimeUnit.DAY) -> "Tomorrow"
+ date == today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
+ else -> formatDateMedium(date)
+ }
+ } catch (e: Exception) {
+ dateString // Return original if parsing fails
+ }
+ }
+
+ /**
+ * Format a date string to medium format (e.g., "Dec 15, 2024")
+ */
+ fun formatDateMedium(dateString: String?): String {
+ if (dateString.isNullOrBlank()) return ""
+
+ return try {
+ val datePart = dateString.substringBefore("T")
+ val date = LocalDate.parse(datePart)
+ formatDateMedium(date)
+ } catch (e: Exception) {
+ dateString
+ }
+ }
+
+ /**
+ * Format a LocalDate to medium format (e.g., "Dec 15, 2024")
+ */
+ private fun formatDateMedium(date: LocalDate): String {
+ val monthName = date.month.name.lowercase().replaceFirstChar { it.uppercase() }.take(3)
+ return "$monthName ${date.dayOfMonth}, ${date.year}"
+ }
+
+ /**
+ * Format an ISO datetime string to a human-readable date format
+ * Handles formats like "2024-01-01T00:00:00Z" or "2024-01-01T00:00:00.000Z"
+ */
+ fun formatDateTime(dateTimeString: String?): String {
+ if (dateTimeString.isNullOrBlank()) return ""
+
+ return try {
+ // Try to parse as full ISO datetime
+ val instant = Instant.parse(dateTimeString)
+ val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
+ val date = localDateTime.date
+ val today = getToday()
+
+ when {
+ date == today -> "Today"
+ date == today.plus(1, DateTimeUnit.DAY) -> "Tomorrow"
+ date == today.minus(1, DateTimeUnit.DAY) -> "Yesterday"
+ else -> formatDateMedium(date)
+ }
+ } catch (e: Exception) {
+ // Fall back to just date parsing
+ formatDate(dateTimeString)
+ }
+ }
+
+ /**
+ * Format a datetime string with time (e.g., "Dec 15, 2024 at 3:30 PM")
+ */
+ fun formatDateTimeWithTime(dateTimeString: String?): String {
+ if (dateTimeString.isNullOrBlank()) return ""
+
+ return try {
+ val instant = Instant.parse(dateTimeString)
+ val localDateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
+ val date = localDateTime.date
+ val time = localDateTime.time
+
+ val hour = if (time.hour == 0) 12 else if (time.hour > 12) time.hour - 12 else time.hour
+ val amPm = if (time.hour < 12) "AM" else "PM"
+ val minuteStr = time.minute.toString().padStart(2, '0')
+
+ "${formatDateMedium(date)} at $hour:$minuteStr $amPm"
+ } catch (e: Exception) {
+ formatDate(dateTimeString)
+ }
+ }
+
+ /**
+ * Format a date for relative display (e.g., "2 days ago", "in 3 days")
+ */
+ fun formatRelativeDate(dateString: String?): String {
+ if (dateString.isNullOrBlank()) return ""
+
+ return try {
+ val datePart = dateString.substringBefore("T")
+ val date = LocalDate.parse(datePart)
+ val today = getToday()
+ val daysDiff = (date.toEpochDays() - today.toEpochDays()).toInt()
+
+ when (daysDiff) {
+ 0 -> "Today"
+ 1 -> "Tomorrow"
+ -1 -> "Yesterday"
+ in 2..7 -> "in $daysDiff days"
+ in -7..-2 -> "${-daysDiff} days ago"
+ else -> formatDateMedium(date)
+ }
+ } catch (e: Exception) {
+ dateString
+ }
+ }
+
+ /**
+ * Check if a date string represents a date in the past
+ */
+ fun isOverdue(dateString: String?): Boolean {
+ if (dateString.isNullOrBlank()) return false
+
+ return try {
+ val datePart = dateString.substringBefore("T")
+ val date = LocalDate.parse(datePart)
+ val today = getToday()
+ date < today
+ } catch (e: Exception) {
+ false
+ }
+ }
+}
diff --git a/iosApp/Casera/MyCrib.swift b/iosApp/Casera/MyCrib.swift
index e88d75f..a780992 100644
--- a/iosApp/Casera/MyCrib.swift
+++ b/iosApp/Casera/MyCrib.swift
@@ -262,10 +262,17 @@ struct MediumWidgetView: View {
.font(.system(size: 42, weight: .bold))
.foregroundStyle(.blue)
- Text(entry.taskCount == 1 ? "upcoming\n task" : "upcoming\ntasks")
- .font(.system(size: 11, weight: .medium))
- .foregroundStyle(.secondary)
- .lineLimit(2)
+ VStack(alignment: .leading) {
+ Text("upcoming:")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+
+ Text(entry.taskCount == 1 ? "task" : "tasks")
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
Spacer()
}
diff --git a/iosApp/CaseraExtension.entitlements b/iosApp/CaseraExtension.entitlements
index b513af7..83ac4e5 100644
--- a/iosApp/CaseraExtension.entitlements
+++ b/iosApp/CaseraExtension.entitlements
@@ -4,7 +4,7 @@
com.apple.security.application-groups
- group.com.tt.mycrib.MyCribDev
+ group.com.tt.casera.CaseraDev
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
index 7bebbf3..c68da6c 100644
--- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,7 +1,7 @@
{
"images" : [
{
- "filename" : "mycrib-icon@2x.png",
+ "filename" : "icon.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png
new file mode 100644
index 0000000..a5215d3
Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png differ
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/mycrib-icon@2x.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/mycrib-icon@2x.png
deleted file mode 100644
index d331dc9..0000000
Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/mycrib-icon@2x.png and /dev/null differ
diff --git a/iosApp/iosApp/Contractor/ContractorDetailView.swift b/iosApp/iosApp/Contractor/ContractorDetailView.swift
index fa7a97d..e3252ac 100644
--- a/iosApp/iosApp/Contractor/ContractorDetailView.swift
+++ b/iosApp/iosApp/Contractor/ContractorDetailView.swift
@@ -3,7 +3,9 @@ import ComposeApp
struct ContractorDetailView: View {
@Environment(\.dismiss) private var dismiss
+ @Environment(\.openURL) private var openURL
@StateObject private var viewModel = ContractorViewModel()
+ @StateObject private var residenceViewModel = ResidenceViewModel()
let contractorId: Int32
@@ -13,149 +15,10 @@ struct ContractorDetailView: View {
var body: some View {
ZStack {
Color.appBackgroundPrimary.ignoresSafeArea()
-
- if viewModel.isLoading {
- ProgressView()
- .scaleEffect(1.2)
- } else if let error = viewModel.errorMessage {
- ErrorView(message: error) {
- viewModel.loadContractorDetail(id: contractorId)
- }
- } else if let contractor = viewModel.selectedContractor {
- ScrollView {
- VStack(spacing: AppSpacing.lg) {
- // Header Card
- VStack(spacing: AppSpacing.md) {
- // Avatar
- ZStack {
- Circle()
- .fill(Color.appPrimary.opacity(0.1))
- .frame(width: 80, height: 80)
-
- Image(systemName: "person.fill")
- .font(.system(size: 40))
- .foregroundColor(Color.appPrimary)
- }
-
- // Name
- Text(contractor.name)
- .font(.title3.weight(.semibold))
- .foregroundColor(Color.appTextPrimary)
-
- // Company
- if let company = contractor.company {
- Text(company)
- .font(.title3.weight(.semibold))
- .foregroundColor(Color.appTextSecondary)
- }
-
- // Specialties Badges
- if !contractor.specialties.isEmpty {
- FlowLayout(spacing: AppSpacing.xs) {
- ForEach(contractor.specialties, id: \.id) { specialty in
- HStack(spacing: AppSpacing.xxs) {
- Image(systemName: "wrench.and.screwdriver")
- .font(.caption)
- Text(specialty.name)
- .font(.body)
- }
- .padding(.horizontal, AppSpacing.sm)
- .padding(.vertical, AppSpacing.xxs)
- .background(Color.appPrimary.opacity(0.1))
- .foregroundColor(Color.appPrimary)
- .cornerRadius(AppRadius.full)
- }
- }
- }
-
- // Rating
- if let rating = contractor.rating, rating.doubleValue > 0 {
- HStack(spacing: AppSpacing.xxs) {
- ForEach(0..<5) { index in
- Image(systemName: index < Int(rating.doubleValue) ? "star.fill" : "star")
- .foregroundColor(Color.appAccent)
- .font(.caption)
- }
- Text(String(format: "%.1f", rating.doubleValue))
- .font(.title3.weight(.semibold))
- .foregroundColor(Color.appTextPrimary)
- }
-
- if contractor.taskCount > 0 {
- Text("\(contractor.taskCount) completed tasks")
- .font(.callout)
- .foregroundColor(Color.appTextSecondary)
- }
- }
- }
- .padding(AppSpacing.lg)
- .frame(maxWidth: .infinity)
- .background(Color.appBackgroundSecondary)
- .cornerRadius(AppRadius.lg)
- .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
-
- // Contact Information
- DetailSection(title: "Contact Information") {
- if let phone = contractor.phone {
- DetailRow(icon: "phone", label: "Phone", value: phone, iconColor: Color.appPrimary)
- }
-
- if let email = contractor.email {
- DetailRow(icon: "envelope", label: "Email", value: email, iconColor: Color.appPrimary)
- }
-
- if let website = contractor.website {
- DetailRow(icon: "globe", label: "Website", value: website, iconColor: Color.appAccent)
- }
- }
-
- // Address
- if contractor.streetAddress != nil || contractor.city != nil {
- DetailSection(title: "Address") {
- let addressComponents = [
- contractor.streetAddress,
- [contractor.city, contractor.stateProvince].compactMap { $0 }.joined(separator: ", "),
- contractor.postalCode
- ].compactMap { $0 }.filter { !$0.isEmpty }
-
- if !addressComponents.isEmpty {
- DetailRow(
- icon: "mappin.circle",
- label: "Location",
- value: addressComponents.joined(separator: "\n"),
- iconColor: Color.appError
- )
- }
- }
- }
-
- // Notes
- if let notes = contractor.notes, !notes.isEmpty {
- DetailSection(title: "Notes") {
- Text(notes)
- .font(.body)
- .foregroundColor(Color.appTextSecondary)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(AppSpacing.md)
- }
- }
-
- // Task History
- DetailSection(title: "Task History") {
- HStack {
- Image(systemName: "checkmark.circle")
- .foregroundColor(Color.appAccent)
- Spacer()
- Text("\(contractor.taskCount) completed tasks")
- .font(.body)
- .foregroundColor(Color.appTextSecondary)
- }
- .padding(AppSpacing.md)
- }
- }
- .padding(AppSpacing.md)
- }
- }
+ contentStateView
+ }
+ .onAppear {
+ residenceViewModel.loadMyResidences()
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -224,6 +87,494 @@ struct ContractorDetailView: View {
}
}
}
+
+ // MARK: - Content State View
+
+ @ViewBuilder
+ private var contentStateView: some View {
+ if viewModel.isLoading {
+ ProgressView()
+ .scaleEffect(1.2)
+ } else if let error = viewModel.errorMessage {
+ ErrorView(message: error) {
+ viewModel.loadContractorDetail(id: contractorId)
+ }
+ } else if let contractor = viewModel.selectedContractor {
+ contractorScrollView(contractor: contractor)
+ }
+ }
+
+ // MARK: - Main Scroll View
+
+ @ViewBuilder
+ private func contractorScrollView(contractor: Contractor) -> some View {
+ ScrollView {
+ VStack(spacing: AppSpacing.lg) {
+ headerCard(contractor: contractor)
+ quickActionsView(contractor: contractor)
+ contactInfoSection(contractor: contractor)
+ addressSection(contractor: contractor)
+ residenceSection(residenceId: (contractor.residenceId as? Int32))
+ notesSection(notes: contractor.notes)
+ statisticsSection(contractor: contractor)
+ metadataSection(contractor: contractor)
+ }
+ .padding(AppSpacing.md)
+ }
+ }
+
+ // MARK: - Header Card
+
+ @ViewBuilder
+ private func headerCard(contractor: Contractor) -> some View {
+ VStack(spacing: AppSpacing.md) {
+ // Avatar
+ ZStack {
+ Circle()
+ .fill(Color.appPrimary.opacity(0.1))
+ .frame(width: 80, height: 80)
+
+ Image(systemName: "person.fill")
+ .font(.system(size: 40))
+ .foregroundColor(Color.appPrimary)
+ }
+
+ // Name
+ Text(contractor.name)
+ .font(.title3.weight(.semibold))
+ .foregroundColor(Color.appTextPrimary)
+
+ // Company
+ if let company = contractor.company {
+ Text(company)
+ .font(.title3.weight(.semibold))
+ .foregroundColor(Color.appTextSecondary)
+ }
+
+ // Specialties Badges
+ specialtiesBadges(contractor: contractor)
+
+ // Rating
+ ratingView(contractor: contractor)
+ }
+ .padding(AppSpacing.lg)
+ .frame(maxWidth: .infinity)
+ .background(Color.appBackgroundSecondary)
+ .cornerRadius(AppRadius.lg)
+ .shadow(color: AppShadow.sm.color, radius: AppShadow.sm.radius, x: AppShadow.sm.x, y: AppShadow.sm.y)
+ }
+
+ @ViewBuilder
+ private func specialtiesBadges(contractor: Contractor) -> some View {
+ if !contractor.specialties.isEmpty {
+ FlowLayout(spacing: AppSpacing.xs) {
+ ForEach(contractor.specialties, id: \.id) { specialty in
+ HStack(spacing: AppSpacing.xxs) {
+ Image(systemName: "wrench.and.screwdriver")
+ .font(.caption)
+ Text(specialty.name)
+ .font(.body)
+ }
+ .padding(.horizontal, AppSpacing.sm)
+ .padding(.vertical, AppSpacing.xxs)
+ .background(Color.appPrimary.opacity(0.1))
+ .foregroundColor(Color.appPrimary)
+ .cornerRadius(AppRadius.full)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func ratingView(contractor: Contractor) -> some View {
+ if let rating = contractor.rating, rating.doubleValue > 0 {
+ HStack(spacing: AppSpacing.xxs) {
+ ForEach(0..<5) { index in
+ Image(systemName: index < Int(rating.doubleValue) ? "star.fill" : "star")
+ .foregroundColor(Color.appAccent)
+ .font(.caption)
+ }
+ Text(String(format: "%.1f", rating.doubleValue))
+ .font(.title3.weight(.semibold))
+ .foregroundColor(Color.appTextPrimary)
+ }
+
+ if contractor.taskCount > 0 {
+ Text("\(contractor.taskCount) completed tasks")
+ .font(.callout)
+ .foregroundColor(Color.appTextSecondary)
+ }
+ }
+ }
+
+ // MARK: - Quick Actions
+
+ @ViewBuilder
+ private func quickActionsView(contractor: Contractor) -> some View {
+ let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
+ let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
+ let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
+ let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
+ (contractor.city != nil && !contractor.city!.isEmpty)
+
+ if hasPhone || hasEmail || hasWebsite || hasAddress {
+ HStack(spacing: AppSpacing.sm) {
+ phoneQuickAction(phone: contractor.phone)
+ emailQuickAction(email: contractor.email)
+ websiteQuickAction(website: contractor.website)
+ directionsQuickAction(contractor: contractor)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func phoneQuickAction(phone: String?) -> some View {
+ if let phone = phone, !phone.isEmpty {
+ QuickActionButton(
+ icon: "phone.fill",
+ label: "Call",
+ color: Color.appPrimary
+ ) {
+ if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func emailQuickAction(email: String?) -> some View {
+ if let email = email, !email.isEmpty {
+ QuickActionButton(
+ icon: "envelope.fill",
+ label: "Email",
+ color: Color.appSecondary
+ ) {
+ if let url = URL(string: "mailto:\(email)") {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func websiteQuickAction(website: String?) -> some View {
+ if let website = website, !website.isEmpty {
+ QuickActionButton(
+ icon: "safari.fill",
+ label: "Website",
+ color: Color.appAccent
+ ) {
+ var urlString = website
+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
+ urlString = "https://\(urlString)"
+ }
+ if let url = URL(string: urlString) {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func directionsQuickAction(contractor: Contractor) -> some View {
+ let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
+ (contractor.city != nil && !contractor.city!.isEmpty)
+ if hasAddress {
+ QuickActionButton(
+ icon: "map.fill",
+ label: "Directions",
+ color: Color.appError
+ ) {
+ let address = [
+ contractor.streetAddress,
+ contractor.city,
+ contractor.stateProvince,
+ contractor.postalCode
+ ].compactMap { $0 }.joined(separator: ", ")
+
+ if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
+ let url = URL(string: "maps://?address=\(encoded)") {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ // MARK: - Contact Information Section
+
+ @ViewBuilder
+ private func contactInfoSection(contractor: Contractor) -> some View {
+ let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
+ let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
+ let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
+
+ if hasPhone || hasEmail || hasWebsite {
+ DetailSection(title: "Contact Information") {
+ phoneContactRow(phone: contractor.phone)
+ emailContactRow(email: contractor.email)
+ websiteContactRow(website: contractor.website)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func phoneContactRow(phone: String?) -> some View {
+ if let phone = phone, !phone.isEmpty {
+ ContactDetailRow(
+ icon: "phone.fill",
+ label: "Phone",
+ value: phone,
+ iconColor: Color.appPrimary
+ ) {
+ if let url = URL(string: "tel:\(phone.replacingOccurrences(of: " ", with: ""))") {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func emailContactRow(email: String?) -> some View {
+ if let email = email, !email.isEmpty {
+ ContactDetailRow(
+ icon: "envelope.fill",
+ label: "Email",
+ value: email,
+ iconColor: Color.appSecondary
+ ) {
+ if let url = URL(string: "mailto:\(email)") {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func websiteContactRow(website: String?) -> some View {
+ if let website = website, !website.isEmpty {
+ ContactDetailRow(
+ icon: "safari.fill",
+ label: "Website",
+ value: website,
+ iconColor: Color.appAccent
+ ) {
+ var urlString = website
+ if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
+ urlString = "https://\(urlString)"
+ }
+ if let url = URL(string: urlString) {
+ openURL(url)
+ }
+ }
+ }
+ }
+
+ // MARK: - Address Section
+
+ @ViewBuilder
+ private func addressSection(contractor: Contractor) -> some View {
+ let hasStreet = contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty
+ let hasCity = contractor.city != nil && !contractor.city!.isEmpty
+
+ if hasStreet || hasCity {
+ let addressComponents = [
+ contractor.streetAddress,
+ [contractor.city, contractor.stateProvince].compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: ", "),
+ contractor.postalCode
+ ].compactMap { $0 }.filter { !$0.isEmpty }
+
+ if !addressComponents.isEmpty {
+ DetailSection(title: "Address") {
+ addressButton(contractor: contractor, addressComponents: addressComponents)
+ }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func addressButton(contractor: Contractor, addressComponents: [String]) -> some View {
+ let fullAddress = addressComponents.joined(separator: "\n")
+ Button {
+ let address = [
+ contractor.streetAddress,
+ contractor.city,
+ contractor.stateProvince,
+ contractor.postalCode
+ ].compactMap { $0 }.joined(separator: ", ")
+
+ if let encoded = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
+ let url = URL(string: "maps://?address=\(encoded)") {
+ openURL(url)
+ }
+ } label: {
+ HStack(alignment: .top, spacing: AppSpacing.sm) {
+ Image(systemName: "mappin.circle.fill")
+ .foregroundColor(Color.appError)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: AppSpacing.xxs) {
+ Text("Location")
+ .font(.caption.weight(.medium))
+ .foregroundColor(Color.appTextSecondary)
+
+ Text(fullAddress)
+ .font(.body)
+ .foregroundColor(Color.appTextPrimary)
+ .multilineTextAlignment(.leading)
+ }
+
+ Spacer()
+
+ Image(systemName: "arrow.up.right.square")
+ .font(.caption)
+ .foregroundColor(Color.appTextSecondary)
+ }
+ .padding(AppSpacing.md)
+ }
+ }
+
+ // MARK: - Residence Section
+
+ @ViewBuilder
+ private func residenceSection(residenceId: Int32?) -> some View {
+ if let residenceId = residenceId {
+ DetailSection(title: "Associated Property") {
+ HStack(spacing: AppSpacing.sm) {
+ Image(systemName: "house.fill")
+ .foregroundColor(Color.appPrimary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: AppSpacing.xxs) {
+ Text("Property")
+ .font(.caption.weight(.medium))
+ .foregroundColor(Color.appTextSecondary)
+
+ if let residence = residenceViewModel.myResidences?.residences.first(where: { $0.id == residenceId }) {
+ Text(residence.name)
+ .font(.body)
+ .foregroundColor(Color.appTextPrimary)
+ } else {
+ Text("Property #\(residenceId)")
+ .font(.body)
+ .foregroundColor(Color.appTextPrimary)
+ }
+ }
+
+ Spacer()
+ }
+ .padding(AppSpacing.md)
+ }
+ }
+ }
+
+ // MARK: - Notes Section
+
+ @ViewBuilder
+ private func notesSection(notes: String?) -> some View {
+ if let notes = notes, !notes.isEmpty {
+ DetailSection(title: "Notes") {
+ HStack(alignment: .top, spacing: AppSpacing.sm) {
+ Image(systemName: "note.text")
+ .foregroundColor(Color.appAccent)
+ .frame(width: 20)
+
+ Text(notes)
+ .font(.body)
+ .foregroundColor(Color.appTextPrimary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .padding(AppSpacing.md)
+ }
+ }
+ }
+
+ // MARK: - Statistics Section
+
+ @ViewBuilder
+ private func statisticsSection(contractor: Contractor) -> some View {
+ DetailSection(title: "Statistics") {
+ HStack(spacing: AppSpacing.lg) {
+ StatCard(
+ icon: "checkmark.circle.fill",
+ value: "\(contractor.taskCount)",
+ label: "Tasks Completed",
+ color: Color.appPrimary
+ )
+
+ if let rating = contractor.rating, rating.doubleValue > 0 {
+ StatCard(
+ icon: "star.fill",
+ value: String(format: "%.1f", rating.doubleValue),
+ label: "Average Rating",
+ color: Color.appAccent
+ )
+ }
+ }
+ .padding(AppSpacing.md)
+ }
+ }
+
+ // MARK: - Metadata Section
+
+ @ViewBuilder
+ private func metadataSection(contractor: Contractor) -> some View {
+ DetailSection(title: "Info") {
+ VStack(spacing: 0) {
+ createdByRow(createdBy: contractor.createdBy)
+ memberSinceRow(createdAt: contractor.createdAt)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func createdByRow(createdBy: ContractorUser?) -> some View {
+ if let createdBy = createdBy {
+ HStack(spacing: AppSpacing.sm) {
+ Image(systemName: "person.badge.plus")
+ .foregroundColor(Color.appTextSecondary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: AppSpacing.xxs) {
+ Text("Added By")
+ .font(.caption.weight(.medium))
+ .foregroundColor(Color.appTextSecondary)
+
+ Text(createdBy.username)
+ .font(.body)
+ .foregroundColor(Color.appTextPrimary)
+ }
+
+ Spacer()
+ }
+ .padding(AppSpacing.md)
+
+ Divider()
+ .padding(.horizontal, AppSpacing.md)
+ }
+ }
+
+ @ViewBuilder
+ private func memberSinceRow(createdAt: String) -> some View {
+ HStack(spacing: AppSpacing.sm) {
+ Image(systemName: "calendar.badge.plus")
+ .foregroundColor(Color.appTextSecondary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: AppSpacing.xxs) {
+ Text("Member Since")
+ .font(.caption.weight(.medium))
+ .foregroundColor(Color.appTextSecondary)
+
+ Text(DateUtils.formatDateMedium(createdAt))
+ .font(.body)
+ .foregroundColor(Color.appTextPrimary)
+ }
+
+ Spacer()
+ }
+ .padding(AppSpacing.md)
+ }
}
// MARK: - Detail Section
@@ -276,3 +627,103 @@ struct DetailRow: View {
.padding(AppSpacing.md)
}
}
+
+// MARK: - Quick Action Button
+struct QuickActionButton: View {
+ let icon: String
+ let label: String
+ let color: Color
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ VStack(spacing: AppSpacing.xs) {
+ ZStack {
+ Circle()
+ .fill(color.opacity(0.1))
+ .frame(width: 50, height: 50)
+
+ Image(systemName: icon)
+ .font(.system(size: 20, weight: .semibold))
+ .foregroundColor(color)
+ }
+
+ Text(label)
+ .font(.caption.weight(.medium))
+ .foregroundColor(Color.appTextSecondary)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, AppSpacing.sm)
+ .background(Color.appBackgroundSecondary)
+ .cornerRadius(AppRadius.md)
+ }
+ }
+}
+
+// MARK: - Contact Detail Row (Clickable)
+struct ContactDetailRow: View {
+ let icon: String
+ let label: String
+ let value: String
+ var iconColor: Color = Color(.secondaryLabel)
+ let action: () -> Void
+
+ var body: some View {
+ Button(action: action) {
+ HStack(alignment: .top, spacing: AppSpacing.sm) {
+ Image(systemName: icon)
+ .foregroundColor(iconColor)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: AppSpacing.xxs) {
+ Text(label)
+ .font(.caption.weight(.medium))
+ .foregroundColor(Color.appTextSecondary)
+
+ Text(value)
+ .font(.body)
+ .foregroundColor(Color.appPrimary)
+ }
+
+ Spacer()
+
+ Image(systemName: "arrow.up.right.square")
+ .font(.caption)
+ .foregroundColor(Color.appTextSecondary)
+ }
+ .padding(AppSpacing.md)
+ }
+ }
+}
+
+// MARK: - Stat Card
+struct StatCard: View {
+ let icon: String
+ let value: String
+ let label: String
+ let color: Color
+
+ var body: some View {
+ VStack(spacing: AppSpacing.xs) {
+ ZStack {
+ Circle()
+ .fill(color.opacity(0.1))
+ .frame(width: 44, height: 44)
+
+ Image(systemName: icon)
+ .font(.system(size: 20))
+ .foregroundColor(color)
+ }
+
+ Text(value)
+ .font(.title2.weight(.bold))
+ .foregroundColor(Color.appTextPrimary)
+
+ Text(label)
+ .font(.caption)
+ .foregroundColor(Color.appTextSecondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ }
+}
diff --git a/iosApp/iosApp/Documents/Components/WarrantyCard.swift b/iosApp/iosApp/Documents/Components/WarrantyCard.swift
index 7dbef22..caf5aee 100644
--- a/iosApp/iosApp/Documents/Components/WarrantyCard.swift
+++ b/iosApp/iosApp/Documents/Components/WarrantyCard.swift
@@ -83,7 +83,7 @@ struct WarrantyCard: View {
Text("Expires")
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
- Text(document.endDate ?? "N/A")
+ Text(DateUtils.formatDateMedium(document.endDate) ?? "N/A")
.font(.body)
.fontWeight(.medium)
.foregroundColor(Color.appTextPrimary)
diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift
index 1f5210a..6c12982 100644
--- a/iosApp/iosApp/Documents/DocumentDetailView.swift
+++ b/iosApp/iosApp/Documents/DocumentDetailView.swift
@@ -188,13 +188,13 @@ struct DocumentDetailView: View {
sectionHeader("Important Dates")
if let purchaseDate = document.purchaseDate {
- detailRow(label: "Purchase Date", value: purchaseDate)
+ detailRow(label: "Purchase Date", value: DateUtils.formatDateMedium(purchaseDate))
}
if let startDate = document.startDate {
- detailRow(label: "Start Date", value: startDate)
+ detailRow(label: "Start Date", value: DateUtils.formatDateMedium(startDate))
}
if let endDate = document.endDate {
- detailRow(label: "End Date", value: endDate)
+ detailRow(label: "End Date", value: DateUtils.formatDateMedium(endDate))
}
}
.padding()
@@ -331,10 +331,10 @@ struct DocumentDetailView: View {
detailRow(label: "Uploaded By", value: uploadedBy)
}
if let createdAt = document.createdAt {
- detailRow(label: "Created", value: createdAt)
+ detailRow(label: "Created", value: DateUtils.formatDateTime(createdAt))
}
if let updatedAt = document.updatedAt {
- detailRow(label: "Updated", value: updatedAt)
+ detailRow(label: "Updated", value: DateUtils.formatDateTime(updatedAt))
}
}
.padding()
diff --git a/iosApp/iosApp/Helpers/DateUtils.swift b/iosApp/iosApp/Helpers/DateUtils.swift
new file mode 100644
index 0000000..0970c89
--- /dev/null
+++ b/iosApp/iosApp/Helpers/DateUtils.swift
@@ -0,0 +1,179 @@
+import Foundation
+
+/// Utility for formatting dates in a human-readable format
+/// Mirrors the shared Kotlin DateUtils for consistent date display
+enum DateUtils {
+
+ private static let isoDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter
+ }()
+
+ private static let isoDateTimeFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ return formatter
+ }()
+
+ private static let isoDateTimeSimpleFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ return formatter
+ }()
+
+ private static let mediumDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMM d, yyyy"
+ return formatter
+ }()
+
+ private static let dateTimeFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
+ return formatter
+ }()
+
+ /// Format a date string (YYYY-MM-DD) to a human-readable format
+ /// Returns "Today", "Tomorrow", "Yesterday", or "Dec 15, 2024" format
+ static func formatDate(_ dateString: String?) -> String {
+ guard let dateString = dateString, !dateString.isEmpty else { return "" }
+
+ // Extract date part if it includes time
+ let datePart = dateString.components(separatedBy: "T").first ?? dateString
+
+ guard let date = isoDateFormatter.date(from: datePart) else {
+ return dateString
+ }
+
+ let today = Calendar.current.startOfDay(for: Date())
+ let targetDate = Calendar.current.startOfDay(for: date)
+
+ let daysDiff = Calendar.current.dateComponents([.day], from: today, to: targetDate).day ?? 0
+
+ switch daysDiff {
+ case 0:
+ return "Today"
+ case 1:
+ return "Tomorrow"
+ case -1:
+ return "Yesterday"
+ default:
+ return mediumDateFormatter.string(from: date)
+ }
+ }
+
+ /// Format a date string to medium format (e.g., "Dec 15, 2024")
+ static func formatDateMedium(_ dateString: String?) -> String {
+ guard let dateString = dateString, !dateString.isEmpty else { return "" }
+
+ // Extract date part if it includes time
+ let datePart = dateString.components(separatedBy: "T").first ?? dateString
+
+ guard let date = isoDateFormatter.date(from: datePart) else {
+ return dateString
+ }
+
+ return mediumDateFormatter.string(from: date)
+ }
+
+ /// Format an ISO datetime string to a human-readable date format
+ /// Handles formats like "2024-01-01T00:00:00Z" or "2024-01-01T00:00:00.000Z"
+ static func formatDateTime(_ dateTimeString: String?) -> String {
+ guard let dateTimeString = dateTimeString, !dateTimeString.isEmpty else { return "" }
+
+ // Try parsing with fractional seconds first
+ var date = isoDateTimeFormatter.date(from: dateTimeString)
+
+ // Fallback to simple ISO format
+ if date == nil {
+ date = isoDateTimeSimpleFormatter.date(from: dateTimeString)
+ }
+
+ // Fallback to just date parsing
+ guard let parsedDate = date else {
+ return formatDate(dateTimeString)
+ }
+
+ let today = Calendar.current.startOfDay(for: Date())
+ let targetDate = Calendar.current.startOfDay(for: parsedDate)
+
+ let daysDiff = Calendar.current.dateComponents([.day], from: today, to: targetDate).day ?? 0
+
+ switch daysDiff {
+ case 0:
+ return "Today"
+ case 1:
+ return "Tomorrow"
+ case -1:
+ return "Yesterday"
+ default:
+ return mediumDateFormatter.string(from: parsedDate)
+ }
+ }
+
+ /// Format a datetime string with time (e.g., "Dec 15, 2024 at 3:30 PM")
+ static func formatDateTimeWithTime(_ dateTimeString: String?) -> String {
+ guard let dateTimeString = dateTimeString, !dateTimeString.isEmpty else { return "" }
+
+ // Try parsing with fractional seconds first
+ var date = isoDateTimeFormatter.date(from: dateTimeString)
+
+ // Fallback to simple ISO format
+ if date == nil {
+ date = isoDateTimeSimpleFormatter.date(from: dateTimeString)
+ }
+
+ guard let parsedDate = date else {
+ return formatDate(dateTimeString)
+ }
+
+ return dateTimeFormatter.string(from: parsedDate)
+ }
+
+ /// Format a date for relative display (e.g., "2 days ago", "in 3 days")
+ static func formatRelativeDate(_ dateString: String?) -> String {
+ guard let dateString = dateString, !dateString.isEmpty else { return "" }
+
+ // Extract date part if it includes time
+ let datePart = dateString.components(separatedBy: "T").first ?? dateString
+
+ guard let date = isoDateFormatter.date(from: datePart) else {
+ return dateString
+ }
+
+ let today = Calendar.current.startOfDay(for: Date())
+ let targetDate = Calendar.current.startOfDay(for: date)
+
+ let daysDiff = Calendar.current.dateComponents([.day], from: today, to: targetDate).day ?? 0
+
+ switch daysDiff {
+ case 0:
+ return "Today"
+ case 1:
+ return "Tomorrow"
+ case -1:
+ return "Yesterday"
+ case 2...7:
+ return "in \(daysDiff) days"
+ case -7 ... -2:
+ return "\(-daysDiff) days ago"
+ default:
+ return mediumDateFormatter.string(from: date)
+ }
+ }
+
+ /// Check if a date string represents a date in the past
+ static func isOverdue(_ dateString: String?) -> Bool {
+ guard let dateString = dateString, !dateString.isEmpty else { return false }
+
+ // Extract date part if it includes time
+ let datePart = dateString.components(separatedBy: "T").first ?? dateString
+
+ guard let date = isoDateFormatter.date(from: datePart) else {
+ return false
+ }
+
+ let today = Calendar.current.startOfDay(for: Date())
+ return date < today
+ }
+}
diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift
index 752b179..dd1698a 100644
--- a/iosApp/iosApp/Profile/ProfileTabView.swift
+++ b/iosApp/iosApp/Profile/ProfileTabView.swift
@@ -76,7 +76,7 @@ struct ProfileTabView: View {
if subscriptionCache.currentTier == "pro",
let expiresAt = subscription.expiresAt {
- Text("Active until \(formatDate(expiresAt))")
+ Text("Active until \(DateUtils.formatDateMedium(expiresAt))")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
} else {
@@ -193,14 +193,4 @@ struct ProfileTabView: View {
Text("Your purchases have been restored successfully.")
}
}
-
- private func formatDate(_ dateString: String) -> String {
- let formatter = ISO8601DateFormatter()
- if let date = formatter.date(from: dateString) {
- let displayFormatter = DateFormatter()
- displayFormatter.dateStyle = .medium
- return displayFormatter.string(from: date)
- }
- return dateString
- }
}
diff --git a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift
index b37d93f..3c8c452 100644
--- a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift
+++ b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift
@@ -8,7 +8,7 @@ struct CompletionCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
- Text(formatDate(completion.completionDate))
+ Text(DateUtils.formatDateMedium(completion.completionDate))
.font(.caption)
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary)
@@ -99,21 +99,4 @@ struct CompletionCardView: View {
PhotoViewerSheet(images: completion.images)
}
}
-
- private func formatDate(_ dateString: String) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
- if let date = formatter.date(from: dateString) {
- formatter.dateStyle = .medium
- formatter.timeStyle = .none
- return formatter.string(from: date)
- }
- // Try without time
- formatter.dateFormat = "yyyy-MM-dd"
- if let date = formatter.date(from: dateString) {
- formatter.dateStyle = .medium
- return formatter.string(from: date)
- }
- return dateString
- }
}
diff --git a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift
index 896363a..95e9dc3 100644
--- a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift
+++ b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift
@@ -50,7 +50,7 @@ struct DynamicTaskCard: View {
Spacer()
if let due_date = task.dueDate {
- Label(formatDate(due_date), systemImage: "calendar")
+ Label(DateUtils.formatDate(due_date), systemImage: "calendar")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
@@ -127,16 +127,6 @@ struct DynamicTaskCard: View {
}
}
- private func formatDate(_ dateString: String) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd"
- if let date = formatter.date(from: dateString) {
- formatter.dateStyle = .medium
- return formatter.string(from: date)
- }
- return dateString
- }
-
// MARK: - Menu Content
@ViewBuilder
diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift
index 2843a63..b5199a1 100644
--- a/iosApp/iosApp/Subviews/Task/TaskCard.swift
+++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift
@@ -63,7 +63,7 @@ struct TaskCard: View {
Image(systemName: "calendar")
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.7))
- Text(formatDate(dueDate))
+ Text(DateUtils.formatDate(dueDate))
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
}
@@ -240,16 +240,6 @@ struct TaskCard: View {
.cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
}
-
- private func formatDate(_ dateString: String) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "yyyy-MM-dd"
- if let date = formatter.date(from: dateString) {
- formatter.dateStyle = .medium
- return formatter.string(from: date)
- }
- return dateString
- }
}
#Preview {
diff --git a/iosApp/iosApp/Task/CompletionHistorySheet.swift b/iosApp/iosApp/Task/CompletionHistorySheet.swift
index cb5e56f..ec75460 100644
--- a/iosApp/iosApp/Task/CompletionHistorySheet.swift
+++ b/iosApp/iosApp/Task/CompletionHistorySheet.swift
@@ -138,7 +138,7 @@ struct CompletionHistoryCard: View {
// Header with date and completed by
HStack {
VStack(alignment: .leading, spacing: 4) {
- Text(formatDate(completion.completionDate))
+ Text(DateUtils.formatDateTimeWithTime(completion.completionDate))
.font(.headline)
.foregroundColor(Color.appTextPrimary)
@@ -254,29 +254,6 @@ struct CompletionHistoryCard: View {
PhotoViewerSheet(images: completion.images)
}
}
-
- private func formatDate(_ dateString: String) -> String {
- let formatters = [
- "yyyy-MM-dd'T'HH:mm:ssZ",
- "yyyy-MM-dd'T'HH:mm:ss.SSSZ",
- "yyyy-MM-dd'T'HH:mm:ss",
- "yyyy-MM-dd"
- ]
-
- let inputFormatter = DateFormatter()
- let outputFormatter = DateFormatter()
- outputFormatter.dateStyle = .long
- outputFormatter.timeStyle = .short
-
- for format in formatters {
- inputFormatter.dateFormat = format
- if let date = inputFormatter.date(from: dateString) {
- return outputFormatter.string(from: date)
- }
- }
-
- return dateString
- }
}
#Preview {
diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements
index 139fdb4..b64e948 100644
--- a/iosApp/iosApp/iosApp.entitlements
+++ b/iosApp/iosApp/iosApp.entitlements
@@ -10,7 +10,7 @@
com.apple.security.application-groups
- group.com.tt.mycrib.MyCribDev
+ group.com.tt.casera.CaseraDev