Fix task stats consistency and improve residence card UI
- Compute task stats locally from kanban data for both summary card and residence cards - Filter out completed_tasks and cancelled_tasks columns from counts - Use startOfDay for accurate date comparisons (overdue, due this week, next 30 days) - Add parseDate helper to DateUtils - Make address tappable to open in Apple Maps - Remove navigation title from residences list - Update CLAUDE.md with Go backend references and DataManager architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,16 @@ enum DateUtils {
|
||||
return date < today
|
||||
}
|
||||
|
||||
/// Parse a date string (YYYY-MM-DD or ISO datetime) into a Date object
|
||||
static func parseDate(_ dateString: String?) -> Date? {
|
||||
guard let dateString = dateString, !dateString.isEmpty else { return nil }
|
||||
|
||||
// Extract date part if it includes time
|
||||
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||
|
||||
return isoDateFormatter.date(from: datePart)
|
||||
}
|
||||
|
||||
// MARK: - Timezone Conversion Utilities
|
||||
|
||||
/// Convert a local hour (0-23) to UTC hour
|
||||
|
||||
@@ -43,18 +43,6 @@
|
||||
"comment" : "A message displayed when a contractor is successfully imported to the user's contacts. The placeholder is replaced with the name of the imported contractor.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"%@, %@" : {
|
||||
"comment" : "A city and state combination.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@, %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%@, %@ %@" : {
|
||||
"comment" : "A label displaying the city, state, and postal code of the residence.",
|
||||
"isCommentAutoGenerated" : true,
|
||||
@@ -17483,9 +17471,6 @@
|
||||
},
|
||||
"or" : {
|
||||
|
||||
},
|
||||
"Overdue" : {
|
||||
|
||||
},
|
||||
"Overview" : {
|
||||
"comment" : "The title of the overview card.",
|
||||
|
||||
@@ -24,8 +24,8 @@ struct ResidencesListView: View {
|
||||
errorMessage: viewModel.errorMessage,
|
||||
content: { residences in
|
||||
ResidencesContent(
|
||||
summary: viewModel.totalSummary ?? TotalSummary(totalResidences: Int32(residences.count), totalTasks: 0, totalPending: 0, totalOverdue: 0, tasksDueNextWeek: 0, tasksDueNextMonth: 0),
|
||||
residences: residences
|
||||
residences: residences,
|
||||
tasksResponse: taskViewModel.tasksResponse
|
||||
)
|
||||
},
|
||||
emptyContent: {
|
||||
@@ -46,7 +46,6 @@ struct ResidencesListView: View {
|
||||
})
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.Residences.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -171,49 +170,132 @@ private struct OrganicToolbarButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Residence Task Stats
|
||||
struct ResidenceTaskStats {
|
||||
let totalCount: Int
|
||||
let overdueCount: Int
|
||||
let dueThisWeekCount: Int
|
||||
let dueNext30DaysCount: Int
|
||||
}
|
||||
|
||||
// MARK: - Residences Content View
|
||||
|
||||
private struct ResidencesContent: View {
|
||||
let summary: TotalSummary
|
||||
let residences: [ResidenceResponse]
|
||||
let tasksResponse: TaskColumnsResponse?
|
||||
|
||||
/// Extract active tasks - skip completed_tasks and cancelled_tasks columns
|
||||
private var activeTasks: [TaskResponse] {
|
||||
guard let response = tasksResponse else { return [] }
|
||||
|
||||
var tasks: [TaskResponse] = []
|
||||
for column in response.columns {
|
||||
// Skip completed and cancelled columns (cancelled includes archived)
|
||||
let columnName = column.name.lowercased()
|
||||
if columnName == "completed_tasks" || columnName == "cancelled_tasks" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Add tasks from this column
|
||||
for task in column.tasks {
|
||||
tasks.append(task)
|
||||
}
|
||||
}
|
||||
return tasks
|
||||
}
|
||||
|
||||
/// Compute total summary from task data using date logic
|
||||
private var computedSummary: TotalSummary {
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let in7Days = calendar.date(byAdding: .day, value: 7, to: today) ?? today
|
||||
let in30Days = calendar.date(byAdding: .day, value: 30, to: today) ?? today
|
||||
|
||||
var overdueCount: Int32 = 0
|
||||
var dueThisWeekCount: Int32 = 0
|
||||
var dueNext30DaysCount: Int32 = 0
|
||||
|
||||
for task in activeTasks {
|
||||
guard let dueDateStr = task.effectiveDueDate,
|
||||
let dueDate = DateUtils.parseDate(dueDateStr) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let taskDate = calendar.startOfDay(for: dueDate)
|
||||
|
||||
if taskDate < today {
|
||||
overdueCount += 1
|
||||
} else if taskDate <= in7Days {
|
||||
dueThisWeekCount += 1
|
||||
} else if taskDate <= in30Days {
|
||||
dueNext30DaysCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return TotalSummary(
|
||||
totalResidences: Int32(residences.count),
|
||||
totalTasks: Int32(activeTasks.count),
|
||||
totalPending: 0,
|
||||
totalOverdue: overdueCount,
|
||||
tasksDueNextWeek: dueThisWeekCount,
|
||||
tasksDueNextMonth: dueNext30DaysCount
|
||||
)
|
||||
}
|
||||
|
||||
/// Get task stats for a specific residence
|
||||
private func taskStats(for residenceId: Int32) -> ResidenceTaskStats {
|
||||
let residenceTasks = activeTasks.filter { $0.residenceId == residenceId }
|
||||
let calendar = Calendar.current
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let in7Days = calendar.date(byAdding: .day, value: 7, to: today) ?? today
|
||||
let in30Days = calendar.date(byAdding: .day, value: 30, to: today) ?? today
|
||||
|
||||
var overdueCount = 0
|
||||
var dueThisWeekCount = 0
|
||||
var dueNext30DaysCount = 0
|
||||
|
||||
for task in residenceTasks {
|
||||
guard let dueDateStr = task.effectiveDueDate,
|
||||
let dueDate = DateUtils.parseDate(dueDateStr) else {
|
||||
continue
|
||||
}
|
||||
|
||||
let taskDate = calendar.startOfDay(for: dueDate)
|
||||
|
||||
if taskDate < today {
|
||||
overdueCount += 1
|
||||
} else if taskDate <= in7Days {
|
||||
dueThisWeekCount += 1
|
||||
} else if taskDate <= in30Days {
|
||||
dueNext30DaysCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
return ResidenceTaskStats(
|
||||
totalCount: residenceTasks.count,
|
||||
overdueCount: overdueCount,
|
||||
dueThisWeekCount: dueThisWeekCount,
|
||||
dueNext30DaysCount: dueNext30DaysCount
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// Summary Card with enhanced styling
|
||||
SummaryCard(summary: summary)
|
||||
SummaryCard(summary: computedSummary)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Properties Section Header
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(L10n.Residences.yourProperties)
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)")
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Decorative leaf
|
||||
Image(systemName: "leaf.fill")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.3))
|
||||
.rotationEffect(.degrees(-15))
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Residences List with staggered animation
|
||||
LazyVStack(spacing: 16) {
|
||||
ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in
|
||||
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
||||
ResidenceCard(residence: residence)
|
||||
.padding(.horizontal, 16)
|
||||
ResidenceCard(
|
||||
residence: residence,
|
||||
taskStats: taskStats(for: residence.id)
|
||||
)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.buttonStyle(OrganicCardButtonStyle())
|
||||
.transition(.asymmetric(
|
||||
|
||||
@@ -3,15 +3,36 @@ import ComposeApp
|
||||
|
||||
struct ResidenceCard: View {
|
||||
let residence: ResidenceResponse
|
||||
let taskStats: ResidenceTaskStats
|
||||
|
||||
/// Check if this residence has any overdue tasks
|
||||
private var hasOverdueTasks: Bool {
|
||||
Int(residence.overdueCount) > 0
|
||||
taskStats.overdueCount > 0
|
||||
}
|
||||
|
||||
/// Get task summary categories (max 3)
|
||||
private var displayCategories: [TaskCategorySummary] {
|
||||
Array(residence.taskSummary.categories.prefix(3))
|
||||
/// Open the address in Apple Maps
|
||||
private func openInMaps() {
|
||||
var addressComponents: [String] = []
|
||||
if !residence.streetAddress.isEmpty {
|
||||
addressComponents.append(residence.streetAddress)
|
||||
}
|
||||
if !residence.city.isEmpty {
|
||||
addressComponents.append(residence.city)
|
||||
}
|
||||
if !residence.stateProvince.isEmpty {
|
||||
addressComponents.append(residence.stateProvince)
|
||||
}
|
||||
if !residence.postalCode.isEmpty {
|
||||
addressComponents.append(residence.postalCode)
|
||||
}
|
||||
|
||||
let address = addressComponents.joined(separator: ", ")
|
||||
guard let encodedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let url = URL(string: "maps://?address=\(encodedAddress)") else {
|
||||
return
|
||||
}
|
||||
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -37,17 +58,25 @@ struct ResidenceCard: View {
|
||||
.tracking(1.2)
|
||||
}
|
||||
|
||||
// Address
|
||||
// Address - tappable to open maps
|
||||
if !residence.streetAddress.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.7))
|
||||
Button(action: {
|
||||
openInMaps()
|
||||
}) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "mappin")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.7))
|
||||
|
||||
Text(residence.streetAddress)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(1)
|
||||
Text(residence.streetAddress)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
Image(systemName: "arrow.up.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||
}
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
@@ -64,41 +93,43 @@ struct ResidenceCard: View {
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
// Location Details (if available)
|
||||
if !residence.city.isEmpty || !residence.stateProvince.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.6))
|
||||
|
||||
Text("\(residence.city), \(residence.stateProvince)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.8))
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
|
||||
// Divider
|
||||
OrganicDivider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
// Task Stats Section
|
||||
if !displayCategories.isEmpty {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 10) {
|
||||
ForEach(displayCategories, id: \.name) { category in
|
||||
TaskCategoryChip(category: category)
|
||||
}
|
||||
if taskStats.totalCount > 0 {
|
||||
HStack(spacing: 0) {
|
||||
// Total Tasks
|
||||
TaskStatItem(
|
||||
value: taskStats.totalCount,
|
||||
label: "Tasks",
|
||||
color: Color.appPrimary
|
||||
)
|
||||
|
||||
// Show overdue count if any
|
||||
if hasOverdueTasks {
|
||||
OverdueChip(count: Int(residence.overdueCount))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 16)
|
||||
// Overdue
|
||||
TaskStatItem(
|
||||
value: taskStats.overdueCount,
|
||||
label: "Overdue",
|
||||
color: taskStats.overdueCount > 0 ? Color.appError : Color.appTextSecondary
|
||||
)
|
||||
|
||||
// Due This Week
|
||||
TaskStatItem(
|
||||
value: taskStats.dueThisWeekCount,
|
||||
label: "This Week",
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
// Next 30 Days
|
||||
TaskStatItem(
|
||||
value: taskStats.dueNext30DaysCount,
|
||||
label: "30 Days",
|
||||
color: Color.appPrimary.opacity(0.7)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 14)
|
||||
} else {
|
||||
// Empty state for tasks
|
||||
HStack(spacing: 8) {
|
||||
@@ -127,41 +158,25 @@ private struct PropertyIconView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle with gradient
|
||||
Circle()
|
||||
// Theme-colored background
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary,
|
||||
Color.appPrimary.opacity(0.85)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 52
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 52, height: 52)
|
||||
|
||||
// Inner highlight
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.25),
|
||||
Color.clear
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 26
|
||||
)
|
||||
)
|
||||
.frame(width: 52, height: 52)
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
// House icon
|
||||
Image("house_outline")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: 48, height: 48)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
|
||||
// Pulse ring for overdue
|
||||
@@ -214,90 +229,24 @@ private struct PrimaryBadgeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Category Chip
|
||||
// MARK: - Task Stat Item
|
||||
|
||||
private struct TaskCategoryChip: View {
|
||||
let category: TaskCategorySummary
|
||||
|
||||
private var chipColor: Color {
|
||||
Color(hex: category.color) ?? Color.appPrimary
|
||||
}
|
||||
private struct TaskStatItem: View {
|
||||
let value: Int
|
||||
let label: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
// Icon background
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(chipColor.opacity(0.15))
|
||||
.frame(width: 26, height: 26)
|
||||
HStack(spacing: 4) {
|
||||
Text("\(value)")
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(color)
|
||||
|
||||
Image(systemName: category.icons.ios)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(chipColor)
|
||||
}
|
||||
|
||||
// Count and label
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(category.count)")
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(category.displayName)
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appBackgroundPrimary.opacity(0.6))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(chipColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overdue Chip
|
||||
|
||||
private struct OverdueChip: View {
|
||||
let count: Int
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appError.opacity(0.15))
|
||||
.frame(width: 26, height: 26)
|
||||
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("\(count)")
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appError)
|
||||
|
||||
Text("Overdue")
|
||||
.font(.system(size: 9, weight: .medium))
|
||||
.foregroundColor(Color.appError.opacity(0.8))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appError.opacity(0.08))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.appError.opacity(0.25), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,63 +290,69 @@ private struct CardBackgroundView: View {
|
||||
#Preview("Residence Card") {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
ResidenceCard(residence: ResidenceResponse(
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Sunset Villa",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
streetAddress: "742 Evergreen Terrace",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94102",
|
||||
country: "USA",
|
||||
bedrooms: 4,
|
||||
bathrooms: 2.5,
|
||||
squareFootage: 2400,
|
||||
lotSize: 0.35,
|
||||
yearBuilt: 2018,
|
||||
description: "Beautiful modern home",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: true,
|
||||
isActive: true,
|
||||
overdueCount: 2,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
ResidenceCard(
|
||||
residence: ResidenceResponse(
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Sunset Villa",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
streetAddress: "742 Evergreen Terrace",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94102",
|
||||
country: "USA",
|
||||
bedrooms: 4,
|
||||
bathrooms: 2.5,
|
||||
squareFootage: 2400,
|
||||
lotSize: 0.35,
|
||||
yearBuilt: 2018,
|
||||
description: "Beautiful modern home",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: true,
|
||||
isActive: true,
|
||||
overdueCount: 2,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
taskStats: ResidenceTaskStats(totalCount: 8, overdueCount: 2, dueThisWeekCount: 3, dueNext30DaysCount: 2)
|
||||
)
|
||||
|
||||
ResidenceCard(residence: ResidenceResponse(
|
||||
id: 2,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Downtown Loft",
|
||||
propertyTypeId: 2,
|
||||
propertyType: ResidenceType(id: 2, name: "Apartment"),
|
||||
streetAddress: "100 Market Street, Unit 502",
|
||||
apartmentUnit: "502",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94105",
|
||||
country: "USA",
|
||||
bedrooms: 2,
|
||||
bathrooms: 1.0,
|
||||
squareFootage: 1100,
|
||||
lotSize: nil,
|
||||
yearBuilt: 2020,
|
||||
description: "",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: false,
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
ResidenceCard(
|
||||
residence: ResidenceResponse(
|
||||
id: 2,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"),
|
||||
users: [],
|
||||
name: "Downtown Loft",
|
||||
propertyTypeId: 2,
|
||||
propertyType: ResidenceType(id: 2, name: "Apartment"),
|
||||
streetAddress: "100 Market Street, Unit 502",
|
||||
apartmentUnit: "502",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94105",
|
||||
country: "USA",
|
||||
bedrooms: 2,
|
||||
bathrooms: 1.0,
|
||||
squareFootage: 1100,
|
||||
lotSize: nil,
|
||||
yearBuilt: 2020,
|
||||
description: "",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: false,
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
),
|
||||
taskStats: ResidenceTaskStats(totalCount: 0, overdueCount: 0, dueThisWeekCount: 0, dueNext30DaysCount: 0)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 24)
|
||||
|
||||
@@ -7,44 +7,14 @@ struct SummaryCard: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header with greeting
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(greetingText)
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
Text("Your Home Dashboard")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Decorative icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 28
|
||||
)
|
||||
)
|
||||
.frame(width: 56, height: 56)
|
||||
|
||||
Image(systemName: "house.lodge.fill")
|
||||
.font(.system(size: 24, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 20)
|
||||
// Header
|
||||
Text("Your Home Dashboard")
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
// Main Stats Row
|
||||
HStack(spacing: 0) {
|
||||
@@ -85,16 +55,16 @@ struct SummaryCard: View {
|
||||
)
|
||||
|
||||
TimelineStatPill(
|
||||
icon: "calendar.badge.clock",
|
||||
icon: "clock.fill",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
label: "This Week",
|
||||
label: "Due This Week",
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
TimelineStatPill(
|
||||
icon: "calendar",
|
||||
icon: "arrow.forward.circle.fill",
|
||||
value: "\(summary.tasksDueNextMonth)",
|
||||
label: "30 Days",
|
||||
label: "Next 30 Days",
|
||||
color: Color.appPrimary.opacity(0.7)
|
||||
)
|
||||
}
|
||||
@@ -107,19 +77,6 @@ struct SummaryCard: View {
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
private var greetingText: String {
|
||||
let hour = Calendar.current.component(.hour, from: Date())
|
||||
switch hour {
|
||||
case 5..<12:
|
||||
return "Good morning"
|
||||
case 12..<17:
|
||||
return "Good afternoon"
|
||||
case 17..<21:
|
||||
return "Good evening"
|
||||
default:
|
||||
return "Good night"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Stat Item
|
||||
@@ -167,32 +124,32 @@ private struct TimelineStatPill: View {
|
||||
var isAlert: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 4) {
|
||||
VStack(spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(isAlert ? color : Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.padding(.vertical, 18)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.fill(
|
||||
isAlert
|
||||
? color.opacity(0.08)
|
||||
: Color.appBackgroundPrimary.opacity(0.5)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(
|
||||
isAlert ? color.opacity(0.2) : Color.clear,
|
||||
lineWidth: 1
|
||||
|
||||
Reference in New Issue
Block a user