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