New framework: - AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings - AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative, .a11yButton, .a11yCard, .a11yStatValue View extensions Shared components: decorative elements hidden, stat views combined, status/priority badges labeled, error views announced, empty states grouped Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard, DocumentCard, WarrantyCard — all grouped with combined labels, chevrons hidden, action buttons labeled Main screens: Login, Register, Residences, Tasks, Contractors, Documents — toolbar buttons labeled, section headers marked, form field hints added Onboarding: all 10 views — header traits, button hints, task selection state, progress indicator, decorative backgrounds hidden Profile/Subscription: toggle hints, theme selection state, feature comparison table accessibility, subscription button labels iOS build verified: BUILD SUCCEEDED
261 lines
8.4 KiB
Swift
261 lines
8.4 KiB
Swift
import SwiftUI
|
|
import ComposeApp
|
|
|
|
struct SummaryCard: View {
|
|
let summary: TotalSummary
|
|
@Environment(\.colorScheme) var colorScheme
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// 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)
|
|
.accessibilityAddTraits(.isHeader)
|
|
|
|
// Main Stats Row
|
|
HStack(spacing: 0) {
|
|
OrganicStatItem(
|
|
icon: "outline",
|
|
value: "\(summary.totalResidences)",
|
|
label: "Properties",
|
|
accentColor: Color.appPrimary
|
|
)
|
|
|
|
// Vertical divider
|
|
Rectangle()
|
|
.fill(Color.appTextSecondary.opacity(0.12))
|
|
.frame(width: 1, height: 50)
|
|
|
|
OrganicStatItem(
|
|
icon: "checklist",
|
|
value: "\(summary.totalTasks)",
|
|
label: "Total Tasks",
|
|
accentColor: Color.appSecondary
|
|
)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.bottom, 16)
|
|
|
|
// Organic divider
|
|
OrganicDivider()
|
|
.padding(.horizontal, 24)
|
|
|
|
// Timeline Stats
|
|
HStack(spacing: 8) {
|
|
TimelineStatPill(
|
|
icon: "exclamationmark.circle.fill",
|
|
value: "\(summary.totalOverdue)",
|
|
label: "Overdue",
|
|
color: summary.totalOverdue > 0 ? Color.appError : Color.appTextSecondary,
|
|
isAlert: summary.totalOverdue > 0
|
|
)
|
|
|
|
TimelineStatPill(
|
|
icon: "clock.fill",
|
|
value: "\(summary.tasksDueNextWeek)",
|
|
label: "Next 7 Days",
|
|
color: Color.appAccent
|
|
)
|
|
|
|
TimelineStatPill(
|
|
icon: "arrow.forward.circle.fill",
|
|
value: "\(summary.tasksDueNextMonth)",
|
|
label: "Next 30 Days",
|
|
color: Color.appPrimary.opacity(0.7)
|
|
)
|
|
}
|
|
.padding(.horizontal, OrganicSpacing.cozy)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, OrganicSpacing.cozy)
|
|
}
|
|
.background(SummaryCardBackground())
|
|
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
|
.naturalShadow(.pronounced)
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Organic Stat Item
|
|
|
|
private struct OrganicStatItem: View {
|
|
let icon: String
|
|
let value: String
|
|
let label: String
|
|
var accentColor: Color = Color.appPrimary
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
// Icon with soft background
|
|
ZStack {
|
|
Circle()
|
|
.fill(accentColor.opacity(0.12))
|
|
.frame(width: 40, height: 40)
|
|
|
|
if icon == "outline" {
|
|
Image("outline")
|
|
.renderingMode(.template)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 20, height: 20)
|
|
.foregroundColor(accentColor)
|
|
} else {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundColor(accentColor)
|
|
}
|
|
}
|
|
|
|
// Value
|
|
Text(value)
|
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
|
.foregroundColor(Color.appTextPrimary)
|
|
|
|
// Label
|
|
Text(label)
|
|
.font(.system(size: 12, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.accessibilityElement(children: .combine)
|
|
}
|
|
}
|
|
|
|
// MARK: - Timeline Stat Pill
|
|
|
|
private struct TimelineStatPill: View {
|
|
let icon: String
|
|
let value: String
|
|
let label: String
|
|
var color: Color = Color.appPrimary
|
|
var isAlert: Bool = false
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundColor(color)
|
|
|
|
Text(value)
|
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
|
.foregroundColor(isAlert ? color : Color.appTextPrimary)
|
|
}
|
|
|
|
Text(label)
|
|
.font(.system(size: 13, weight: .medium))
|
|
.foregroundColor(Color.appTextSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.accessibilityElement(children: .combine)
|
|
.padding(.vertical, 18)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(
|
|
isAlert
|
|
? color.opacity(0.08)
|
|
: Color.appBackgroundPrimary.opacity(0.5)
|
|
)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.stroke(
|
|
isAlert ? color.opacity(0.2) : Color.clear,
|
|
lineWidth: 1
|
|
)
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Summary Card Background
|
|
|
|
private struct SummaryCardBackground: View {
|
|
@Environment(\.colorScheme) var colorScheme
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// Base gradient
|
|
LinearGradient(
|
|
colors: [
|
|
Color.appBackgroundSecondary,
|
|
Color.appBackgroundSecondary.opacity(0.95)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
|
|
// Decorative blob in top-right
|
|
GeometryReader { geo in
|
|
OrganicBlobShape(variation: 0)
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05),
|
|
Color.appPrimary.opacity(0.01)
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: geo.size.width * 0.4
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.8)
|
|
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.15)
|
|
.blur(radius: 25)
|
|
}
|
|
|
|
// Secondary blob in bottom-left
|
|
GeometryReader { geo in
|
|
OrganicBlobShape(variation: 2)
|
|
.fill(
|
|
RadialGradient(
|
|
colors: [
|
|
Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04),
|
|
Color.appAccent.opacity(0.01)
|
|
],
|
|
center: .center,
|
|
startRadius: 0,
|
|
endRadius: geo.size.width * 0.3
|
|
)
|
|
)
|
|
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5)
|
|
.offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.6)
|
|
.blur(radius: 20)
|
|
}
|
|
|
|
// Grain texture
|
|
GrainTexture(opacity: 0.015)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview
|
|
|
|
#Preview("Summary Card") {
|
|
VStack(spacing: 24) {
|
|
SummaryCard(summary: TotalSummary(
|
|
totalResidences: 3,
|
|
totalTasks: 24,
|
|
totalPending: 8,
|
|
totalOverdue: 2,
|
|
tasksDueNextWeek: 5,
|
|
tasksDueNextMonth: 12
|
|
))
|
|
|
|
SummaryCard(summary: TotalSummary(
|
|
totalResidences: 1,
|
|
totalTasks: 8,
|
|
totalPending: 3,
|
|
totalOverdue: 0,
|
|
tasksDueNextWeek: 2,
|
|
tasksDueNextMonth: 4
|
|
))
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 24)
|
|
.background(WarmGradientBackground())
|
|
}
|