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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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")