Add centralized DateUtils and enhance contractor detail views

- Add DateUtils.kt for shared Kotlin date formatting with formatDate,
  formatDateMedium, formatDateTime, formatRelativeDate, and isOverdue
- Add DateUtils.swift for iOS with matching date formatting functions
- Enhance ContractorDetailScreen (Android) with quick action buttons
  (call, email, website, directions), clickable contact rows, residence
  association, statistics section, and metadata
- Enhance ContractorDetailView (iOS) with same features, refactored into
  smaller @ViewBuilder functions to fix Swift compiler type-check timeout
- Fix empty string handling in iOS - check !isEmpty in addition to != nil
  for optional fields like phone, email, website, address
- Update various task and document views to use centralized DateUtils

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-01 14:08:45 -06:00
parent 94781f4c48
commit c07821711f
22 changed files with 1329 additions and 322 deletions

View File

@@ -16,6 +16,7 @@ import com.example.casera.models.TaskCompletionResponse
import com.example.casera.models.TaskCompletion import com.example.casera.models.TaskCompletion
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
import com.example.casera.network.APILayer import com.example.casera.network.APILayer
import com.example.casera.util.DateUtils
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** /**
@@ -259,7 +260,7 @@ private fun CompletionHistoryCard(completion: TaskCompletionResponse) {
) { ) {
Column { Column {
Text( Text(
text = formatCompletionDate(completion.completedAt), text = DateUtils.formatDateMedium(completion.completedAt),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface 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
}
}

View File

@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.casera.util.DateUtils
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable @Composable
@@ -77,7 +78,7 @@ fun SimpleTaskListItem(
) )
if (dueDate != null) { if (dueDate != null) {
Text( Text(
text = "Due: $dueDate", text = "Due: ${DateUtils.formatDate(dueDate)}",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = if (isOverdue) color = if (isOverdue)
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error

View File

@@ -20,6 +20,7 @@ import com.example.casera.models.TaskPriority
import com.example.casera.models.TaskFrequency import com.example.casera.models.TaskFrequency
import com.example.casera.models.TaskStatus import com.example.casera.models.TaskStatus
import com.example.casera.models.TaskCompletion import com.example.casera.models.TaskCompletion
import com.example.casera.util.DateUtils
import org.jetbrains.compose.ui.tooling.preview.Preview import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable @Composable
@@ -167,7 +168,7 @@ fun TaskCard(
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = task.nextScheduledDate ?: task.dueDate ?: "N/A", text = DateUtils.formatDate(task.nextScheduledDate ?: task.dueDate) ?: "N/A",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )

View File

@@ -1,6 +1,7 @@
package com.example.casera.ui.screens package com.example.casera.ui.screens
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -14,12 +15,17 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color 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.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel 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.AddContractorDialog
import com.example.casera.ui.components.ApiResultHandler import com.example.casera.ui.components.ApiResultHandler
import com.example.casera.ui.components.HandleErrors import com.example.casera.ui.components.HandleErrors
import com.example.casera.util.DateUtils
import com.example.casera.viewmodel.ContractorViewModel import com.example.casera.viewmodel.ContractorViewModel
import com.example.casera.network.ApiResult import com.example.casera.network.ApiResult
@@ -108,12 +114,15 @@ fun ContractorDetailScreen(
.padding(padding) .padding(padding)
.background(Color(0xFFF9FAFB)) .background(Color(0xFFF9FAFB))
) { ) {
val uriHandler = LocalUriHandler.current
val residences = DataCache.residences.value
ApiResultHandler( ApiResultHandler(
state = contractorState, state = contractorState,
onRetry = { viewModel.loadContractorDetail(contractorId) }, onRetry = { viewModel.loadContractorDetail(contractorId) },
errorTitle = "Failed to Load Contractor", errorTitle = "Failed to Load Contractor",
loadingContent = { loadingContent = {
CircularProgressIndicator(color = Color(0xFF2563EB)) CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
} }
) { contractor -> ) { contractor ->
LazyColumn( LazyColumn(
@@ -126,7 +135,7 @@ fun ContractorDetailScreen(
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) { ) {
Column( Column(
@@ -140,14 +149,14 @@ fun ContractorDetailScreen(
modifier = Modifier modifier = Modifier
.size(80.dp) .size(80.dp)
.clip(CircleShape) .clip(CircleShape)
.background(Color(0xFFEEF2FF)), .background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
Icons.Default.Person, Icons.Default.Person,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(48.dp), modifier = Modifier.size(48.dp),
tint = Color(0xFF3B82F6) tint = MaterialTheme.colorScheme.primary
) )
} }
@@ -157,42 +166,43 @@ fun ContractorDetailScreen(
text = contractor.name, text = contractor.name,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color(0xFF111827) color = MaterialTheme.colorScheme.onSurface
) )
if (contractor.company != null) { if (contractor.company != null) {
Text( Text(
text = contractor.company, text = contractor.company,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = Color(0xFF6B7280) color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
if (contractor.specialties.isNotEmpty()) { if (contractor.specialties.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
contractor.specialties.forEach { specialty -> contractor.specialties.forEach { specialty ->
Surface( Surface(
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
color = Color(0xFFEEF2FF) color = MaterialTheme.colorScheme.primaryContainer
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
Icons.Default.WorkOutline, Icons.Default.Build,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
tint = Color(0xFF3B82F6) tint = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = specialty.name, text = specialty.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF3B82F6), color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
} }
@@ -214,10 +224,10 @@ fun ContractorDetailScreen(
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "${(contractor.rating * 10).toInt() / 10.0}", text = ((contractor.rating * 10).toInt() / 10.0).toString(),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color(0xFF111827) color = MaterialTheme.colorScheme.onSurface
) )
} }
} }
@@ -227,7 +237,80 @@ fun ContractorDetailScreen(
Text( Text(
text = "${contractor.taskCount} completed tasks", text = "${contractor.taskCount} completed tasks",
style = MaterialTheme.typography.bodySmall, 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 // Contact Information
item { item {
DetailSection(title = "Contact Information") { DetailSection(title = "Contact Information") {
if (contractor.phone != null) { contractor.phone?.let { phone ->
DetailRow( ClickableDetailRow(
icon = Icons.Default.Phone, icon = Icons.Default.Phone,
label = "Phone", label = "Phone",
value = contractor.phone, value = phone,
iconTint = Color(0xFF3B82F6) iconTint = MaterialTheme.colorScheme.primary,
onClick = {
try {
uriHandler.openUri("tel:${phone.replace(" ", "")}")
} catch (e: Exception) { /* Handle error */ }
}
) )
} }
if (contractor.email != null) { contractor.email?.let { email ->
DetailRow( ClickableDetailRow(
icon = Icons.Default.Email, icon = Icons.Default.Email,
label = "Email", label = "Email",
value = contractor.email, value = email,
iconTint = Color(0xFF8B5CF6) iconTint = MaterialTheme.colorScheme.secondary,
onClick = {
try {
uriHandler.openUri("mailto:$email")
} catch (e: Exception) { /* Handle error */ }
}
) )
} }
if (contractor.website != null) { contractor.website?.let { website ->
DetailRow( ClickableDetailRow(
icon = Icons.Default.Language, icon = Icons.Default.Language,
label = "Website", label = "Website",
value = contractor.website, value = website,
iconTint = Color(0xFFF59E0B) 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()) { if (fullAddress.isNotBlank()) {
DetailRow( ClickableDetailRow(
icon = Icons.Default.LocationOn, icon = Icons.Default.LocationOn,
label = "Location", label = "Location",
value = fullAddress, 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 // Associated Property
if (contractor.notes != null) { contractor.residenceId?.let { resId ->
val residenceName = residences.find { r -> r.id == resId }?.name
?: "Property #$resId"
item { item {
DetailSection(title = "Notes") { DetailSection(title = "Associated Property") {
Text( DetailRow(
text = contractor.notes, icon = Icons.Default.Home,
style = MaterialTheme.typography.bodyMedium, label = "Property",
color = Color(0xFF374151), value = residenceName,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) 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 { item {
DetailSection(title = "Task History") { DetailSection(title = "Statistics") {
// Placeholder for task history
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceEvenly
horizontalArrangement = Arrangement.Center
) { ) {
Icon( StatCard(
Icons.Default.CheckCircle, icon = Icons.Default.CheckCircle,
contentDescription = null, value = contractor.taskCount.toString(),
tint = Color(0xFF10B981) label = "Tasks\nCompleted",
) color = MaterialTheme.colorScheme.primary
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${contractor.taskCount} completed tasks",
color = Color(0xFF6B7280),
style = MaterialTheme.typography.bodyMedium
) )
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( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color.White), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) { ) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
@@ -397,7 +567,7 @@ fun DetailSection(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = Color(0xFF111827), color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(16.dp).padding(bottom = 0.dp) modifier = Modifier.padding(16.dp).padding(bottom = 0.dp)
) )
content() content()
@@ -407,10 +577,10 @@ fun DetailSection(
@Composable @Composable
fun DetailRow( fun DetailRow(
icon: androidx.compose.ui.graphics.vector.ImageVector, icon: ImageVector,
label: String, label: String,
value: String, value: String,
iconTint: Color = Color(0xFF6B7280) iconTint: Color = MaterialTheme.colorScheme.onSurfaceVariant
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@@ -429,14 +599,143 @@ fun DetailRow(
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280) color = MaterialTheme.colorScheme.onSurfaceVariant
) )
Text( Text(
text = value, text = value,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF111827), color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium 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
)
}
}

View File

@@ -34,6 +34,7 @@ import androidx.compose.foundation.lazy.grid.items
import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent import coil3.compose.SubcomposeAsyncImageContent
import coil3.compose.AsyncImagePainter import coil3.compose.AsyncImagePainter
import com.example.casera.util.DateUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -257,9 +258,9 @@ fun DocumentDetailScreen(
) )
Divider() Divider()
document.purchaseDate?.let { DetailRow("Purchase Date", it) } document.purchaseDate?.let { DetailRow("Purchase Date", DateUtils.formatDateMedium(it)) }
document.startDate?.let { DetailRow("Start Date", it) } document.startDate?.let { DetailRow("Start Date", DateUtils.formatDateMedium(it)) }
document.endDate?.let { DetailRow("End Date", it) } document.endDate?.let { DetailRow("End Date", DateUtils.formatDateMedium(it)) }
} }
} }
} }
@@ -405,8 +406,8 @@ fun DocumentDetailScreen(
Divider() Divider()
document.uploadedByUsername?.let { DetailRow("Uploaded By", it) } document.uploadedByUsername?.let { DetailRow("Uploaded By", it) }
document.createdAt?.let { DetailRow("Created", it) } document.createdAt?.let { DetailRow("Created", DateUtils.formatDateMedium(it)) }
document.updatedAt?.let { DetailRow("Updated", it) } document.updatedAt?.let { DetailRow("Updated", DateUtils.formatDateMedium(it)) }
} }
} }
} }

View File

@@ -34,6 +34,7 @@ import com.example.casera.utils.SubscriptionHelper
import com.example.casera.ui.subscription.UpgradePromptDialog import com.example.casera.ui.subscription.UpgradePromptDialog
import com.example.casera.cache.SubscriptionCache import com.example.casera.cache.SubscriptionCache
import com.example.casera.cache.DataCache import com.example.casera.cache.DataCache
import com.example.casera.util.DateUtils
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -598,7 +599,7 @@ fun ResidenceDetailScreen(
title = "Purchase Information" title = "Purchase Information"
) { ) {
residence.purchaseDate?.let { residence.purchaseDate?.let {
DetailRow(Icons.Default.Event, "Purchase Date", it) DetailRow(Icons.Default.Event, "Purchase Date", DateUtils.formatDateMedium(it))
} }
residence.purchasePrice?.let { residence.purchasePrice?.let {
DetailRow(Icons.Default.Payment, "Purchase Price", "$$it") DetailRow(Icons.Default.Payment, "Purchase Price", "$$it")

View File

@@ -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
}
}
}

View File

@@ -262,10 +262,17 @@ struct MediumWidgetView: View {
.font(.system(size: 42, weight: .bold)) .font(.system(size: 42, weight: .bold))
.foregroundStyle(.blue) .foregroundStyle(.blue)
Text(entry.taskCount == 1 ? "upcoming\n task" : "upcoming\ntasks") VStack(alignment: .leading) {
.font(.system(size: 11, weight: .medium)) Text("upcoming:")
.foregroundStyle(.secondary) .font(.system(size: 11, weight: .medium))
.lineLimit(2) .foregroundStyle(.secondary)
.lineLimit(2)
Text(entry.taskCount == 1 ? "task" : "tasks")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer() Spacer()
} }

View File

@@ -4,7 +4,7 @@
<dict> <dict>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.tt.mycrib.MyCribDev</string> <string>group.com.tt.casera.CaseraDev</string>
</array> </array>
</dict> </dict>
</plist> </plist>

View File

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "mycrib-icon@2x.png", "filename" : "icon.png",
"idiom" : "universal", "idiom" : "universal",
"platform" : "ios", "platform" : "ios",
"size" : "1024x1024" "size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 464 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

View File

@@ -3,7 +3,9 @@ import ComposeApp
struct ContractorDetailView: View { struct ContractorDetailView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = ContractorViewModel() @StateObject private var viewModel = ContractorViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
let contractorId: Int32 let contractorId: Int32
@@ -13,149 +15,10 @@ struct ContractorDetailView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.appBackgroundPrimary.ignoresSafeArea() Color.appBackgroundPrimary.ignoresSafeArea()
contentStateView
if viewModel.isLoading { }
ProgressView() .onAppear {
.scaleEffect(1.2) residenceViewModel.loadMyResidences()
} 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)
}
}
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .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 // MARK: - Detail Section
@@ -276,3 +627,103 @@ struct DetailRow: View {
.padding(AppSpacing.md) .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)
}
}

