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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user