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:
Trey t
2025-12-16 20:15:32 -06:00
parent 67f8dcc80f
commit 3598a8d57f
15 changed files with 2318 additions and 703 deletions

View 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())
}

View File

@@ -40,49 +40,70 @@ struct LoginView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
ZStack { ZStack {
// Background gradient // Warm organic background
Color.appBackgroundPrimary WarmGradientBackground()
.ignoresSafeArea()
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.xl) { VStack(spacing: OrganicSpacing.spacious) {
Spacer() Spacer()
.frame(height: AppSpacing.xxxl) .frame(height: OrganicSpacing.airy)
// Hero Section // Hero Section
VStack(spacing: AppSpacing.lg) { VStack(spacing: OrganicSpacing.comfortable) {
// App Icon with gradient // App Icon with organic glow
ZStack { 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") Image("icon")
.font(.system(size: 50, weight: .semibold)) .resizable()
.foregroundStyle(.white) .scaledToFit()
.frame(width: 80, height: 80)
} }
VStack(spacing: AppSpacing.xs) { VStack(spacing: 8) {
Text(L10n.Auth.welcomeBack) Text(L10n.Auth.welcomeBack)
.font(.title2.weight(.bold)) .font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(L10n.Auth.signInSubtitle) Text(L10n.Auth.signInSubtitle)
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
// Login Card // Login Card
VStack(spacing: AppSpacing.lg) { VStack(spacing: 20) {
// Username Field // Username Field
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(alignment: .leading, spacing: 8) {
Text(L10n.Auth.loginUsernameLabel) Text(L10n.Auth.loginUsernameLabel)
.font(.subheadline.weight(.medium)) .font(.system(size: 14, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
Image(systemName: "envelope.fill") ZStack {
.foregroundColor(Color.appTextSecondary) Circle()
.frame(width: 20) .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) TextField(L10n.Auth.enterEmail, text: $viewModel.username)
.font(.system(size: 16, weight: .medium))
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.keyboardType(.emailAddress) .keyboardType(.emailAddress)
@@ -97,31 +118,36 @@ struct LoginView: View {
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField)
} }
.padding(AppSpacing.md) .padding(16)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundPrimary.opacity(0.5))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.md) RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(focusedField == .username ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) .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) .animation(.easeInOut(duration: 0.2), value: focusedField)
} }
// Password Field // Password Field
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(alignment: .leading, spacing: 8) {
Text(L10n.Auth.loginPasswordLabel) Text(L10n.Auth.loginPasswordLabel)
.font(.subheadline.weight(.medium)) .font(.system(size: 14, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
Image(systemName: "lock.fill") ZStack {
.foregroundColor(Color.appTextSecondary) Circle()
.frame(width: 20) .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 { Group {
if isPasswordVisible { if isPasswordVisible {
TextField(L10n.Auth.enterPassword, text: $viewModel.password) TextField(L10n.Auth.enterPassword, text: $viewModel.password)
.font(.system(size: 16, weight: .medium))
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled() .autocorrectionDisabled()
.textContentType(.password) .textContentType(.password)
@@ -133,6 +159,7 @@ struct LoginView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField)
} else { } else {
SecureField(L10n.Auth.enterPassword, text: $viewModel.password) SecureField(L10n.Auth.enterPassword, text: $viewModel.password)
.font(.system(size: 16, weight: .medium))
.textContentType(.password) .textContentType(.password)
.focused($focusedField, equals: .password) .focused($focusedField, equals: .password)
.submitLabel(.go) .submitLabel(.go)
@@ -147,19 +174,18 @@ struct LoginView: View {
isPasswordVisible.toggle() isPasswordVisible.toggle()
}) { }) {
Image(systemName: isPasswordVisible ? "eye.slash.fill" : "eye.fill") Image(systemName: isPasswordVisible ? "eye.slash.fill" : "eye.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.frame(width: 20)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle)
} }
.padding(AppSpacing.md) .padding(16)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundPrimary.opacity(0.5))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay( .overlay(
RoundedRectangle(cornerRadius: AppRadius.md) RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(focusedField == .password ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) .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) .animation(.easeInOut(duration: 0.2), value: focusedField)
.onChange(of: viewModel.password) { _, _ in .onChange(of: viewModel.password) { _, _ in
viewModel.clearError() viewModel.clearError()
@@ -172,24 +198,24 @@ struct LoginView: View {
Button(L10n.Auth.forgotPassword) { Button(L10n.Auth.forgotPassword) {
showPasswordReset = true showPasswordReset = true
} }
.font(.subheadline.weight(.medium)) .font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton)
} }
// Error Message // Error Message
if let errorMessage = viewModel.errorMessage { if let errorMessage = viewModel.errorMessage {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(errorMessage) Text(errorMessage)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer() Spacer()
} }
.padding(AppSpacing.md) .padding(16)
.background(Color.appError.opacity(0.1)) .background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
// Login Button // Login Button
@@ -200,19 +226,14 @@ struct LoginView: View {
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
// Divider // Divider
HStack { HStack(spacing: 12) {
Rectangle() OrganicDivider()
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
Text(L10n.Auth.orDivider) Text(L10n.Auth.orDivider)
.font(.subheadline) .font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.padding(.horizontal, AppSpacing.sm) OrganicDivider()
Rectangle()
.fill(Color.appTextSecondary.opacity(0.3))
.frame(height: 1)
} }
.padding(.vertical, AppSpacing.xs) .padding(.vertical, 8)
// Sign in with Apple Button // Sign in with Apple Button
SignInWithAppleButton( SignInWithAppleButton(
@@ -221,8 +242,8 @@ struct LoginView: View {
}, },
onCompletion: { _ in } onCompletion: { _ in }
) )
.frame(height: 56) .frame(height: 54)
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.signInWithAppleButtonStyle(.black) .signInWithAppleButtonStyle(.black)
.disabled(appleSignInViewModel.isLoading) .disabled(appleSignInViewModel.isLoading)
.opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0) .opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0)
@@ -238,51 +259,52 @@ struct LoginView: View {
// Apple Sign In loading indicator // Apple Sign In loading indicator
if appleSignInViewModel.isLoading { if appleSignInViewModel.isLoading {
HStack { HStack(spacing: 8) {
ProgressView() ProgressView()
.progressViewStyle(CircularProgressViewStyle()) .progressViewStyle(CircularProgressViewStyle())
.tint(Color.appPrimary)
Text(L10n.Auth.signingInWithApple) Text(L10n.Auth.signingInWithApple)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.padding(.top, AppSpacing.xs) .padding(.top, 8)
} }
// Apple Sign In Error // Apple Sign In Error
if let appleError = appleSignInViewModel.errorMessage { if let appleError = appleSignInViewModel.errorMessage {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 10) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: "exclamationmark.circle.fill")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Text(appleError) Text(appleError)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
Spacer() Spacer()
} }
.padding(AppSpacing.md) .padding(16)
.background(Color.appError.opacity(0.1)) .background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
// Sign Up Link // Sign Up Link
HStack(spacing: AppSpacing.xs) { HStack(spacing: 6) {
Text(L10n.Auth.dontHaveAccount) Text(L10n.Auth.dontHaveAccount)
.font(.body) .font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
Button(L10n.Auth.signUp) { Button(L10n.Auth.signUp) {
showingRegister = true showingRegister = true
} }
.font(.body) .font(.system(size: 15, weight: .bold, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton)
} }
.padding(.top, 8)
} }
.padding(AppSpacing.xl) .padding(OrganicSpacing.cozy)
.background(Color.appBackgroundSecondary) .background(LoginCardBackground())
.cornerRadius(AppRadius.xxl) .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.shadow(color: .black.opacity(0.08), radius: 20, y: 10) .naturalShadow(.pronounced)
.padding(.horizontal, AppSpacing.lg) .padding(.horizontal, 16)
Spacer() 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 // MARK: - Preview
#Preview { #Preview {
LoginView() LoginView()

View File

@@ -7,28 +7,48 @@ struct NotificationPreferencesView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Form { ZStack {
// Header Section WarmGradientBackground()
Section { .ignoresSafeArea()
VStack(spacing: 16) {
Image(systemName: "bell.badge.fill")
.font(.system(size: 60))
.foregroundStyle(Color.appPrimary.gradient)
Text(L10n.Profile.notificationPreferences) Form {
.font(.title2) // Header Section
.fontWeight(.bold) Section {
.foregroundColor(Color.appTextPrimary) 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) Image(systemName: "bell.badge.fill")
.font(.subheadline) .font(.system(size: 48))
.foregroundColor(Color.appTextSecondary) .foregroundStyle(Color.appPrimary.gradient)
.multilineTextAlignment(.center) }
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) .listRowBackground(Color.clear)
.padding(.vertical)
}
.listRowBackground(Color.clear)
if viewModel.isLoading { if viewModel.isLoading {
Section { Section {
@@ -282,9 +302,10 @@ struct NotificationPreferencesView: View {
.listRowBackground(Color.appBackgroundSecondary) .listRowBackground(Color.appBackgroundSecondary)
} }
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary) .background(Color.clear)
}
.navigationTitle(L10n.Profile.notifications) .navigationTitle(L10n.Profile.notifications)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -292,6 +313,7 @@ struct NotificationPreferencesView: View {
Button(L10n.Common.done) { Button(L10n.Common.done) {
dismiss() dismiss()
} }
.font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
} }

View File

@@ -11,31 +11,57 @@ struct ProfileView: View {
var body: some View { var body: some View {
NavigationView { NavigationView {
if viewModel.isLoadingUser { ZStack {
VStack { WarmGradientBackground()
ProgressView() .ignoresSafeArea()
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)
Text(L10n.Profile.profileSettings) if viewModel.isLoadingUser {
.font(.title2) VStack(spacing: OrganicSpacing.comfortable) {
.fontWeight(.bold) ZStack {
.foregroundColor(Color.appTextPrimary) Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 64, height: 64)
ProgressView()
.scaleEffect(1.2)
.tint(Color.appPrimary)
} }
.frame(maxWidth: .infinity) Text(L10n.Profile.loadingProfile)
.padding(.vertical) .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 { Section {
TextField(L10n.Profile.firstName, text: $viewModel.firstName) TextField(L10n.Profile.firstName, text: $viewModel.firstName)
@@ -122,30 +148,32 @@ struct ProfileView: View {
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary) .background(Color.clear)
.navigationTitle(L10n.Profile.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button(L10n.Common.cancel) {
dismiss()
}
}
} }
.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() }
)
} }
} }
} }

View File

@@ -6,22 +6,27 @@ struct ThemeSelectionView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { ZStack {
ForEach(ThemeID.allCases, id: \.self) { theme in WarmGradientBackground()
Button(action: { .ignoresSafeArea()
selectTheme(theme)
}) { List {
ThemeRow( ForEach(ThemeID.allCases, id: \.self) { theme in
theme: theme, Button(action: {
isSelected: themeManager.currentTheme == theme 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) .navigationTitle(L10n.Profile.appearance)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
@@ -29,6 +34,7 @@ struct ThemeSelectionView: View {
Button(L10n.Common.done) { Button(L10n.Common.done) {
dismiss() dismiss()
} }
.font(.system(size: 16, weight: .semibold, design: .rounded))
} }
} }
} }
@@ -53,27 +59,31 @@ struct ThemeRow: View {
let isSelected: Bool let isSelected: Bool
var body: some View { var body: some View {
HStack(spacing: AppSpacing.md) { HStack(spacing: 14) {
// Theme preview circles // Theme preview circles
HStack(spacing: 4) { HStack(spacing: 5) {
ForEach(0..<3, id: \.self) { index in ForEach(0..<3, id: \.self) { index in
Circle() Circle()
.fill(theme.previewColors[index]) .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)) .background(Color.appBackgroundPrimary.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.md)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
// Theme info // Theme info
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 3) {
Text(theme.displayName) Text(theme.displayName)
.font(.headline) .font(.system(size: 16, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(theme.description) Text(theme.description)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
@@ -81,12 +91,17 @@ struct ThemeRow: View {
// Checkmark for selected theme // Checkmark for selected theme
if isSelected { if isSelected {
Image(systemName: "checkmark.circle.fill") ZStack {
.font(.title3) Circle()
.foregroundColor(Color.appPrimary) .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()) .contentShape(Rectangle())
} }
} }

View File

@@ -47,8 +47,7 @@ struct ResidenceDetailView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.appBackgroundPrimary WarmGradientBackground()
.ignoresSafeArea()
mainContent mainContent
} }
@@ -208,19 +207,19 @@ private extension ResidenceDetailView {
@ViewBuilder @ViewBuilder
func contentView(for residence: ResidenceResponse) -> some View { func contentView(for residence: ResidenceResponse) -> some View {
ScrollView { ScrollView(showsIndicators: false) {
VStack(spacing: 16) { VStack(spacing: OrganicSpacing.comfortable) {
PropertyHeaderCard(residence: residence) PropertyHeaderCard(residence: residence)
.padding(.horizontal) .padding(.horizontal, 16)
.padding(.top) .padding(.top, 8)
tasksSection tasksSection
.padding(.horizontal) .padding(.horizontal, 16)
contractorsSection contractorsSection
.padding(.horizontal) .padding(.horizontal, 16)
} }
.padding(.bottom) .padding(.bottom, OrganicSpacing.airy)
} }
} }
@@ -248,49 +247,81 @@ private extension ResidenceDetailView {
@ViewBuilder @ViewBuilder
var contractorsSection: some View { var contractorsSection: some View {
VStack(alignment: .leading, spacing: AppSpacing.md) { VStack(alignment: .leading, spacing: 16) {
// Section Header // Section Header
HStack(spacing: AppSpacing.sm) { HStack(alignment: .center, spacing: 12) {
Image(systemName: "person.2.fill") ZStack {
.font(.title2) Circle()
.foregroundColor(Color.appPrimary) .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) Text(L10n.Residences.contractors)
.font(.title2.weight(.bold)) .font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appTextPrimary)
Spacer()
} }
.padding(.top, AppSpacing.sm) .padding(.top, 8)
if isLoadingContractors { if isLoadingContractors {
HStack { HStack {
Spacer() Spacer()
ProgressView() ProgressView()
.tint(Color.appPrimary)
Spacer() Spacer()
} }
.padding() .padding(OrganicSpacing.cozy)
} else if let error = contractorsError { } else if let error = contractorsError {
Text("\(L10n.Common.error): \(error)") Text("\(L10n.Common.error): \(error)")
.foregroundColor(Color.appError) .foregroundColor(Color.appError)
.padding() .padding()
} else if contractors.isEmpty { } else if contractors.isEmpty {
// Empty state // Empty state with organic styling
VStack(spacing: AppSpacing.md) { VStack(spacing: 16) {
Image(systemName: "person.crop.circle.badge.plus") ZStack {
.font(.system(size: 48)) Circle()
.foregroundColor(Color.appTextSecondary.opacity(0.6)) .fill(
Text(L10n.Residences.noContractors) RadialGradient(
.font(.headline) colors: [
.foregroundColor(Color.appTextPrimary) Color.appPrimary.opacity(0.12),
Text(L10n.Residences.addContractorsPrompt) Color.appPrimary.opacity(0.04)
.font(.subheadline) ],
.foregroundColor(Color.appTextSecondary) 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) .frame(maxWidth: .infinity)
.padding(AppSpacing.xl) .padding(OrganicSpacing.spacious)
.background(Color.appBackgroundSecondary) .background(OrganicCardBackground(showBlob: true, blobVariation: 1))
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.naturalShadow(.subtle)
} else { } else {
// Contractors list // Contractors list
VStack(spacing: AppSpacing.sm) { VStack(spacing: 12) {
ForEach(contractors, id: \.id) { contractor in ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard( 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 // MARK: - Toolbars
private extension ResidenceDetailView { private extension ResidenceDetailView {

View File

@@ -14,8 +14,8 @@ struct ResidencesListView: View {
var body: some View { var body: some View {
ZStack { ZStack {
Color.appBackgroundPrimary // Warm organic background
.ignoresSafeArea() WarmGradientBackground()
if let response = viewModel.myResidences { if let response = viewModel.myResidences {
ListAsyncContentView( ListAsyncContentView(
@@ -29,7 +29,7 @@ struct ResidencesListView: View {
) )
}, },
emptyContent: { emptyContent: {
EmptyResidencesView() OrganicEmptyResidencesView()
}, },
onRefresh: { onRefresh: {
viewModel.loadMyResidences(forceRefresh: true) viewModel.loadMyResidences(forceRefresh: true)
@@ -53,9 +53,7 @@ struct ResidencesListView: View {
Button(action: { Button(action: {
showingSettings = true showingSettings = true
}) { }) {
Image(systemName: "gearshape.fill") OrganicToolbarButton(systemName: "gearshape.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
} }
@@ -70,9 +68,7 @@ struct ResidencesListView: View {
showingJoinResidence = true showingJoinResidence = true
} }
}) { }) {
Image(systemName: "person.badge.plus") OrganicToolbarButton(systemName: "person.badge.plus")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
} }
Button(action: { Button(action: {
@@ -84,9 +80,7 @@ struct ResidencesListView: View {
showingAddResidence = true showingAddResidence = true
} }
}) { }) {
Image(systemName: "plus.circle.fill") OrganicToolbarButton(systemName: "plus", isPrimary: true)
.font(.system(size: 22, weight: .semibold))
.foregroundColor(Color.appPrimary)
} }
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton) .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 // MARK: - Residences Content View
private struct ResidencesContent: View { private struct ResidencesContent: View {
@@ -156,36 +179,51 @@ private struct ResidencesContent: View {
var body: some View { var body: some View {
ScrollView(showsIndicators: false) { ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.lg) { VStack(spacing: OrganicSpacing.comfortable) {
// Summary Card // Summary Card with enhanced styling
SummaryCard(summary: summary) SummaryCard(summary: summary)
.padding(.horizontal, AppSpacing.md) .padding(.horizontal, 16)
.padding(.top, AppSpacing.sm) .padding(.top, 8)
// Properties Header // Properties Section Header
HStack { HStack(alignment: .center) {
VStack(alignment: .leading, spacing: AppSpacing.xxs) { VStack(alignment: .leading, spacing: 4) {
Text(L10n.Residences.yourProperties) Text(L10n.Residences.yourProperties)
.font(.title3.weight(.semibold)) .font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)") 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) .foregroundColor(Color.appTextSecondary)
} }
Spacer()
}
.padding(.horizontal, AppSpacing.md)
// Residences List Spacer()
ForEach(residences, id: \.id) { residence in
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) { // Decorative leaf
ResidenceCard(residence: residence) Image(systemName: "leaf.fill")
.padding(.horizontal, AppSpacing.md) .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) { .safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0) 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 { #Preview {
NavigationView { NavigationView {
ResidencesListView() ResidencesListView()

View File

@@ -3,108 +3,310 @@ import ComposeApp
struct PropertyHeaderCard: View { struct PropertyHeaderCard: View {
let residence: ResidenceResponse let residence: ResidenceResponse
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 0) {
HStack { // Header Section
VStack { HStack(alignment: .top, spacing: 16) {
Image("house_outline") // Property Icon
.resizable() PropertyDetailIcon()
.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() // Property Info
} VStack(alignment: .leading, spacing: 6) {
VStack(alignment: .leading, spacing: 4) {
Text(residence.name) Text(residence.name)
.font(.title2) .font(.system(size: 24, weight: .bold, design: .rounded))
.fontWeight(.bold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
if let propertyTypeName = residence.propertyTypeName { if let propertyTypeName = residence.propertyTypeName {
Text(propertyTypeName) Text(propertyTypeName.uppercased())
.font(.caption) .font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.tracking(1.5)
} }
} }
Spacer() 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 { if !residence.streetAddress.isEmpty {
Label(residence.streetAddress, systemImage: "mappin.circle.fill") HStack(spacing: 10) {
.font(.subheadline) ZStack {
.foregroundColor(Color.appTextPrimary) 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 { if !residence.city.isEmpty || !residence.stateProvince.isEmpty || !residence.postalCode.isEmpty {
Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)") HStack(spacing: 10) {
.font(.subheadline) Color.clear.frame(width: 32, height: 1) // Alignment spacer
.foregroundColor(Color.appTextSecondary)
Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)")
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
} }
if !residence.country.isEmpty { if !residence.country.isEmpty {
Text(residence.country) HStack(spacing: 10) {
.font(.caption) Color.clear.frame(width: 32, height: 1) // Alignment spacer
.foregroundColor(Color.appTextSecondary)
}
}
if let bedrooms = residence.bedrooms, Text(residence.country)
let bathrooms = residence.bathrooms { .font(.system(size: 13, weight: .medium))
Divider() .foregroundColor(Color.appTextSecondary.opacity(0.8))
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")
} }
} }
} }
.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(PropertyHeaderBackground())
.background(Color.appBackgroundSecondary) .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous))
.cornerRadius(16) .naturalShadow(.pronounced)
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) }
private func formatNumber(_ num: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
return formatter.string(from: NSNumber(value: num)) ?? "\(num)"
} }
} }
//#Preview { // MARK: - Property Detail Icon
// PropertyHeaderCard(residence: Residence(
// id: 1, private struct PropertyDetailIcon: View {
// owner: "My Beautiful Home", var body: some View {
// ownerUsername: "House", ZStack {
// name: "123 Main Street", // Outer glow
// propertyType: nil, Circle()
// streetAddress: "San Francisco", .fill(
// apartmentUnit: "CA", RadialGradient(
// city: "94102", colors: [
// stateProvince: "USA", Color.appPrimary.opacity(0.15),
// postalCode: 3, Color.appPrimary.opacity(0.05)
// country: 2.5, ],
// bedrooms: 1800, center: .center,
// bathrooms: 0.25, startRadius: 0,
// squareFootage: 2010, endRadius: 32
// lotSize: nil, )
// yearBuilt: nil, )
// description: nil, .frame(width: 64, height: 64)
// purchaseDate: true,
// purchasePrice: "testuser", // Inner circle
// isPrimary: 1, Circle()
// createdAt: "2024-01-01T00:00:00Z", .fill(
// updatedAt: "2024-01-01T00:00:00Z" RadialGradient(
// )) colors: [
// .padding() 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())
}

View File

@@ -9,136 +9,398 @@ struct ResidenceCard: View {
Int(residence.overdueCount) > 0 Int(residence.overdueCount) > 0
} }
/// Get task summary categories (max 3)
private var displayCategories: [TaskCategorySummary] {
Array(residence.taskSummary.categories.prefix(3))
}
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.md) { VStack(alignment: .leading, spacing: 0) {
// Header with property type icon (pulses when overdue tasks exist) // Top Section: Icon + Property Info + Primary Badge
HStack(spacing: AppSpacing.sm) { HStack(alignment: .top, spacing: 16) {
VStack { // Property Icon with organic styling
if hasOverdueTasks { PropertyIconView(hasOverdue: hasOverdueTasks)
PulsingIconView(backgroundColor: Color.appPrimary)
.frame(width: 44, height: 44) // Property Details
.padding([.trailing], AppSpacing.md) VStack(alignment: .leading, spacing: 6) {
} else { // Property Name
CaseraIconView(backgroundColor: Color.appPrimary) Text(residence.name)
.frame(width: 44, height: 44) .font(.system(size: 20, weight: .bold, design: .rounded))
.padding([.trailing], AppSpacing.md) .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() // Address
} if !residence.streetAddress.isEmpty {
HStack(spacing: 6) {
Image(systemName: "mappin")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Color.appPrimary.opacity(0.7))
VStack(alignment: .leading, spacing: AppSpacing.xxs) { Text(residence.streetAddress)
Text(residence.name) .font(.system(size: 13, weight: .medium))
.font(.title3.weight(.semibold)) .foregroundColor(Color.appTextSecondary)
.fontWeight(.bold) .lineLimit(1)
.foregroundColor(Color.appTextPrimary) }
.padding(.top, 2)
if let propertyTypeName = residence.propertyTypeName {
Text(propertyTypeName)
.font(.caption.weight(.medium))
.foregroundColor(Color.appTextSecondary)
.textCase(.uppercase)
.tracking(0.5)
} }
} }
Spacer() Spacer()
// Primary Badge
if residence.isPrimary { if residence.isPrimary {
VStack { PrimaryBadgeView()
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()
}
} }
} }
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 16)
// Address // Location Details (if available)
VStack(alignment: .leading, spacing: AppSpacing.xxs) { if !residence.city.isEmpty || !residence.stateProvince.isEmpty {
if !residence.streetAddress.isEmpty { HStack(spacing: 8) {
HStack(spacing: AppSpacing.xxs) { Image(systemName: "location.fill")
Image(systemName: "mappin.circle.fill") .font(.system(size: 10, weight: .medium))
.font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary.opacity(0.6))
.foregroundColor(Color.appTextSecondary)
Text(residence.streetAddress)
.font(.callout)
.foregroundColor(Color.appTextSecondary)
}
}
if !residence.city.isEmpty || !residence.stateProvince.isEmpty { Text("\(residence.city), \(residence.stateProvince)")
HStack(spacing: AppSpacing.xxs) { .font(.system(size: 12, weight: .medium))
Image(systemName: "location.fill") .foregroundColor(Color.appTextSecondary.opacity(0.8))
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
Text("\(residence.city ?? ""), \(residence.stateProvince ?? "")")
.font(.callout)
.foregroundColor(Color.appTextSecondary)
}
} }
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.bottom, 16)
} }
.padding(.vertical, AppSpacing.xs)
Divider() // Divider
OrganicDivider()
.padding(.horizontal, 16)
// Fully dynamic task stats from API - show first 3 categories // Task Stats Section
HStack(spacing: AppSpacing.sm) { if !displayCategories.isEmpty {
let displayCategories = Array(residence.taskSummary.categories.prefix(3)) ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(displayCategories, id: \.name) { category in
TaskCategoryChip(category: category)
}
ForEach(displayCategories, id: \.name) { category in // Show overdue count if any
TaskStatChip( if hasOverdueTasks {
icon: category.icons.ios, OverdueChip(count: Int(residence.overdueCount))
value: "\(category.count)", }
label: category.displayName, }
color: Color(hex: category.color) ?? Color.appTextSecondary .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(CardBackgroundView(hasOverdue: hasOverdueTasks))
.background(Color.appBackgroundSecondary) .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.cornerRadius(AppRadius.lg) .naturalShadow(.medium)
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y)
} }
} }
#Preview { // MARK: - Property Icon View
ResidenceCard(residence: ResidenceResponse(
id: 1, private struct PropertyIconView: View {
ownerId: 1, let hasOverdue: Bool
owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "", lastName: ""),
users: [], var body: some View {
name: "My Home", ZStack {
propertyTypeId: 1, // Background circle with gradient
propertyType: ResidenceType(id: 1, name: "House"), Circle()
streetAddress: "123 Main St", .fill(
apartmentUnit: "", RadialGradient(
city: "San Francisco", colors: [
stateProvince: "CA", Color.appPrimary,
postalCode: "94102", Color.appPrimary.opacity(0.85)
country: "USA", ],
bedrooms: 3, center: .topLeading,
bathrooms: 2.5, startRadius: 0,
squareFootage: 1800, endRadius: 52
lotSize: 0.25, )
yearBuilt: 2010, )
description: "", .frame(width: 52, height: 52)
purchaseDate: nil,
purchasePrice: nil, // Inner highlight
isPrimary: true, Circle()
isActive: true, .fill(
overdueCount: 1, RadialGradient(
createdAt: "2024-01-01T00:00:00Z", colors: [
updatedAt: "2024-01-01T00:00:00Z" Color.white.opacity(0.25),
)) Color.clear
.padding() ],
.background(Color.appBackgroundPrimary) 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())
} }

View File

@@ -3,67 +3,289 @@ import ComposeApp
struct SummaryCard: View { struct SummaryCard: View {
let summary: TotalSummary let summary: TotalSummary
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 0) {
HStack { // Header with greeting
Text("Overview") HStack(alignment: .center) {
.font(.title3) VStack(alignment: .leading, spacing: 4) {
.fontWeight(.bold) 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() Spacer()
}
HStack(spacing: 20) { // Decorative icon
SummaryStatView( ZStack {
icon: "house_outline", 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)", value: "\(summary.totalResidences)",
label: "Properties" label: "Properties",
accentColor: Color.appPrimary
) )
SummaryStatView( // Vertical divider
icon: "list.bullet", Rectangle()
.fill(Color.appTextSecondary.opacity(0.12))
.frame(width: 1, height: 50)
OrganicStatItem(
icon: "checklist",
value: "\(summary.totalTasks)", 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) { // Timeline Stats
SummaryStatView( HStack(spacing: 8) {
icon: "calendar", TimelineStatPill(
icon: "exclamationmark.circle.fill",
value: "\(summary.totalOverdue)", value: "\(summary.totalOverdue)",
label: "Over Due" label: "Overdue",
color: summary.totalOverdue > 0 ? Color.appError : Color.appTextSecondary,
isAlert: summary.totalOverdue > 0
) )
SummaryStatView( TimelineStatPill(
icon: "calendar",
value: "\(summary.tasksDueNextWeek)",
label: "Due This Week"
)
SummaryStatView(
icon: "calendar.badge.clock", icon: "calendar.badge.clock",
value: "\(summary.tasksDueNextWeek)",
label: "This Week",
color: Color.appAccent
)
TimelineStatPill(
icon: "calendar",
value: "\(summary.tasksDueNextMonth)", 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 { // MARK: - Organic Stat Item
SummaryCard(summary: TotalSummary(
totalResidences: 3, private struct OrganicStatItem: View {
totalTasks: 12, let icon: String
totalPending: 2, let value: String
totalOverdue: 1, let label: String
tasksDueNextWeek: 4, var accentColor: Color = Color.appPrimary
tasksDueNextMonth: 8
)) var body: some View {
.padding() 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())
} }

View File

@@ -4,19 +4,33 @@ struct PriorityBadge: View {
let priority: String let priority: String
var body: some View { var body: some View {
HStack(spacing: AppSpacing.xxs) { HStack(spacing: 5) {
Image(systemName: "exclamationmark.circle.fill") Image(systemName: priorityIcon)
.font(.system(size: 10, weight: .bold)) .font(.system(size: 10, weight: .bold))
Text(priority.capitalized) Text(priority.capitalized)
.font(.caption.weight(.medium)) .font(.system(size: 11, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
} }
.padding(.horizontal, AppSpacing.sm) .padding(.horizontal, 10)
.padding(.vertical, AppSpacing.xxs) .padding(.vertical, 5)
.background(priorityColor.opacity(0.15))
.foregroundColor(priorityColor) .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 { private var priorityColor: Color {
@@ -24,7 +38,7 @@ struct PriorityBadge: View {
case "high": return Color.appError case "high": return Color.appError
case "medium": return Color.appAccent case "medium": return Color.appAccent
case "low": return Color.appPrimary 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") PriorityBadge(priority: "low")
} }
.padding() .padding()
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
} }

View File

@@ -4,14 +4,24 @@ struct StatusBadge: View {
let status: String let status: String
var body: some View { var body: some View {
Text(formatStatus(status)) HStack(spacing: 5) {
.font(.caption.weight(.medium)) Image(systemName: statusIcon)
.fontWeight(.semibold) .font(.system(size: 10, weight: .bold))
.padding(.horizontal, AppSpacing.sm)
.padding(.vertical, AppSpacing.xxs) Text(formatStatus(status))
.background(statusColor.opacity(0.15)) .font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(statusColor) }
.cornerRadius(AppRadius.xs) .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 { 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 { private var statusColor: Color {
switch status { switch status {
case "completed": return Color.appPrimary case "completed": return Color.appPrimary
case "in_progress": return Color.appAccent case "in_progress": return Color.appAccent
case "pending": return Color.appAccent case "pending": return Color.appSecondary
case "cancelled": return Color.appError 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") StatusBadge(status: "cancelled")
} }
.padding() .padding()
.background(Color.appBackgroundPrimary) .background(WarmGradientBackground())
} }

View File

@@ -12,14 +12,15 @@ struct TaskCard: View {
let onUnarchive: (() -> Void)? let onUnarchive: (() -> Void)?
@State private var isCompletionsExpanded = false @State private var isCompletionsExpanded = false
@Environment(\.colorScheme) var colorScheme
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.md) { VStack(alignment: .leading, spacing: 16) {
// Header // Header
HStack(alignment: .top, spacing: AppSpacing.sm) { HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: AppSpacing.xs) { VStack(alignment: .leading, spacing: 8) {
Text(task.title) Text(task.title)
.font(.title3.weight(.semibold)) .font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.lineLimit(2) .lineLimit(2)
@@ -36,66 +37,50 @@ struct TaskCard: View {
// Description // Description
if !task.description_.isEmpty { if !task.description_.isEmpty {
Text(task.description_) Text(task.description_)
.font(.callout) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.lineLimit(3) .lineLimit(3)
} }
// Metadata // Metadata Pills
HStack(spacing: AppSpacing.md) { HStack(spacing: 10) {
HStack(spacing: AppSpacing.xxs) { TaskMetadataPill(
Image(systemName: "repeat") icon: "repeat",
.font(.system(size: 12, weight: .medium)) text: task.frequencyDisplayName ?? ""
.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)
Spacer() Spacer()
if let effectiveDate = task.effectiveDueDate { if let effectiveDate = task.effectiveDueDate {
HStack(spacing: AppSpacing.xxs) { TaskMetadataPill(
Image(systemName: "calendar") icon: "calendar",
.font(.system(size: 12, weight: .medium)) text: DateUtils.formatDate(effectiveDate),
.foregroundColor(Color.appTextSecondary.opacity(0.7)) color: DateUtils.isOverdue(effectiveDate) ? Color.appError : Color.appTextSecondary
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)
} }
} }
// Completions // Completions
if task.completions.count > 0 { if task.completions.count > 0 {
Divider() OrganicDivider()
.padding(.vertical, AppSpacing.xxs) .padding(.vertical, 4)
VStack(alignment: .leading, spacing: AppSpacing.sm) { VStack(alignment: .leading, spacing: 12) {
HStack(spacing: AppSpacing.xs) { HStack(spacing: 10) {
ZStack { ZStack {
Circle() Circle()
.fill(Color.appAccent.opacity(0.1)) .fill(Color.appAccent.opacity(0.12))
.frame(width: 24, height: 24) .frame(width: 28, height: 28)
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
} }
Text("\(L10n.Tasks.completions.capitalized) (\(task.completions.count))") Text("\(L10n.Tasks.completions.capitalized) (\(task.completions.count))")
.font(.footnote.weight(.medium)) .font(.system(size: 13, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Spacer() Spacer()
Image(systemName: isCompletionsExpanded ? "chevron.up" : "chevron.down") Image(systemName: isCompletionsExpanded ? "chevron.up" : "chevron.down")
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
@@ -115,130 +100,205 @@ struct TaskCard: View {
// Primary Actions // Primary Actions
if task.showCompletedButton { if task.showCompletedButton {
VStack(spacing: AppSpacing.xs) { VStack(spacing: 10) {
if let onMarkInProgress = onMarkInProgress, !task.inProgress { if let onMarkInProgress = onMarkInProgress, !task.inProgress {
Button(action: onMarkInProgress) { Button(action: onMarkInProgress) {
HStack(spacing: AppSpacing.xs) { HStack(spacing: 8) {
Image(systemName: "play.circle.fill") Image(systemName: "play.circle.fill")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
Text(L10n.Tasks.inProgress) Text(L10n.Tasks.inProgress)
.font(.subheadline.weight(.medium)) .font(.system(size: 15, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 44) .frame(height: 48)
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
.background(Color.appAccent.opacity(0.1)) .background(
.cornerRadius(AppRadius.md) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color.appAccent.opacity(0.12))
)
} }
} }
if task.showCompletedButton, let onComplete = onComplete { if task.showCompletedButton, let onComplete = onComplete {
Button(action: onComplete) { Button(action: onComplete) {
HStack(spacing: AppSpacing.xs) { HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.font(.system(size: 16, weight: .semibold)) .font(.system(size: 16, weight: .semibold))
Text(L10n.Tasks.complete) Text(L10n.Tasks.complete)
.font(.subheadline.weight(.medium)) .font(.system(size: 15, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 44) .frame(height: 48)
.foregroundColor(Color.appTextOnPrimary) .foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary) .background(
.cornerRadius(AppRadius.md) RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appPrimary.opacity(0.9)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
} }
} }
} }
} }
// Secondary Actions // Secondary Actions
VStack(spacing: AppSpacing.xs) { VStack(spacing: 10) {
HStack(spacing: AppSpacing.xs) { HStack(spacing: 10) {
Button(action: onEdit) { TaskSecondaryButton(
HStack(spacing: AppSpacing.xxs) { icon: "pencil",
Image(systemName: "pencil") text: L10n.Tasks.edit,
.font(.system(size: 14, weight: .medium)) color: Color.appPrimary,
Text(L10n.Tasks.edit) action: onEdit
.font(.footnote.weight(.medium)) )
}
.frame(maxWidth: .infinity)
.frame(height: 36)
.foregroundColor(Color.appPrimary)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.sm)
}
if let onCancel = onCancel { if let onCancel = onCancel {
Button(action: onCancel) { TaskSecondaryButton(
HStack(spacing: AppSpacing.xxs) { icon: "xmark.circle",
Image(systemName: "xmark.circle") text: L10n.Tasks.cancel,
.font(.system(size: 14, weight: .medium)) color: Color.appError,
Text(L10n.Tasks.cancel) isDestructive: true,
.font(.footnote.weight(.medium)) action: onCancel
} )
.frame(maxWidth: .infinity)
.frame(height: 36)
.foregroundColor(Color.appError)
.background(Color.appError.opacity(0.1))
.cornerRadius(AppRadius.sm)
}
} else if let onUncancel = onUncancel { } else if let onUncancel = onUncancel {
Button(action: onUncancel) { TaskSecondaryButton(
HStack(spacing: AppSpacing.xxs) { icon: "arrow.uturn.backward",
Image(systemName: "arrow.uturn.backward") text: L10n.Tasks.restore,
.font(.system(size: 14, weight: .medium)) color: Color.appPrimary,
Text(L10n.Tasks.restore) isFilled: true,
.font(.footnote.weight(.medium)) action: onUncancel
} )
.frame(maxWidth: .infinity)
.frame(height: 36)
.foregroundColor(Color.appTextOnPrimary)
.background(Color.appPrimary)
.cornerRadius(AppRadius.sm)
}
} }
} }
if task.archived { if task.archived {
if let onUnarchive = onUnarchive { if let onUnarchive = onUnarchive {
Button(action: onUnarchive) { TaskSecondaryButton(
HStack(spacing: AppSpacing.xxs) { icon: "tray.and.arrow.up",
Image(systemName: "tray.and.arrow.up") text: L10n.Tasks.unarchive,
.font(.system(size: 14, weight: .medium)) color: Color.appPrimary,
Text(L10n.Tasks.unarchive) action: onUnarchive
.font(.footnote.weight(.medium)) )
}
.frame(maxWidth: .infinity)
.frame(height: 36)
.foregroundColor(Color.appPrimary)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.sm)
}
} }
} else { } else {
if let onArchive = onArchive { if let onArchive = onArchive {
Button(action: onArchive) { TaskSecondaryButton(
HStack(spacing: AppSpacing.xxs) { icon: "archivebox",
Image(systemName: "archivebox") text: L10n.Tasks.archive,
.font(.system(size: 14, weight: .medium)) color: Color.appTextSecondary,
Text(L10n.Tasks.archive) action: onArchive
.font(.footnote.weight(.medium)) )
}
.frame(maxWidth: .infinity)
.frame(height: 36)
.foregroundColor(Color.appTextSecondary)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.sm)
}
} }
} }
} }
} }
.padding(AppSpacing.md) .padding(OrganicSpacing.cozy)
.background(Color.appBackgroundSecondary) .background(TaskCardBackground())
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) .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)
}
} }
} }

View File

@@ -10,15 +10,20 @@ struct CompletionHistorySheet: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
Group { ZStack {
if viewModel.isLoadingCompletions { WarmGradientBackground()
loadingView .ignoresSafeArea()
} else if let error = viewModel.completionsError {
errorView(error) Group {
} else if viewModel.completions.isEmpty { if viewModel.isLoadingCompletions {
emptyView loadingView
} else { } else if let error = viewModel.completionsError {
completionsList errorView(error)
} else if viewModel.completions.isEmpty {
emptyView
} else {
completionsList
}
} }
} }
.navigationTitle(L10n.Tasks.completionHistory) .navigationTitle(L10n.Tasks.completionHistory)
@@ -28,9 +33,9 @@ struct CompletionHistorySheet: View {
Button(L10n.Common.done) { Button(L10n.Common.done) {
isPresented = false isPresented = false
} }
.font(.system(size: 16, weight: .semibold, design: .rounded))
} }
} }
.background(Color.appBackgroundPrimary)
} }
.onAppear { .onAppear {
viewModel.loadCompletions(taskId: taskId) viewModel.loadCompletions(taskId: taskId)
@@ -43,56 +48,80 @@ struct CompletionHistorySheet: View {
// MARK: - Subviews // MARK: - Subviews
private var loadingView: some View { private var loadingView: some View {
VStack(spacing: AppSpacing.md) { VStack(spacing: OrganicSpacing.comfortable) {
ProgressView() ZStack {
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary)) Circle()
.scaleEffect(1.5) .fill(Color.appPrimary.opacity(0.1))
.frame(width: 64, height: 64)
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
.scaleEffect(1.2)
}
Text(L10n.Tasks.loadingCompletions) Text(L10n.Tasks.loadingCompletions)
.font(.subheadline) .font(.system(size: 15, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private func errorView(_ error: String) -> some View { private func errorView(_ error: String) -> some View {
VStack(spacing: AppSpacing.md) { VStack(spacing: OrganicSpacing.comfortable) {
Image(systemName: "exclamationmark.triangle.fill") ZStack {
.font(.system(size: 48)) Circle()
.foregroundColor(Color.appError) .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) Text(L10n.Tasks.failedToLoad)
.font(.headline) .font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(error) Text(error)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal) .padding(.horizontal, OrganicSpacing.spacious)
Button(action: { Button(action: {
viewModel.loadCompletions(taskId: taskId) viewModel.loadCompletions(taskId: taskId)
}) { }) {
Label(L10n.Common.retry, systemImage: "arrow.clockwise") HStack(spacing: 8) {
.foregroundColor(Color.appPrimary) 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) .frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private var emptyView: some View { private var emptyView: some View {
VStack(spacing: AppSpacing.md) { VStack(spacing: OrganicSpacing.comfortable) {
Image(systemName: "checkmark.circle") ZStack {
.font(.system(size: 48)) Circle()
.foregroundColor(Color.appTextSecondary.opacity(0.5)) .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) Text(L10n.Tasks.noCompletionsYet)
.font(.headline) .font(.system(size: 18, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
Text(L10n.Tasks.notCompleted) Text(L10n.Tasks.notCompleted)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -100,30 +129,46 @@ struct CompletionHistorySheet: View {
private var completionsList: some View { private var completionsList: some View {
ScrollView { ScrollView {
VStack(spacing: AppSpacing.sm) { VStack(spacing: OrganicSpacing.cozy) {
// Task title header // Task title header
HStack { HStack(spacing: 12) {
Image(systemName: "doc.text") ZStack {
.foregroundColor(Color.appPrimary) 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) Text(taskTitle)
.font(.subheadline) .font(.system(size: 15, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
.lineLimit(2)
Spacer() Spacer()
Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? L10n.Tasks.completion : L10n.Tasks.completions)") 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) .foregroundColor(Color.appTextSecondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Capsule()
.fill(Color.appTextSecondary.opacity(0.1))
)
} }
.padding() .padding(OrganicSpacing.cozy)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.md) .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.subtle)
// Completions list // Completions list
ForEach(viewModel.completions, id: \.id) { completion in ForEach(viewModel.completions, id: \.id) { completion in
CompletionHistoryCard(completion: completion) CompletionHistoryCard(completion: completion)
} }
} }
.padding() .padding(OrganicSpacing.cozy)
} }
} }
} }
@@ -134,20 +179,20 @@ struct CompletionHistoryCard: View {
@State private var showPhotoSheet = false @State private var showPhotoSheet = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: AppSpacing.sm) { VStack(alignment: .leading, spacing: 14) {
// Header with date and completed by // Header with date and completed by
HStack { HStack {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(DateUtils.formatDateTimeWithTime(completion.completionDate)) Text(DateUtils.formatDateTimeWithTime(completion.completionDate))
.font(.headline) .font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
if let completedBy = completion.completedByName, !completedBy.isEmpty { if let completedBy = completion.completedByName, !completedBy.isEmpty {
HStack(spacing: 4) { HStack(spacing: 5) {
Image(systemName: "person.fill") Image(systemName: "person.fill")
.font(.caption2) .font(.system(size: 10, weight: .medium))
Text("\(L10n.Tasks.completedByName) \(completedBy)") Text("\(L10n.Tasks.completedByName) \(completedBy)")
.font(.caption) .font(.system(size: 12, weight: .medium))
} }
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
@@ -157,39 +202,48 @@ struct CompletionHistoryCard: View {
// Rating badge // Rating badge
if let rating = completion.rating { if let rating = completion.rating {
HStack(spacing: 2) { HStack(spacing: 4) {
Image(systemName: "star.fill") Image(systemName: "star.fill")
.font(.caption) .font(.system(size: 11, weight: .bold))
Text("\(rating)") Text("\(rating)")
.font(.subheadline) .font(.system(size: 13, weight: .bold, design: .rounded))
.fontWeight(.bold)
} }
.foregroundColor(Color.appAccent) .foregroundColor(Color.appAccent)
.padding(.horizontal, 10) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color.appAccent.opacity(0.1)) .background(
.cornerRadius(AppRadius.sm) Capsule()
.fill(Color.appAccent.opacity(0.12))
.overlay(
Capsule()
.stroke(Color.appAccent.opacity(0.2), lineWidth: 1)
)
)
} }
} }
Divider() OrganicDivider()
// Contractor info // Contractor info
if let contractor = completion.contractorDetails { if let contractor = completion.contractorDetails {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
Image(systemName: "wrench.and.screwdriver.fill") ZStack {
.foregroundColor(Color.appPrimary) Circle()
.frame(width: 24) .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) { VStack(alignment: .leading, spacing: 2) {
Text(contractor.name) Text(contractor.name)
.font(.subheadline) .font(.system(size: 14, weight: .semibold, design: .rounded))
.fontWeight(.medium)
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
if let company = contractor.company { if let company = contractor.company {
Text(company) Text(company)
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
} }
@@ -198,28 +252,33 @@ struct CompletionHistoryCard: View {
// Cost // Cost
if let cost = completion.actualCost { if let cost = completion.actualCost {
HStack(spacing: AppSpacing.sm) { HStack(spacing: 12) {
Image(systemName: "dollarsign.circle.fill") ZStack {
.foregroundColor(Color.appPrimary) Circle()
.frame(width: 24) .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)") Text("$\(cost)")
.font(.subheadline) .font(.system(size: 15, weight: .bold, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
} }
} }
// Notes // Notes
if !completion.notes.isEmpty { if !completion.notes.isEmpty {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 6) {
Text(L10n.Tasks.notes) Text(L10n.Tasks.notes)
.font(.caption) .font(.system(size: 11, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
.textCase(.uppercase)
.tracking(0.5)
Text(completion.notes) Text(completion.notes)
.font(.subheadline) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextPrimary) .foregroundColor(Color.appTextPrimary)
} }
.padding(.top, 4) .padding(.top, 4)
@@ -230,26 +289,27 @@ struct CompletionHistoryCard: View {
Button(action: { Button(action: {
showPhotoSheet = true showPhotoSheet = true
}) { }) {
HStack { HStack(spacing: 8) {
Image(systemName: "photo.on.rectangle.angled") Image(systemName: "photo.on.rectangle.angled")
.font(.subheadline) .font(.system(size: 14, weight: .semibold))
Text("\(L10n.Tasks.viewPhotos) (\(completion.images.count))") Text("\(L10n.Tasks.viewPhotos) (\(completion.images.count))")
.font(.subheadline) .font(.system(size: 14, weight: .semibold, design: .rounded))
.fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, 12) .padding(.vertical, 14)
.background(Color.appPrimary.opacity(0.1)) .background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(Color.appPrimary.opacity(0.12))
)
.foregroundColor(Color.appPrimary) .foregroundColor(Color.appPrimary)
.cornerRadius(AppRadius.sm)
} }
.padding(.top, 4) .padding(.top, 6)
} }
} }
.padding() .padding(OrganicSpacing.cozy)
.background(Color.appBackgroundSecondary) .background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg) .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) .naturalShadow(.medium)
.sheet(isPresented: $showPhotoSheet) { .sheet(isPresented: $showPhotoSheet) {
PhotoViewerSheet(images: completion.images) PhotoViewerSheet(images: completion.images)
} }

View File

@@ -98,6 +98,9 @@ struct TaskFormView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
ZStack { ZStack {
WarmGradientBackground()
.ignoresSafeArea()
Form { Form {
// Residence Picker (only if needed) // Residence Picker (only if needed)
if needsResidenceSelection, let residences = residences { if needsResidenceSelection, let residences = residences {
@@ -130,31 +133,40 @@ struct TaskFormView: View {
Button { Button {
showingTemplatesBrowser = true showingTemplatesBrowser = true
} label: { } label: {
HStack { HStack(spacing: 14) {
Image(systemName: "list.bullet.rectangle") ZStack {
.font(.system(size: 18)) Circle()
.foregroundColor(Color.appPrimary) .fill(Color.appPrimary.opacity(0.12))
.frame(width: 28) .frame(width: 40, height: 40)
Image(systemName: "list.bullet.rectangle")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Text("Browse Task Templates") VStack(alignment: .leading, spacing: 2) {
.foregroundColor(Color.appTextPrimary) 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() Spacer()
Text("\(dataManager.taskTemplateCount) tasks")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
Image(systemName: "chevron.right") Image(systemName: "chevron.right")
.font(.caption) .font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.padding(.vertical, 4)
} }
} header: { } header: {
Text("Quick Start") Text("Quick Start")
.font(.system(size: 13, weight: .semibold, design: .rounded))
} footer: { } footer: {
Text("Choose from common home maintenance tasks or create your own below") Text("Choose from common home maintenance tasks or create your own below")
.font(.caption) .font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary) .foregroundColor(Color.appTextSecondary)
} }
.listRowBackground(Color.appBackgroundSecondary) .listRowBackground(Color.appBackgroundSecondary)
@@ -291,19 +303,36 @@ struct TaskFormView: View {
.blur(radius: isLoadingLookups ? 3 : 0) .blur(radius: isLoadingLookups ? 3 : 0)
if isLoadingLookups { if isLoadingLookups {
VStack(spacing: 16) { VStack(spacing: OrganicSpacing.comfortable) {
ProgressView() ZStack {
.scaleEffect(1.5) Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 64, height: 64)
ProgressView()
.scaleEffect(1.2)
.tint(Color.appPrimary)
}
Text(L10n.Tasks.loading) Text(L10n.Tasks.loading)
.font(.system(size: 15, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary) .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) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.appBackgroundPrimary.opacity(0.8)) .background(Color.appBackgroundPrimary.opacity(0.9))
} }
} }
.listStyle(.plain) .listStyle(.plain)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary) .background(Color.clear)
.navigationTitle(isEditMode ? L10n.Tasks.editTitle : L10n.Tasks.addTitle) .navigationTitle(isEditMode ? L10n.Tasks.editTitle : L10n.Tasks.addTitle)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {