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.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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
BIN
iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png
Normal file
BIN
iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 464 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 638 KiB |
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
179
iosApp/iosApp/Helpers/DateUtils.swift
Normal file
179
iosApp/iosApp/Helpers/DateUtils.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user