View File

@@ -83,7 +83,7 @@ struct WarrantyCard: View {
Text("Expires") Text("Expires")
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
Text(document.endDate ?? "N/A") Text(DateUtils.formatDateMedium(document.endDate) ?? "N/A")
.font(.body) .font(.body)
.fontWeight(.medium) .fontWeight(.medium)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)

View File

@@ -188,13 +188,13 @@ struct DocumentDetailView: View {
sectionHeader("Important Dates") sectionHeader("Important Dates")
if let purchaseDate = document.purchaseDate { if let purchaseDate = document.purchaseDate {
detailRow(label: "Purchase Date", value: purchaseDate) detailRow(label: "Purchase Date", value: DateUtils.formatDateMedium(purchaseDate))
} }
if let startDate = document.startDate { if let startDate = document.startDate {
detailRow(label: "Start Date", value: startDate) detailRow(label: "Start Date", value: DateUtils.formatDateMedium(startDate))
} }
if let endDate = document.endDate { if let endDate = document.endDate {
detailRow(label: "End Date", value: endDate) detailRow(label: "End Date", value: DateUtils.formatDateMedium(endDate))
} }
} }
.padding() .padding()
@@ -331,10 +331,10 @@ struct DocumentDetailView: View {
detailRow(label: "Uploaded By", value: uploadedBy) detailRow(label: "Uploaded By", value: uploadedBy)
} }
if let createdAt = document.createdAt { if let createdAt = document.createdAt {
detailRow(label: "Created", value: createdAt) detailRow(label: "Created", value: DateUtils.formatDateTime(createdAt))
} }
if let updatedAt = document.updatedAt { if let updatedAt = document.updatedAt {
detailRow(label: "Updated", value: updatedAt) detailRow(label: "Updated", value: DateUtils.formatDateTime(updatedAt))
} }
} }
.padding() .padding()

