Add Warm Organic design system to iOS app
- Add OrganicDesign.swift with reusable components: - WarmGradientBackground, OrganicBlobShape, GrainTexture - OrganicDivider, OrganicCardBackground, NaturalShadow modifier - OrganicSpacing constants (cozy, comfortable, spacious, airy) - Update high-priority screens with organic styling: - LoginView: hero glow, organic card background, rounded fonts - ResidenceDetailView, ResidencesListView: warm backgrounds - ResidenceCard, SummaryCard, PropertyHeaderCard: organic cards - TaskCard: metadata pills, secondary buttons, card background - TaskFormView: organic loading overlay, templates button - CompletionHistorySheet: organic loading/error/empty states - ProfileView, NotificationPreferencesView, ThemeSelectionView - Update task badges with icons and capsule styling: - PriorityBadge: priority-specific icons - StatusBadge: status-specific icons - Fix TaskCard isOverdue error using DateUtils.isOverdue() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
454
iosApp/iosApp/Design/OrganicDesign.swift
Normal file
454
iosApp/iosApp/Design/OrganicDesign.swift
Normal file
@@ -0,0 +1,454 @@
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Organic Design System
|
||||
// Warm, natural aesthetic with soft shapes, subtle textures, and flowing layouts
|
||||
|
||||
// MARK: - Organic Shapes
|
||||
|
||||
/// Soft organic blob shape for backgrounds
|
||||
struct OrganicBlobShape: Shape {
|
||||
var variation: Int = 0
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
let w = rect.width
|
||||
let h = rect.height
|
||||
|
||||
switch variation % 3 {
|
||||
case 0:
|
||||
// Soft cloud-like blob
|
||||
path.move(to: CGPoint(x: w * 0.1, y: h * 0.5))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.5, y: h * 0.05),
|
||||
control1: CGPoint(x: w * 0.0, y: h * 0.1),
|
||||
control2: CGPoint(x: w * 0.25, y: h * 0.0)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.95, y: h * 0.45),
|
||||
control1: CGPoint(x: w * 0.75, y: h * 0.1),
|
||||
control2: CGPoint(x: w * 1.0, y: h * 0.25)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.55, y: h * 0.95),
|
||||
control1: CGPoint(x: w * 0.9, y: h * 0.7),
|
||||
control2: CGPoint(x: w * 0.8, y: h * 0.95)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.1, y: h * 0.5),
|
||||
control1: CGPoint(x: w * 0.25, y: h * 0.95),
|
||||
control2: CGPoint(x: w * 0.05, y: h * 0.75)
|
||||
)
|
||||
case 1:
|
||||
// Pebble shape
|
||||
path.move(to: CGPoint(x: w * 0.15, y: h * 0.4))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.6, y: h * 0.08),
|
||||
control1: CGPoint(x: w * 0.1, y: h * 0.15),
|
||||
control2: CGPoint(x: w * 0.35, y: h * 0.05)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.9, y: h * 0.55),
|
||||
control1: CGPoint(x: w * 0.85, y: h * 0.12),
|
||||
control2: CGPoint(x: w * 0.95, y: h * 0.35)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.45, y: h * 0.92),
|
||||
control1: CGPoint(x: w * 0.85, y: h * 0.8),
|
||||
control2: CGPoint(x: w * 0.65, y: h * 0.95)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.15, y: h * 0.4),
|
||||
control1: CGPoint(x: w * 0.2, y: h * 0.88),
|
||||
control2: CGPoint(x: w * 0.08, y: h * 0.65)
|
||||
)
|
||||
default:
|
||||
// Leaf-like shape
|
||||
path.move(to: CGPoint(x: w * 0.05, y: h * 0.5))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.5, y: h * 0.02),
|
||||
control1: CGPoint(x: w * 0.05, y: h * 0.2),
|
||||
control2: CGPoint(x: w * 0.25, y: h * 0.02)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.95, y: h * 0.5),
|
||||
control1: CGPoint(x: w * 0.75, y: h * 0.02),
|
||||
control2: CGPoint(x: w * 0.95, y: h * 0.2)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.5, y: h * 0.98),
|
||||
control1: CGPoint(x: w * 0.95, y: h * 0.8),
|
||||
control2: CGPoint(x: w * 0.75, y: h * 0.98)
|
||||
)
|
||||
path.addCurve(
|
||||
to: CGPoint(x: w * 0.05, y: h * 0.5),
|
||||
control1: CGPoint(x: w * 0.25, y: h * 0.98),
|
||||
control2: CGPoint(x: w * 0.05, y: h * 0.8)
|
||||
)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// Super soft rounded rectangle for organic cards
|
||||
struct OrganicRoundedRectangle: Shape {
|
||||
var cornerRadius: CGFloat = 28
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
Path(roundedRect: rect, cornerRadius: cornerRadius, style: .continuous)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grain Texture Overlay
|
||||
|
||||
struct GrainTexture: View {
|
||||
var opacity: Double = 0.03
|
||||
var animated: Bool = false
|
||||
@State private var phase: Double = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Canvas { context, size in
|
||||
for _ in 0..<Int(size.width * size.height / 50) {
|
||||
let x = CGFloat.random(in: 0...size.width)
|
||||
let y = CGFloat.random(in: 0...size.height)
|
||||
let grainOpacity = Double.random(in: 0.3...1.0)
|
||||
|
||||
context.fill(
|
||||
Path(ellipseIn: CGRect(x: x, y: y, width: 1, height: 1)),
|
||||
with: .color(.black.opacity(grainOpacity * opacity))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Background
|
||||
|
||||
struct OrganicCardBackground: View {
|
||||
var accentColor: Color = Color.appPrimary
|
||||
var showBlob: Bool = true
|
||||
var blobVariation: Int = 0
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Main card fill
|
||||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||
.fill(Color.appBackgroundSecondary)
|
||||
|
||||
// Subtle accent blob in corner
|
||||
if showBlob {
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: blobVariation)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
accentColor.opacity(0.08),
|
||||
accentColor.opacity(0.02)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.7)
|
||||
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
|
||||
// Grain texture
|
||||
GrainTexture(opacity: 0.015)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Natural Shadow Modifier
|
||||
|
||||
struct NaturalShadow: ViewModifier {
|
||||
var intensity: ShadowIntensity = .medium
|
||||
|
||||
enum ShadowIntensity {
|
||||
case subtle, medium, pronounced
|
||||
|
||||
var values: (color: Color, radius: CGFloat, y: CGFloat) {
|
||||
switch self {
|
||||
case .subtle:
|
||||
return (Color.black.opacity(0.04), 8, 4)
|
||||
case .medium:
|
||||
return (Color.black.opacity(0.08), 16, 8)
|
||||
case .pronounced:
|
||||
return (Color.black.opacity(0.12), 24, 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.shadow(
|
||||
color: intensity.values.color,
|
||||
radius: intensity.values.radius,
|
||||
x: 0,
|
||||
y: intensity.values.y
|
||||
)
|
||||
// Secondary ambient shadow for depth
|
||||
.shadow(
|
||||
color: intensity.values.color.opacity(0.5),
|
||||
radius: intensity.values.radius * 2,
|
||||
x: 0,
|
||||
y: intensity.values.y * 0.5
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Icon Container
|
||||
|
||||
struct OrganicIconContainer: View {
|
||||
let systemName: String
|
||||
var size: CGFloat = 48
|
||||
var iconScale: CGFloat = 0.5
|
||||
var backgroundColor: Color = Color.appPrimary
|
||||
var iconColor: Color = Color.appTextOnPrimary
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Soft organic background
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
backgroundColor,
|
||||
backgroundColor.opacity(0.85)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: size
|
||||
)
|
||||
)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
// Inner glow
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.2),
|
||||
Color.clear
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: size * 0.5
|
||||
)
|
||||
)
|
||||
.frame(width: size, height: size)
|
||||
|
||||
// Icon
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: size * iconScale, weight: .semibold))
|
||||
.foregroundColor(iconColor)
|
||||
}
|
||||
.modifier(NaturalShadow(intensity: .subtle))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Stat Pill
|
||||
|
||||
struct OrganicStatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
var color: Color = Color.appPrimary
|
||||
var isSystemIcon: Bool = true
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
// Icon with soft background
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(color.opacity(0.12))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
if isSystemIcon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
} else {
|
||||
Image(icon)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(color)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(value)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appBackgroundSecondary)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(color.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Divider
|
||||
|
||||
struct OrganicDivider: View {
|
||||
var color: Color = Color.appTextSecondary.opacity(0.15)
|
||||
var height: CGFloat = 1
|
||||
var horizontalPadding: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
color.opacity(0),
|
||||
color,
|
||||
color,
|
||||
color.opacity(0)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.frame(height: height)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Warm Gradient Background
|
||||
|
||||
struct WarmGradientBackground: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary
|
||||
|
||||
// Subtle warm gradient overlay
|
||||
LinearGradient(
|
||||
colors: colorScheme == .dark
|
||||
? [Color.appPrimary.opacity(0.05), Color.clear]
|
||||
: [Color.appPrimary.opacity(0.03), Color.clear],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
// Grain for natural feel
|
||||
GrainTexture(opacity: 0.02)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View Extensions
|
||||
|
||||
extension View {
|
||||
func organicCard(
|
||||
accentColor: Color = Color.appPrimary,
|
||||
showBlob: Bool = true,
|
||||
shadowIntensity: NaturalShadow.ShadowIntensity = .medium
|
||||
) -> some View {
|
||||
self
|
||||
.background(OrganicCardBackground(accentColor: accentColor, showBlob: showBlob))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.modifier(NaturalShadow(intensity: shadowIntensity))
|
||||
}
|
||||
|
||||
func naturalShadow(_ intensity: NaturalShadow.ShadowIntensity = .medium) -> some View {
|
||||
modifier(NaturalShadow(intensity: intensity))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Spacing
|
||||
|
||||
struct OrganicSpacing {
|
||||
static let cozy: CGFloat = 20
|
||||
static let comfortable: CGFloat = 24
|
||||
static let spacious: CGFloat = 32
|
||||
static let airy: CGFloat = 40
|
||||
}
|
||||
|
||||
// MARK: - Animated Leaf Decoration
|
||||
|
||||
struct FloatingLeaf: View {
|
||||
@State private var rotation: Double = 0
|
||||
@State private var offset: CGFloat = 0
|
||||
var delay: Double = 0
|
||||
var size: CGFloat = 20
|
||||
var color: Color = Color.appPrimary
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: "leaf.fill")
|
||||
.font(.system(size: size))
|
||||
.foregroundColor(color.opacity(0.15))
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.offset(y: offset)
|
||||
.onAppear {
|
||||
withAnimation(
|
||||
Animation
|
||||
.easeInOut(duration: 4)
|
||||
.repeatForever(autoreverses: true)
|
||||
.delay(delay)
|
||||
) {
|
||||
rotation = 15
|
||||
offset = 8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Organic Components") {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Organic Icon Container
|
||||
HStack(spacing: 16) {
|
||||
OrganicIconContainer(systemName: "house.fill", size: 56)
|
||||
OrganicIconContainer(systemName: "wrench.fill", size: 48, backgroundColor: .orange)
|
||||
OrganicIconContainer(systemName: "leaf.fill", size: 40, backgroundColor: .green)
|
||||
}
|
||||
|
||||
// Organic Stat Pills
|
||||
HStack(spacing: 12) {
|
||||
OrganicStatPill(icon: "house.fill", value: "3", label: "Properties")
|
||||
OrganicStatPill(icon: "checklist", value: "12", label: "Tasks", color: .orange)
|
||||
}
|
||||
|
||||
// Organic Card
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Organic Card Example")
|
||||
.font(.title3.weight(.bold))
|
||||
Text("This card uses soft shapes, subtle gradients, and natural shadows.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(OrganicSpacing.cozy)
|
||||
.organicCard()
|
||||
|
||||
// Organic Divider
|
||||
OrganicDivider()
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(WarmGradientBackground())
|
||||
}
|
||||
@@ -40,49 +40,70 @@ struct LoginView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
// Background gradient
|
||||
Color.appBackgroundPrimary
|
||||
.ignoresSafeArea()
|
||||
// Warm organic background
|
||||
WarmGradientBackground()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.xl) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.spacious) {
|
||||
Spacer()
|
||||
.frame(height: AppSpacing.xxxl)
|
||||
.frame(height: OrganicSpacing.airy)
|
||||
|
||||
// Hero Section
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// App Icon with gradient
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// App Icon with organic glow
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 60
|
||||
)
|
||||
)
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image("icon")
|
||||
.font(.system(size: 50, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 80, height: 80)
|
||||
}
|
||||
|
||||
VStack(spacing: AppSpacing.xs) {
|
||||
VStack(spacing: 8) {
|
||||
Text(L10n.Auth.welcomeBack)
|
||||
.font(.title2.weight(.bold))
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(L10n.Auth.signInSubtitle)
|
||||
.font(.body)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Login Card
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
VStack(spacing: 20) {
|
||||
// Username Field
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(L10n.Auth.loginUsernameLabel)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.frame(width: 20)
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
TextField(L10n.Auth.enterEmail, text: $viewModel.username)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.emailAddress)
|
||||
@@ -97,31 +118,36 @@ struct LoginView: View {
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.padding(16)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(focusedField == .username ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(focusedField == .username ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||
)
|
||||
.shadow(color: focusedField == .username ? Color.appPrimary.opacity(0.1) : .clear, radius: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: focusedField)
|
||||
}
|
||||
|
||||
// Password Field
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(L10n.Auth.loginPasswordLabel)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(.system(size: 14, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "lock.fill")
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.frame(width: 20)
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Group {
|
||||
if isPasswordVisible {
|
||||
TextField(L10n.Auth.enterPassword, text: $viewModel.password)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
.textContentType(.password)
|
||||
@@ -133,6 +159,7 @@ struct LoginView: View {
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField)
|
||||
} else {
|
||||
SecureField(L10n.Auth.enterPassword, text: $viewModel.password)
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.textContentType(.password)
|
||||
.focused($focusedField, equals: .password)
|
||||
.submitLabel(.go)
|
||||
@@ -147,19 +174,18 @@ struct LoginView: View {
|
||||
isPasswordVisible.toggle()
|
||||
}) {
|
||||
Image(systemName: isPasswordVisible ? "eye.slash.fill" : "eye.fill")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.frame(width: 20)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle)
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.padding(16)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(focusedField == .password ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5)
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.stroke(focusedField == .password ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||
)
|
||||
.shadow(color: focusedField == .password ? Color.appPrimary.opacity(0.1) : .clear, radius: 8)
|
||||
.animation(.easeInOut(duration: 0.2), value: focusedField)
|
||||
.onChange(of: viewModel.password) { _, _ in
|
||||
viewModel.clearError()
|
||||
@@ -172,24 +198,24 @@ struct LoginView: View {
|
||||
Button(L10n.Auth.forgotPassword) {
|
||||
showPasswordReset = true
|
||||
}
|
||||
.font(.subheadline.weight(.medium))
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton)
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
Text(errorMessage)
|
||||
.font(.callout)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appError)
|
||||
Spacer()
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.padding(16)
|
||||
.background(Color.appError.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
// Login Button
|
||||
@@ -200,19 +226,14 @@ struct LoginView: View {
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
|
||||
|
||||
// Divider
|
||||
HStack {
|
||||
Rectangle()
|
||||
.fill(Color.appTextSecondary.opacity(0.3))
|
||||
.frame(height: 1)
|
||||
HStack(spacing: 12) {
|
||||
OrganicDivider()
|
||||
Text(L10n.Auth.orDivider)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
Rectangle()
|
||||
.fill(Color.appTextSecondary.opacity(0.3))
|
||||
.frame(height: 1)
|
||||
OrganicDivider()
|
||||
}
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Sign in with Apple Button
|
||||
SignInWithAppleButton(
|
||||
@@ -221,8 +242,8 @@ struct LoginView: View {
|
||||
},
|
||||
onCompletion: { _ in }
|
||||
)
|
||||
.frame(height: 56)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.frame(height: 54)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.signInWithAppleButtonStyle(.black)
|
||||
.disabled(appleSignInViewModel.isLoading)
|
||||
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
|
||||
@@ -238,51 +259,52 @@ struct LoginView: View {
|
||||
|
||||
// Apple Sign In loading indicator
|
||||
if appleSignInViewModel.isLoading {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
.tint(Color.appPrimary)
|
||||
Text(L10n.Auth.signingInWithApple)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.top, AppSpacing.xs)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
// Apple Sign In Error
|
||||
if let appleError = appleSignInViewModel.errorMessage {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
Text(appleError)
|
||||
.font(.callout)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appError)
|
||||
Spacer()
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.padding(16)
|
||||
.background(Color.appError.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
// Sign Up Link
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
HStack(spacing: 6) {
|
||||
Text(L10n.Auth.dontHaveAccount)
|
||||
.font(.body)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
Button(L10n.Auth.signUp) {
|
||||
showingRegister = true
|
||||
}
|
||||
.font(.body)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.padding(AppSpacing.xl)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.xxl)
|
||||
.shadow(color: .black.opacity(0.08), radius: 20, y: 10)
|
||||
.padding(.horizontal, AppSpacing.lg)
|
||||
.padding(OrganicSpacing.cozy)
|
||||
.background(LoginCardBackground())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||
.naturalShadow(.pronounced)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
@@ -387,6 +409,40 @@ struct LoginView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Login Card Background
|
||||
|
||||
private struct LoginCardBackground: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
|
||||
// Organic blob accent
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 2)
|
||||
.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.5
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5)
|
||||
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.3)
|
||||
.blur(radius: 20)
|
||||
}
|
||||
|
||||
// Grain texture for natural feel
|
||||
GrainTexture(opacity: 0.015)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview {
|
||||
LoginView()
|
||||
|
||||
@@ -7,28 +7,48 @@ struct NotificationPreferencesView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Header Section
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "bell.badge.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
Text(L10n.Profile.notificationPreferences)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Form {
|
||||
// Header Section
|
||||
Section {
|
||||
VStack(spacing: OrganicSpacing.cozy) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 50
|
||||
)
|
||||
)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Text(L10n.Profile.notificationPreferencesSubtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
Image(systemName: "bell.badge.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
}
|
||||
|
||||
Text(L10n.Profile.notificationPreferences)
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(L10n.Profile.notificationPreferencesSubtitle)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
if viewModel.isLoading {
|
||||
Section {
|
||||
@@ -282,9 +302,10 @@ struct NotificationPreferencesView: View {
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
}
|
||||
.navigationTitle(L10n.Profile.notifications)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -292,6 +313,7 @@ struct NotificationPreferencesView: View {
|
||||
Button(L10n.Common.done) {
|
||||
dismiss()
|
||||
}
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,31 +11,57 @@ struct ProfileView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
if viewModel.isLoadingUser {
|
||||
VStack {
|
||||
ProgressView()
|
||||
Text(L10n.Profile.loadingProfile)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
Section {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
Text(L10n.Profile.profileSettings)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
if viewModel.isLoadingUser {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 64, height: 64)
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
.tint(Color.appPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
Text(L10n.Profile.loadingProfile)
|
||||
.font(.system(size: 15, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
} else {
|
||||
Form {
|
||||
Section {
|
||||
VStack(spacing: OrganicSpacing.cozy) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 50
|
||||
)
|
||||
)
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(Color.appPrimary.gradient)
|
||||
}
|
||||
|
||||
Text(L10n.Profile.profileSettings)
|
||||
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
Section {
|
||||
TextField(L10n.Profile.firstName, text: $viewModel.firstName)
|
||||
@@ -122,30 +148,32 @@ struct ProfileView: View {
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.navigationTitle(L10n.Profile.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(L10n.Common.cancel) {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.background(Color.clear)
|
||||
}
|
||||
.onChange(of: viewModel.firstName) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.onChange(of: viewModel.lastName) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.onChange(of: viewModel.email) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.updateProfile() }
|
||||
)
|
||||
}
|
||||
.navigationTitle(L10n.Profile.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
Button(L10n.Common.cancel) {
|
||||
dismiss()
|
||||
}
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.firstName) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.onChange(of: viewModel.lastName) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.onChange(of: viewModel.email) { _, _ in
|
||||
viewModel.clearMessages()
|
||||
}
|
||||
.handleErrors(
|
||||
error: viewModel.errorMessage,
|
||||
onRetry: { viewModel.updateProfile() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,22 +6,27 @@ struct ThemeSelectionView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(ThemeID.allCases, id: \.self) { theme in
|
||||
Button(action: {
|
||||
selectTheme(theme)
|
||||
}) {
|
||||
ThemeRow(
|
||||
theme: theme,
|
||||
isSelected: themeManager.currentTheme == theme
|
||||
)
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
List {
|
||||
ForEach(ThemeID.allCases, id: \.self) { theme in
|
||||
Button(action: {
|
||||
selectTheme(theme)
|
||||
}) {
|
||||
ThemeRow(
|
||||
theme: theme,
|
||||
isSelected: themeManager.currentTheme == theme
|
||||
)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.clear)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.navigationTitle(L10n.Profile.appearance)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
@@ -29,6 +34,7 @@ struct ThemeSelectionView: View {
|
||||
Button(L10n.Common.done) {
|
||||
dismiss()
|
||||
}
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,27 +59,31 @@ struct ThemeRow: View {
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.md) {
|
||||
HStack(spacing: 14) {
|
||||
// Theme preview circles
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(0..<3, id: \.self) { index in
|
||||
Circle()
|
||||
.fill(theme.previewColors[index])
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: 26, height: 26)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.xs)
|
||||
.padding(8)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.md))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||||
|
||||
// Theme info
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(theme.displayName)
|
||||
.font(.headline)
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(theme.description)
|
||||
.font(.caption)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
@@ -81,12 +91,17 @@ struct ThemeRow: View {
|
||||
|
||||
// Checkmark for selected theme
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.title3)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
.padding(.vertical, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,7 @@ struct ResidenceDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary
|
||||
.ignoresSafeArea()
|
||||
WarmGradientBackground()
|
||||
|
||||
mainContent
|
||||
}
|
||||
@@ -208,19 +207,19 @@ private extension ResidenceDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
func contentView(for residence: ResidenceResponse) -> some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
PropertyHeaderCard(residence: residence)
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
tasksSection
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
contractorsSection
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
.padding(.bottom)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,49 +247,81 @@ private extension ResidenceDetailView {
|
||||
|
||||
@ViewBuilder
|
||||
var contractorsSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Section Header
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text(L10n.Residences.contractors)
|
||||
.font(.title2.weight(.bold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.top, AppSpacing.sm)
|
||||
.padding(.top, 8)
|
||||
|
||||
if isLoadingContractors {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.tint(Color.appPrimary)
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.padding(OrganicSpacing.cozy)
|
||||
} else if let error = contractorsError {
|
||||
Text("\(L10n.Common.error): \(error)")
|
||||
.foregroundColor(Color.appError)
|
||||
.padding()
|
||||
} else if contractors.isEmpty {
|
||||
// Empty state
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.6))
|
||||
Text(L10n.Residences.noContractors)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Text(L10n.Residences.addContractorsPrompt)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
// Empty state with organic styling
|
||||
VStack(spacing: 16) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.12),
|
||||
Color.appPrimary.opacity(0.04)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 50
|
||||
)
|
||||
)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "person.crop.circle.badge.plus")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.6))
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text(L10n.Residences.noContractors)
|
||||
.font(.system(size: 17, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(L10n.Residences.addContractorsPrompt)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(AppSpacing.xl)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.padding(OrganicSpacing.spacious)
|
||||
.background(OrganicCardBackground(showBlob: true, blobVariation: 1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.naturalShadow(.subtle)
|
||||
} else {
|
||||
// Contractors list
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(contractors, id: \.id) { contractor in
|
||||
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
|
||||
ContractorCard(
|
||||
@@ -300,7 +331,7 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
.buttonStyle(OrganicCardButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,6 +339,17 @@ private extension ResidenceDetailView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Toolbars
|
||||
|
||||
private extension ResidenceDetailView {
|
||||
|
||||
@@ -14,8 +14,8 @@ struct ResidencesListView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundPrimary
|
||||
.ignoresSafeArea()
|
||||
// Warm organic background
|
||||
WarmGradientBackground()
|
||||
|
||||
if let response = viewModel.myResidences {
|
||||
ListAsyncContentView(
|
||||
@@ -29,7 +29,7 @@ struct ResidencesListView: View {
|
||||
)
|
||||
},
|
||||
emptyContent: {
|
||||
EmptyResidencesView()
|
||||
OrganicEmptyResidencesView()
|
||||
},
|
||||
onRefresh: {
|
||||
viewModel.loadMyResidences(forceRefresh: true)
|
||||
@@ -53,9 +53,7 @@ struct ResidencesListView: View {
|
||||
Button(action: {
|
||||
showingSettings = true
|
||||
}) {
|
||||
Image(systemName: "gearshape.fill")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
OrganicToolbarButton(systemName: "gearshape.fill")
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
|
||||
}
|
||||
@@ -70,9 +68,7 @@ struct ResidencesListView: View {
|
||||
showingJoinResidence = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "person.badge.plus")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
OrganicToolbarButton(systemName: "person.badge.plus")
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
@@ -84,9 +80,7 @@ struct ResidencesListView: View {
|
||||
showingAddResidence = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
OrganicToolbarButton(systemName: "plus", isPrimary: true)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton)
|
||||
}
|
||||
@@ -148,6 +142,35 @@ struct ResidencesListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Toolbar Button
|
||||
|
||||
private struct OrganicToolbarButton: View {
|
||||
let systemName: String
|
||||
var isPrimary: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if isPrimary {
|
||||
Circle()
|
||||
.fill(Color.appPrimary)
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Residences Content View
|
||||
|
||||
private struct ResidencesContent: View {
|
||||
@@ -156,36 +179,51 @@ private struct ResidencesContent: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: AppSpacing.lg) {
|
||||
// Summary Card
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// Summary Card with enhanced styling
|
||||
SummaryCard(summary: summary)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 8)
|
||||
|
||||
// Properties Header
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
// Properties Section Header
|
||||
HStack(alignment: .center) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(L10n.Residences.yourProperties)
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)")
|
||||
.font(.callout)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
|
||||
// Residences List
|
||||
ForEach(residences, id: \.id) { residence in
|
||||
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
|
||||
ResidenceCard(residence: residence)
|
||||
.padding(.horizontal, AppSpacing.md)
|
||||
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)
|
||||
}
|
||||
.buttonStyle(OrganicCardButtonStyle())
|
||||
.transition(.asymmetric(
|
||||
insertion: .opacity.combined(with: .move(edge: .bottom)),
|
||||
removal: .opacity
|
||||
))
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
Color.clear.frame(height: 0)
|
||||
@@ -193,6 +231,97 @@ private struct ResidencesContent: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Card Button Style
|
||||
|
||||
private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
||||
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Organic Empty Residences View
|
||||
|
||||
private struct OrganicEmptyResidencesView: View {
|
||||
@State private var isAnimating = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
Spacer()
|
||||
|
||||
// Animated house illustration
|
||||
ZStack {
|
||||
// Background glow
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
// House icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 100, height: 100)
|
||||
|
||||
Image(systemName: "house.lodge.fill")
|
||||
.font(.system(size: 44, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.offset(y: isAnimating ? -2 : 2)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
||||
value: isAnimating
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Text("Welcome to Your Space")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("Add your first property to start\nmanaging your home with ease")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
.padding(.top, 8)
|
||||
|
||||
Spacer()
|
||||
|
||||
// Decorative footer elements
|
||||
HStack(spacing: 40) {
|
||||
FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary)
|
||||
FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent)
|
||||
FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary)
|
||||
}
|
||||
.opacity(0.6)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
ResidencesListView()
|
||||
|
||||
@@ -3,108 +3,310 @@ import ComposeApp
|
||||
|
||||
struct PropertyHeaderCard: View {
|
||||
let residence: ResidenceResponse
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
VStack {
|
||||
Image("house_outline")
|
||||
.resizable()
|
||||
.frame(width: 38, height: 38)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(content: {
|
||||
RoundedRectangle(cornerRadius: AppRadius.sm)
|
||||
.fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing))
|
||||
.frame(width: 38, height: 38)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3)
|
||||
})
|
||||
|
||||
Spacer()
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header Section
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
// Property Icon
|
||||
PropertyDetailIcon()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Property Info
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(residence.name)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let propertyTypeName = residence.propertyTypeName {
|
||||
Text(propertyTypeName)
|
||||
.font(.caption)
|
||||
Text(propertyTypeName.uppercased())
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.tracking(1.5)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Primary badge if applicable
|
||||
if residence.isPrimary {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
|
||||
Text("Primary")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(Color.appAccent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.top, OrganicSpacing.cozy)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Divider()
|
||||
// Divider
|
||||
OrganicDivider()
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Address Section
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
if !residence.streetAddress.isEmpty {
|
||||
Label(residence.streetAddress, systemImage: "mappin.circle.fill")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text(residence.streetAddress)
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
if !residence.city.isEmpty || !residence.stateProvince.isEmpty || !residence.postalCode.isEmpty {
|
||||
Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
HStack(spacing: 10) {
|
||||
Color.clear.frame(width: 32, height: 1) // Alignment spacer
|
||||
|
||||
Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !residence.country.isEmpty {
|
||||
Text(residence.country)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
Color.clear.frame(width: 32, height: 1) // Alignment spacer
|
||||
|
||||
if let bedrooms = residence.bedrooms,
|
||||
let bathrooms = residence.bathrooms {
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 24) {
|
||||
PropertyDetailItem(icon: "bed.double.fill", value: "\(bedrooms.intValue)", label: "Beds")
|
||||
PropertyDetailItem(icon: "shower.fill", value: String(format: "%.1f", bathrooms.doubleValue), label: "Baths")
|
||||
|
||||
if let sqft = residence.squareFootage {
|
||||
PropertyDetailItem(icon: "square.fill", value: "\(sqft.intValue)", label: "Sq Ft")
|
||||
Text(residence.country)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.8))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 16)
|
||||
|
||||
// Property Details (if available)
|
||||
if let bedrooms = residence.bedrooms, let bathrooms = residence.bathrooms {
|
||||
OrganicDivider()
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
// Property Features Row
|
||||
HStack(spacing: 0) {
|
||||
PropertyFeaturePill(
|
||||
icon: "bed.double.fill",
|
||||
value: "\(bedrooms.intValue)",
|
||||
label: "Beds"
|
||||
)
|
||||
|
||||
PropertyFeaturePill(
|
||||
icon: "shower.fill",
|
||||
value: String(format: "%.1f", bathrooms.doubleValue),
|
||||
label: "Baths"
|
||||
)
|
||||
|
||||
if let sqft = residence.squareFootage {
|
||||
PropertyFeaturePill(
|
||||
icon: "square.dashed",
|
||||
value: formatNumber(sqft.intValue),
|
||||
label: "Sq Ft"
|
||||
)
|
||||
}
|
||||
|
||||
if let yearBuilt = residence.yearBuilt {
|
||||
PropertyFeaturePill(
|
||||
icon: "calendar",
|
||||
value: "\(yearBuilt.intValue)",
|
||||
label: "Built"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
|
||||
.background(PropertyHeaderBackground())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
private func formatNumber(_ num: Int) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: num)) ?? "\(num)"
|
||||
}
|
||||
}
|
||||
|
||||
//#Preview {
|
||||
// PropertyHeaderCard(residence: Residence(
|
||||
// id: 1,
|
||||
// owner: "My Beautiful Home",
|
||||
// ownerUsername: "House",
|
||||
// name: "123 Main Street",
|
||||
// propertyType: nil,
|
||||
// streetAddress: "San Francisco",
|
||||
// apartmentUnit: "CA",
|
||||
// city: "94102",
|
||||
// stateProvince: "USA",
|
||||
// postalCode: 3,
|
||||
// country: 2.5,
|
||||
// bedrooms: 1800,
|
||||
// bathrooms: 0.25,
|
||||
// squareFootage: 2010,
|
||||
// lotSize: nil,
|
||||
// yearBuilt: nil,
|
||||
// description: nil,
|
||||
// purchaseDate: true,
|
||||
// purchasePrice: "testuser",
|
||||
// isPrimary: 1,
|
||||
// createdAt: "2024-01-01T00:00:00Z",
|
||||
// updatedAt: "2024-01-01T00:00:00Z"
|
||||
// ))
|
||||
// .padding()
|
||||
//}
|
||||
// MARK: - Property Detail Icon
|
||||
|
||||
private struct PropertyDetailIcon: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Outer glow
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.15),
|
||||
Color.appPrimary.opacity(0.05)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: 32
|
||||
)
|
||||
)
|
||||
.frame(width: 64, height: 64)
|
||||
|
||||
// Inner circle
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary,
|
||||
Color.appPrimary.opacity(0.9)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 48
|
||||
)
|
||||
)
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
// Highlight
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.white.opacity(0.3),
|
||||
Color.clear
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 24
|
||||
)
|
||||
)
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
// Icon
|
||||
Image("house_outline")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 22, height: 22)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
}
|
||||
.naturalShadow(.subtle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Property Feature Pill
|
||||
|
||||
private struct PropertyFeaturePill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Property Header Background
|
||||
|
||||
private struct PropertyHeaderBackground: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Base
|
||||
Color.appBackgroundSecondary
|
||||
|
||||
// Decorative blob
|
||||
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.7)
|
||||
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1)
|
||||
.blur(radius: 25)
|
||||
}
|
||||
|
||||
// Grain texture
|
||||
GrainTexture(opacity: 0.012)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Property Header Card") {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
PropertyHeaderCard(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 with stunning views",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: true,
|
||||
isActive: true,
|
||||
overdueCount: 0,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.background(WarmGradientBackground())
|
||||
}
|
||||
|
||||
@@ -9,136 +9,398 @@ struct ResidenceCard: View {
|
||||
Int(residence.overdueCount) > 0
|
||||
}
|
||||
|
||||
/// Get task summary categories (max 3)
|
||||
private var displayCategories: [TaskCategorySummary] {
|
||||
Array(residence.taskSummary.categories.prefix(3))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
||||
// Header with property type icon (pulses when overdue tasks exist)
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
VStack {
|
||||
if hasOverdueTasks {
|
||||
PulsingIconView(backgroundColor: Color.appPrimary)
|
||||
.frame(width: 44, height: 44)
|
||||
.padding([.trailing], AppSpacing.md)
|
||||
} else {
|
||||
CaseraIconView(backgroundColor: Color.appPrimary)
|
||||
.frame(width: 44, height: 44)
|
||||
.padding([.trailing], AppSpacing.md)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Top Section: Icon + Property Info + Primary Badge
|
||||
HStack(alignment: .top, spacing: 16) {
|
||||
// Property Icon with organic styling
|
||||
PropertyIconView(hasOverdue: hasOverdueTasks)
|
||||
|
||||
// Property Details
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Property Name
|
||||
Text(residence.name)
|
||||
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.lineLimit(1)
|
||||
|
||||
// Property Type
|
||||
if let propertyTypeName = residence.propertyTypeName {
|
||||
Text(propertyTypeName.uppercased())
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.tracking(1.2)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
Text(residence.name)
|
||||
.font(.title3.weight(.semibold))
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let propertyTypeName = residence.propertyTypeName {
|
||||
Text(propertyTypeName)
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.5)
|
||||
// Address
|
||||
if !residence.streetAddress.isEmpty {
|
||||
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)
|
||||
}
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Spacer()
|
||||
|
||||
|
||||
// Primary Badge
|
||||
if residence.isPrimary {
|
||||
VStack {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(Color.appAccent)
|
||||
.background(content: {
|
||||
Circle()
|
||||
.fill(Color.appAccent.opacity(0.2))
|
||||
.frame(width: 32, height: 32)
|
||||
})
|
||||
.padding(AppSpacing.md)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
PrimaryBadgeView()
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
|
||||
if !residence.streetAddress.isEmpty {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
Text(residence.streetAddress)
|
||||
.font(.callout)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !residence.city.isEmpty || !residence.stateProvince.isEmpty {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
Text("\(residence.city ?? ""), \(residence.stateProvince ?? "")")
|
||||
.font(.callout)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.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)
|
||||
}
|
||||
.padding(.vertical, AppSpacing.xs)
|
||||
|
||||
Divider()
|
||||
|
||||
// Fully dynamic task stats from API - show first 3 categories
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
let displayCategories = Array(residence.taskSummary.categories.prefix(3))
|
||||
|
||||
ForEach(displayCategories, id: \.name) { category in
|
||||
TaskStatChip(
|
||||
icon: category.icons.ios,
|
||||
value: "\(category.count)",
|
||||
label: category.displayName,
|
||||
color: Color(hex: category.color) ?? Color.appTextSecondary
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Show overdue count if any
|
||||
if hasOverdueTasks {
|
||||
OverdueChip(count: Int(residence.overdueCount))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
} else {
|
||||
// Empty state for tasks
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appPrimary.opacity(0.5))
|
||||
|
||||
Text("No tasks yet")
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.cozy)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
|
||||
.background(CardBackgroundView(hasOverdue: hasOverdueTasks))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ResidenceCard(residence: ResidenceResponse(
|
||||
id: 1,
|
||||
ownerId: 1,
|
||||
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "", lastName: ""),
|
||||
users: [],
|
||||
name: "My Home",
|
||||
propertyTypeId: 1,
|
||||
propertyType: ResidenceType(id: 1, name: "House"),
|
||||
streetAddress: "123 Main St",
|
||||
apartmentUnit: "",
|
||||
city: "San Francisco",
|
||||
stateProvince: "CA",
|
||||
postalCode: "94102",
|
||||
country: "USA",
|
||||
bedrooms: 3,
|
||||
bathrooms: 2.5,
|
||||
squareFootage: 1800,
|
||||
lotSize: 0.25,
|
||||
yearBuilt: 2010,
|
||||
description: "",
|
||||
purchaseDate: nil,
|
||||
purchasePrice: nil,
|
||||
isPrimary: true,
|
||||
isActive: true,
|
||||
overdueCount: 1,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-01T00:00:00Z"
|
||||
))
|
||||
.padding()
|
||||
.background(Color.appBackgroundPrimary)
|
||||
// MARK: - Property Icon View
|
||||
|
||||
private struct PropertyIconView: View {
|
||||
let hasOverdue: Bool
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background circle with gradient
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary,
|
||||
Color.appPrimary.opacity(0.85)
|
||||
],
|
||||
center: .topLeading,
|
||||
startRadius: 0,
|
||||
endRadius: 52
|
||||
)
|
||||
)
|
||||
.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)
|
||||
|
||||
// House icon
|
||||
Image("house_outline")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
|
||||
// Pulse ring for overdue
|
||||
if hasOverdue {
|
||||
PulseRing()
|
||||
}
|
||||
}
|
||||
.naturalShadow(.subtle)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pulse Ring Animation
|
||||
|
||||
private struct PulseRing: View {
|
||||
@State private var isPulsing = false
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.stroke(Color.appError.opacity(0.6), lineWidth: 2)
|
||||
.frame(width: 60, height: 60)
|
||||
.scaleEffect(isPulsing ? 1.15 : 1.0)
|
||||
.opacity(isPulsing ? 0 : 1)
|
||||
.animation(
|
||||
Animation
|
||||
.easeOut(duration: 1.5)
|
||||
.repeatForever(autoreverses: false),
|
||||
value: isPulsing
|
||||
)
|
||||
.onAppear {
|
||||
isPulsing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Primary Badge
|
||||
|
||||
private struct PrimaryBadgeView: View {
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Soft background
|
||||
Circle()
|
||||
.fill(Color.appAccent.opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
// Star icon
|
||||
Image(systemName: "star.fill")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(Color.appAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Category Chip
|
||||
|
||||
private struct TaskCategoryChip: View {
|
||||
let category: TaskCategorySummary
|
||||
|
||||
private var chipColor: Color {
|
||||
Color(hex: category.color) ?? Color.appPrimary
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
// Icon background
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(chipColor.opacity(0.15))
|
||||
.frame(width: 26, height: 26)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card Background
|
||||
|
||||
private struct CardBackgroundView: View {
|
||||
let hasOverdue: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Base fill
|
||||
Color.appBackgroundSecondary
|
||||
|
||||
// Subtle organic blob accent in corner
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 1)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.04),
|
||||
Color.appPrimary.opacity(0.01)
|
||||
],
|
||||
startPoint: .topTrailing,
|
||||
endPoint: .bottomLeading
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.6)
|
||||
.offset(x: geo.size.width * 0.55, y: -geo.size.height * 0.05)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
|
||||
// Subtle grain texture
|
||||
GrainTexture(opacity: 0.012)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#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: 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"
|
||||
))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 24)
|
||||
}
|
||||
.background(WarmGradientBackground())
|
||||
}
|
||||
|
||||
@@ -3,67 +3,289 @@ import ComposeApp
|
||||
|
||||
struct SummaryCard: View {
|
||||
let summary: TotalSummary
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
HStack {
|
||||
Text("Overview")
|
||||
.font(.title3)
|
||||
.fontWeight(.bold)
|
||||
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()
|
||||
}
|
||||
|
||||
HStack(spacing: 20) {
|
||||
SummaryStatView(
|
||||
icon: "house_outline",
|
||||
// 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)
|
||||
|
||||
// Main Stats Row
|
||||
HStack(spacing: 0) {
|
||||
OrganicStatItem(
|
||||
icon: "house.fill",
|
||||
value: "\(summary.totalResidences)",
|
||||
label: "Properties"
|
||||
label: "Properties",
|
||||
accentColor: Color.appPrimary
|
||||
)
|
||||
|
||||
SummaryStatView(
|
||||
icon: "list.bullet",
|
||||
// Vertical divider
|
||||
Rectangle()
|
||||
.fill(Color.appTextSecondary.opacity(0.12))
|
||||
.frame(width: 1, height: 50)
|
||||
|
||||
OrganicStatItem(
|
||||
icon: "checklist",
|
||||
value: "\(summary.totalTasks)",
|
||||
label: "Total Tasks"
|
||||
label: "Total Tasks",
|
||||
accentColor: Color.appSecondary
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 16)
|
||||
|
||||
Divider()
|
||||
// Organic divider
|
||||
OrganicDivider()
|
||||
.padding(.horizontal, 24)
|
||||
|
||||
HStack(spacing: 20) {
|
||||
SummaryStatView(
|
||||
icon: "calendar",
|
||||
// Timeline Stats
|
||||
HStack(spacing: 8) {
|
||||
TimelineStatPill(
|
||||
icon: "exclamationmark.circle.fill",
|
||||
value: "\(summary.totalOverdue)",
|
||||
label: "Over Due"
|
||||
)
|
||||
|
||||
SummaryStatView(
|
||||
icon: "calendar",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
label: "Due This Week"
|
||||
label: "Overdue",
|
||||
color: summary.totalOverdue > 0 ? Color.appError : Color.appTextSecondary,
|
||||
isAlert: summary.totalOverdue > 0
|
||||
)
|
||||
|
||||
SummaryStatView(
|
||||
TimelineStatPill(
|
||||
icon: "calendar.badge.clock",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
label: "This Week",
|
||||
color: Color.appAccent
|
||||
)
|
||||
|
||||
TimelineStatPill(
|
||||
icon: "calendar",
|
||||
value: "\(summary.tasksDueNextMonth)",
|
||||
label: "Next 30 Days"
|
||||
label: "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)
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
.padding(20)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(16)
|
||||
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SummaryCard(summary: TotalSummary(
|
||||
totalResidences: 3,
|
||||
totalTasks: 12,
|
||||
totalPending: 2,
|
||||
totalOverdue: 1,
|
||||
tasksDueNextWeek: 4,
|
||||
tasksDueNextMonth: 8
|
||||
))
|
||||
.padding()
|
||||
// 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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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: 6) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(value)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(isAlert ? color : Color.appTextPrimary)
|
||||
}
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(
|
||||
isAlert
|
||||
? color.opacity(0.08)
|
||||
: Color.appBackgroundPrimary.opacity(0.5)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16, 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())
|
||||
}
|
||||
|
||||
@@ -4,19 +4,33 @@ struct PriorityBadge: View {
|
||||
let priority: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: priorityIcon)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
|
||||
Text(priority.capitalized)
|
||||
.font(.caption.weight(.medium))
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
.background(priorityColor.opacity(0.15))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundColor(priorityColor)
|
||||
.cornerRadius(AppRadius.xs)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(priorityColor.opacity(0.12))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(priorityColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private var priorityIcon: String {
|
||||
switch priority.lowercased() {
|
||||
case "high": return "exclamationmark.triangle.fill"
|
||||
case "medium": return "exclamationmark.circle.fill"
|
||||
case "low": return "minus.circle.fill"
|
||||
default: return "circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var priorityColor: Color {
|
||||
@@ -24,7 +38,7 @@ struct PriorityBadge: View {
|
||||
case "high": return Color.appError
|
||||
case "medium": return Color.appAccent
|
||||
case "low": return Color.appPrimary
|
||||
default: return Color.appTextSecondary.opacity(0.7)
|
||||
default: return Color.appTextSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,5 +50,5 @@ struct PriorityBadge: View {
|
||||
PriorityBadge(priority: "low")
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.background(WarmGradientBackground())
|
||||
}
|
||||
|
||||
@@ -4,14 +4,24 @@ struct StatusBadge: View {
|
||||
let status: String
|
||||
|
||||
var body: some View {
|
||||
Text(formatStatus(status))
|
||||
.font(.caption.weight(.medium))
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
.background(statusColor.opacity(0.15))
|
||||
.foregroundColor(statusColor)
|
||||
.cornerRadius(AppRadius.xs)
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: statusIcon)
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
|
||||
Text(formatStatus(status))
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.foregroundColor(statusColor)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(statusColor.opacity(0.12))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(statusColor.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func formatStatus(_ status: String) -> String {
|
||||
@@ -22,13 +32,23 @@ struct StatusBadge: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var statusIcon: String {
|
||||
switch status {
|
||||
case "completed": return "checkmark.circle.fill"
|
||||
case "in_progress": return "play.circle.fill"
|
||||
case "pending": return "clock.fill"
|
||||
case "cancelled": return "xmark.circle.fill"
|
||||
default: return "circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var statusColor: Color {
|
||||
switch status {
|
||||
case "completed": return Color.appPrimary
|
||||
case "in_progress": return Color.appAccent
|
||||
case "pending": return Color.appAccent
|
||||
case "pending": return Color.appSecondary
|
||||
case "cancelled": return Color.appError
|
||||
default: return Color.appTextSecondary.opacity(0.7)
|
||||
default: return Color.appTextSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,5 +61,5 @@ struct StatusBadge: View {
|
||||
StatusBadge(status: "cancelled")
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.background(WarmGradientBackground())
|
||||
}
|
||||
|
||||
@@ -12,14 +12,15 @@ struct TaskCard: View {
|
||||
let onUnarchive: (() -> Void)?
|
||||
|
||||
@State private var isCompletionsExpanded = false
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header
|
||||
HStack(alignment: .top, spacing: AppSpacing.sm) {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(task.title)
|
||||
.font(.title3.weight(.semibold))
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -36,66 +37,50 @@ struct TaskCard: View {
|
||||
// Description
|
||||
if !task.description_.isEmpty {
|
||||
Text(task.description_)
|
||||
.font(.callout)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.lineLimit(3)
|
||||
}
|
||||
|
||||
// Metadata
|
||||
HStack(spacing: AppSpacing.md) {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "repeat")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||
Text(task.frequencyDisplayName ?? "")
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.xs)
|
||||
// Metadata Pills
|
||||
HStack(spacing: 10) {
|
||||
TaskMetadataPill(
|
||||
icon: "repeat",
|
||||
text: task.frequencyDisplayName ?? ""
|
||||
)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let effectiveDate = task.effectiveDueDate {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.7))
|
||||
Text(DateUtils.formatDate(effectiveDate))
|
||||
.font(.caption.weight(.medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.sm)
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.xs)
|
||||
TaskMetadataPill(
|
||||
icon: "calendar",
|
||||
text: DateUtils.formatDate(effectiveDate),
|
||||
color: DateUtils.isOverdue(effectiveDate) ? Color.appError : Color.appTextSecondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Completions
|
||||
if task.completions.count > 0 {
|
||||
Divider()
|
||||
.padding(.vertical, AppSpacing.xxs)
|
||||
OrganicDivider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 10) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appAccent.opacity(0.1))
|
||||
.frame(width: 24, height: 24)
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
.frame(width: 28, height: 28)
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appAccent)
|
||||
}
|
||||
Text("\(L10n.Tasks.completions.capitalized) (\(task.completions.count))")
|
||||
.font(.footnote.weight(.medium))
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Spacer()
|
||||
Image(systemName: isCompletionsExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -115,130 +100,205 @@ struct TaskCard: View {
|
||||
|
||||
// Primary Actions
|
||||
if task.showCompletedButton {
|
||||
VStack(spacing: AppSpacing.xs) {
|
||||
VStack(spacing: 10) {
|
||||
if let onMarkInProgress = onMarkInProgress, !task.inProgress {
|
||||
Button(action: onMarkInProgress) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Text(L10n.Tasks.inProgress)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.frame(height: 48)
|
||||
.foregroundColor(Color.appAccent)
|
||||
.background(Color.appAccent.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if task.showCompletedButton, let onComplete = onComplete {
|
||||
Button(action: onComplete) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
Text(L10n.Tasks.complete)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.frame(height: 48)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appPrimary.opacity(0.9)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary Actions
|
||||
VStack(spacing: AppSpacing.xs) {
|
||||
HStack(spacing: AppSpacing.xs) {
|
||||
Button(action: onEdit) {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "pencil")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(L10n.Tasks.edit)
|
||||
.font(.footnote.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.sm)
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
HStack(spacing: 10) {
|
||||
TaskSecondaryButton(
|
||||
icon: "pencil",
|
||||
text: L10n.Tasks.edit,
|
||||
color: Color.appPrimary,
|
||||
action: onEdit
|
||||
)
|
||||
|
||||
if let onCancel = onCancel {
|
||||
Button(action: onCancel) {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "xmark.circle")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(L10n.Tasks.cancel)
|
||||
.font(.footnote.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.foregroundColor(Color.appError)
|
||||
.background(Color.appError.opacity(0.1))
|
||||
.cornerRadius(AppRadius.sm)
|
||||
}
|
||||
TaskSecondaryButton(
|
||||
icon: "xmark.circle",
|
||||
text: L10n.Tasks.cancel,
|
||||
color: Color.appError,
|
||||
isDestructive: true,
|
||||
action: onCancel
|
||||
)
|
||||
} else if let onUncancel = onUncancel {
|
||||
Button(action: onUncancel) {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "arrow.uturn.backward")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(L10n.Tasks.restore)
|
||||
.font(.footnote.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.sm)
|
||||
}
|
||||
TaskSecondaryButton(
|
||||
icon: "arrow.uturn.backward",
|
||||
text: L10n.Tasks.restore,
|
||||
color: Color.appPrimary,
|
||||
isFilled: true,
|
||||
action: onUncancel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if task.archived {
|
||||
if let onUnarchive = onUnarchive {
|
||||
Button(action: onUnarchive) {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "tray.and.arrow.up")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(L10n.Tasks.unarchive)
|
||||
.font(.footnote.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.sm)
|
||||
}
|
||||
TaskSecondaryButton(
|
||||
icon: "tray.and.arrow.up",
|
||||
text: L10n.Tasks.unarchive,
|
||||
color: Color.appPrimary,
|
||||
action: onUnarchive
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if let onArchive = onArchive {
|
||||
Button(action: onArchive) {
|
||||
HStack(spacing: AppSpacing.xxs) {
|
||||
Image(systemName: "archivebox")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
Text(L10n.Tasks.archive)
|
||||
.font(.footnote.weight(.medium))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.sm)
|
||||
}
|
||||
TaskSecondaryButton(
|
||||
icon: "archivebox",
|
||||
text: L10n.Tasks.archive,
|
||||
color: Color.appTextSecondary,
|
||||
action: onArchive
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppSpacing.md)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
|
||||
.padding(OrganicSpacing.cozy)
|
||||
.background(TaskCardBackground())
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.naturalShadow(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Metadata Pill
|
||||
|
||||
private struct TaskMetadataPill: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
var color: Color = Color.appTextSecondary
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundColor(color.opacity(0.8))
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .medium, design: .rounded))
|
||||
.foregroundColor(color)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appBackgroundPrimary.opacity(0.6))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(color.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Secondary Button
|
||||
|
||||
private struct TaskSecondaryButton: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
var color: Color = Color.appPrimary
|
||||
var isDestructive: Bool = false
|
||||
var isFilled: Bool = false
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 40)
|
||||
.foregroundColor(isFilled ? Color.appTextOnPrimary : color)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(
|
||||
isFilled
|
||||
? color
|
||||
: (isDestructive ? color.opacity(0.1) : Color.appBackgroundPrimary.opacity(0.5))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.stroke(color.opacity(isFilled ? 0 : 0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Card Background
|
||||
|
||||
private struct TaskCardBackground: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
|
||||
// Subtle organic blob
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 1)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.04),
|
||||
Color.appPrimary.opacity(0.01)
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.4
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.6)
|
||||
.offset(x: geo.size.width * 0.55, y: -geo.size.height * 0.1)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
|
||||
// Grain texture
|
||||
GrainTexture(opacity: 0.012)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,20 @@ struct CompletionHistorySheet: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if viewModel.isLoadingCompletions {
|
||||
loadingView
|
||||
} else if let error = viewModel.completionsError {
|
||||
errorView(error)
|
||||
} else if viewModel.completions.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
completionsList
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
Group {
|
||||
if viewModel.isLoadingCompletions {
|
||||
loadingView
|
||||
} else if let error = viewModel.completionsError {
|
||||
errorView(error)
|
||||
} else if viewModel.completions.isEmpty {
|
||||
emptyView
|
||||
} else {
|
||||
completionsList
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(L10n.Tasks.completionHistory)
|
||||
@@ -28,9 +33,9 @@ struct CompletionHistorySheet: View {
|
||||
Button(L10n.Common.done) {
|
||||
isPresented = false
|
||||
}
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
}
|
||||
}
|
||||
.background(Color.appBackgroundPrimary)
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.loadCompletions(taskId: taskId)
|
||||
@@ -43,56 +48,80 @@ struct CompletionHistorySheet: View {
|
||||
// MARK: - Subviews
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
|
||||
.scaleEffect(1.5)
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 64, height: 64)
|
||||
ProgressView()
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
|
||||
.scaleEffect(1.2)
|
||||
}
|
||||
Text(L10n.Tasks.loadingCompletions)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 15, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func errorView(_ error: String) -> some View {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Color.appError)
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appError.opacity(0.1))
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 32, weight: .semibold))
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
|
||||
Text(L10n.Tasks.failedToLoad)
|
||||
.font(.headline)
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
.padding(.horizontal, OrganicSpacing.spacious)
|
||||
|
||||
Button(action: {
|
||||
viewModel.loadCompletions(taskId: taskId)
|
||||
}) {
|
||||
Label(L10n.Common.retry, systemImage: "arrow.clockwise")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text(L10n.Common.retry)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.padding(.top, AppSpacing.sm)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var emptyView: some View {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 48))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appTextSecondary.opacity(0.08))
|
||||
.frame(width: 80, height: 80)
|
||||
Image(systemName: "checkmark.circle")
|
||||
.font(.system(size: 36, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||
}
|
||||
|
||||
Text(L10n.Tasks.noCompletionsYet)
|
||||
.font(.headline)
|
||||
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text(L10n.Tasks.notCompleted)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
@@ -100,30 +129,46 @@ struct CompletionHistorySheet: View {
|
||||
|
||||
private var completionsList: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppSpacing.sm) {
|
||||
VStack(spacing: OrganicSpacing.cozy) {
|
||||
// Task title header
|
||||
HStack {
|
||||
Image(systemName: "doc.text")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
.frame(width: 36, height: 36)
|
||||
Image(systemName: "doc.text")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text(taskTitle)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.lineLimit(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? L10n.Tasks.completion : L10n.Tasks.completions)")
|
||||
.font(.caption)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appTextSecondary.opacity(0.1))
|
||||
)
|
||||
}
|
||||
.padding()
|
||||
.padding(OrganicSpacing.cozy)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.naturalShadow(.subtle)
|
||||
|
||||
// Completions list
|
||||
ForEach(viewModel.completions, id: \.id) { completion in
|
||||
CompletionHistoryCard(completion: completion)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(OrganicSpacing.cozy)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,20 +179,20 @@ struct CompletionHistoryCard: View {
|
||||
@State private var showPhotoSheet = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
// Header with date and completed by
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(DateUtils.formatDateTimeWithTime(completion.completionDate))
|
||||
.font(.headline)
|
||||
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let completedBy = completion.completedByName, !completedBy.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.caption2)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
Text("\(L10n.Tasks.completedByName) \(completedBy)")
|
||||
.font(.caption)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
}
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
@@ -157,39 +202,48 @@ struct CompletionHistoryCard: View {
|
||||
|
||||
// Rating badge
|
||||
if let rating = completion.rating {
|
||||
HStack(spacing: 2) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "star.fill")
|
||||
.font(.caption)
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
Text("\(rating)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.bold)
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
}
|
||||
.foregroundColor(Color.appAccent)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.appAccent.opacity(0.1))
|
||||
.cornerRadius(AppRadius.sm)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.appAccent.opacity(0.12))
|
||||
.overlay(
|
||||
Capsule()
|
||||
.stroke(Color.appAccent.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
OrganicDivider()
|
||||
|
||||
// Contractor info
|
||||
if let contractor = completion.contractorDetails {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "wrench.and.screwdriver.fill")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 24)
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "wrench.and.screwdriver.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contractor.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let company = contractor.company {
|
||||
Text(company)
|
||||
.font(.caption)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
}
|
||||
@@ -198,28 +252,33 @@ struct CompletionHistoryCard: View {
|
||||
|
||||
// Cost
|
||||
if let cost = completion.actualCost {
|
||||
HStack(spacing: AppSpacing.sm) {
|
||||
Image(systemName: "dollarsign.circle.fill")
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 24)
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 32, height: 32)
|
||||
Image(systemName: "dollarsign.circle.fill")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text("$\(cost)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
// Notes
|
||||
if !completion.notes.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(L10n.Tasks.notes)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.textCase(.uppercase)
|
||||
.tracking(0.5)
|
||||
|
||||
Text(completion.notes)
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
@@ -230,26 +289,27 @@ struct CompletionHistoryCard: View {
|
||||
Button(action: {
|
||||
showPhotoSheet = true
|
||||
}) {
|
||||
HStack {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.subheadline)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Text("\(L10n.Tasks.viewPhotos) (\(completion.images.count))")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.sm)
|
||||
}
|
||||
.padding(.top, 4)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.padding(OrganicSpacing.cozy)
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
.naturalShadow(.medium)
|
||||
.sheet(isPresented: $showPhotoSheet) {
|
||||
PhotoViewerSheet(images: completion.images)
|
||||
}
|
||||
|
||||
@@ -98,6 +98,9 @@ struct TaskFormView: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
.ignoresSafeArea()
|
||||
|
||||
Form {
|
||||
// Residence Picker (only if needed)
|
||||
if needsResidenceSelection, let residences = residences {
|
||||
@@ -130,31 +133,40 @@ struct TaskFormView: View {
|
||||
Button {
|
||||
showingTemplatesBrowser = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.frame(width: 28)
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.12))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
|
||||
Text("Browse Task Templates")
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Browse Task Templates")
|
||||
.font(.system(size: 16, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
Text("\(dataManager.taskTemplateCount) common tasks")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text("\(dataManager.taskTemplateCount) tasks")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
} header: {
|
||||
Text("Quick Start")
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
} footer: {
|
||||
Text("Choose from common home maintenance tasks or create your own below")
|
||||
.font(.caption)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.listRowBackground(Color.appBackgroundSecondary)
|
||||
@@ -291,19 +303,36 @@ struct TaskFormView: View {
|
||||
.blur(radius: isLoadingLookups ? 3 : 0)
|
||||
|
||||
if isLoadingLookups {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.appPrimary.opacity(0.1))
|
||||
.frame(width: 64, height: 64)
|
||||
ProgressView()
|
||||
.scaleEffect(1.2)
|
||||
.tint(Color.appPrimary)
|
||||
}
|
||||
Text(L10n.Tasks.loading)
|
||||
.font(.system(size: 15, weight: .medium, design: .rounded))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(OrganicSpacing.spacious)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||
.fill(Color.appBackgroundSecondary)
|
||||
.overlay(
|
||||
GrainTexture(opacity: 0.015)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
)
|
||||
)
|
||||
.naturalShadow(.medium)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(Color.appBackgroundPrimary.opacity(0.8))
|
||||
.background(Color.appBackgroundPrimary.opacity(0.9))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Color.appBackgroundPrimary)
|
||||
.background(Color.clear)
|
||||
.navigationTitle(isEditMode ? L10n.Tasks.editTitle : L10n.Tasks.addTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
|
||||
Reference in New Issue
Block a user