View File

@@ -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
}
}

View File

@@ -76,7 +76,7 @@ struct ProfileTabView: View {
if subscriptionCache.currentTier == "pro", if subscriptionCache.currentTier == "pro",
let expiresAt = subscription.expiresAt { let expiresAt = subscription.expiresAt {
Text("Active until \(formatDate(expiresAt))") Text("Active until \(DateUtils.formatDateMedium(expiresAt))")
.font(.caption) .font(.caption)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} else { } else {
@@ -193,14 +193,4 @@ struct ProfileTabView: View {
Text("Your purchases have been restored successfully.") 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
}
} }

View File

@@ -8,7 +8,7 @@ struct CompletionCardView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
HStack { HStack {
Text(formatDate(completion.completionDate)) Text(DateUtils.formatDateMedium(completion.completionDate))
.font(.caption) .font(.caption)
.fontWeight(.semibold) .fontWeight(.semibold)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
@@ -99,21 +99,4 @@ struct CompletionCardView: View {
PhotoViewerSheet(images: completion.images) 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
}
} }

View File

@@ -50,7 +50,7 @@ struct DynamicTaskCard: View {
Spacer() Spacer()
if let due_date = task.dueDate { if let due_date = task.dueDate {
Label(formatDate(due_date), systemImage: "calendar") Label(DateUtils.formatDate(due_date), systemImage: "calendar")
.font(.caption) .font(.caption)
.foregroundColor(Color.appTextSecondary) .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 // MARK: - Menu Content
@ViewBuilder @ViewBuilder

View File

@@ -63,7 +63,7 @@ struct TaskCard: View {
Image(systemName: "calendar") Image(systemName: "calendar")
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary.opacity(0.7)) .foregroundColor(Color.appTextSecondary.opacity(0.7))
Text(formatDate(dueDate)) Text(DateUtils.formatDate(dueDate))
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
@@ -240,16 +240,6 @@ struct TaskCard: View {
.cornerRadius(AppRadius.lg) .cornerRadius(AppRadius.lg)
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) .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 { #Preview {

View File

@@ -138,7 +138,7 @@ struct CompletionHistoryCard: View {
// Header with date and completed by // Header with date and completed by
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(formatDate(completion.completionDate)) Text(DateUtils.formatDateTimeWithTime(completion.completionDate))
.font(.headline) .font(.headline)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
@@ -254,29 +254,6 @@ struct CompletionHistoryCard: View {
PhotoViewerSheet(images: completion.images) 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 { #Preview {

View File

@@ -10,7 +10,7 @@
</array> </array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>
<string>group.com.tt.mycrib.MyCribDev</string> <string>group.com.tt.casera.CaseraDev</string>
</array> </array>
</dict> </dict>
</plist> </plist>