From 3598a8d57f74a6d764c6b33dac149f9552df633c Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 16 Dec 2025 20:15:32 -0600 Subject: [PATCH] Add Warm Organic design system to iOS app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- iosApp/iosApp/Design/OrganicDesign.swift | 454 ++++++++++++++++ iosApp/iosApp/Login/LoginView.swift | 200 ++++--- .../Profile/NotificationPreferencesView.swift | 66 ++- iosApp/iosApp/Profile/ProfileView.swift | 116 +++-- .../iosApp/Profile/ThemeSelectionView.swift | 65 ++- .../Residence/ResidenceDetailView.swift | 110 ++-- .../iosApp/Residence/ResidencesListView.swift | 191 +++++-- .../Residence/PropertyHeaderCard.swift | 352 ++++++++++--- .../Subviews/Residence/ResidenceCard.swift | 492 ++++++++++++++---- .../Subviews/Residence/SummaryCard.swift | 302 +++++++++-- .../iosApp/Subviews/Task/PriorityBadge.swift | 34 +- iosApp/iosApp/Subviews/Task/StatusBadge.swift | 42 +- iosApp/iosApp/Subviews/Task/TaskCard.swift | 308 ++++++----- .../iosApp/Task/CompletionHistorySheet.swift | 224 +++++--- iosApp/iosApp/Task/TaskFormView.swift | 65 ++- 15 files changed, 2318 insertions(+), 703 deletions(-) create mode 100644 iosApp/iosApp/Design/OrganicDesign.swift diff --git a/iosApp/iosApp/Design/OrganicDesign.swift b/iosApp/iosApp/Design/OrganicDesign.swift new file mode 100644 index 0000000..7c52d5a --- /dev/null +++ b/iosApp/iosApp/Design/OrganicDesign.swift @@ -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.. 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()) +} diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index d0420fa..237f8f0 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -40,49 +40,70 @@ struct LoginView: View { var body: some View { NavigationView { ZStack { - // Background gradient - Color.appBackgroundPrimary - .ignoresSafeArea() + // Warm organic background + WarmGradientBackground() - ScrollView { - VStack(spacing: AppSpacing.xl) { + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.spacious) { Spacer() - .frame(height: AppSpacing.xxxl) + .frame(height: OrganicSpacing.airy) // Hero Section - VStack(spacing: AppSpacing.lg) { - // App Icon with gradient + VStack(spacing: OrganicSpacing.comfortable) { + // App Icon with organic glow ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 60 + ) + ) + .frame(width: 120, height: 120) + Image("icon") - .font(.system(size: 50, weight: .semibold)) - .foregroundStyle(.white) + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) } - VStack(spacing: AppSpacing.xs) { + VStack(spacing: 8) { Text(L10n.Auth.welcomeBack) - .font(.title2.weight(.bold)) + .font(.system(size: 26, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text(L10n.Auth.signInSubtitle) - .font(.body) + .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) } } // Login Card - VStack(spacing: AppSpacing.lg) { + VStack(spacing: 20) { // Username Field - VStack(alignment: .leading, spacing: AppSpacing.xs) { + VStack(alignment: .leading, spacing: 8) { Text(L10n.Auth.loginUsernameLabel) - .font(.subheadline.weight(.medium)) + .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(Color.appTextSecondary) - HStack(spacing: AppSpacing.sm) { - Image(systemName: "envelope.fill") - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + Image(systemName: "envelope.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appPrimary) + } TextField(L10n.Auth.enterEmail, text: $viewModel.username) + .font(.system(size: 16, weight: .medium)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .keyboardType(.emailAddress) @@ -97,31 +118,36 @@ struct LoginView: View { } .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.usernameField) } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) + .padding(16) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(focusedField == .username ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(focusedField == .username ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5) ) - .shadow(color: focusedField == .username ? Color.appPrimary.opacity(0.1) : .clear, radius: 8) .animation(.easeInOut(duration: 0.2), value: focusedField) } // Password Field - VStack(alignment: .leading, spacing: AppSpacing.xs) { + VStack(alignment: .leading, spacing: 8) { Text(L10n.Auth.loginPasswordLabel) - .font(.subheadline.weight(.medium)) + .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(Color.appTextSecondary) - HStack(spacing: AppSpacing.sm) { - Image(systemName: "lock.fill") - .foregroundColor(Color.appTextSecondary) - .frame(width: 20) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + Image(systemName: "lock.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appPrimary) + } Group { if isPasswordVisible { TextField(L10n.Auth.enterPassword, text: $viewModel.password) + .font(.system(size: 16, weight: .medium)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .textContentType(.password) @@ -133,6 +159,7 @@ struct LoginView: View { .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordField) } else { SecureField(L10n.Auth.enterPassword, text: $viewModel.password) + .font(.system(size: 16, weight: .medium)) .textContentType(.password) .focused($focusedField, equals: .password) .submitLabel(.go) @@ -147,19 +174,18 @@ struct LoginView: View { isPasswordVisible.toggle() }) { Image(systemName: isPasswordVisible ? "eye.slash.fill" : "eye.fill") + .font(.system(size: 16, weight: .medium)) .foregroundColor(Color.appTextSecondary) - .frame(width: 20) } .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.passwordVisibilityToggle) } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) + .padding(16) + .background(Color.appBackgroundPrimary.opacity(0.5)) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( - RoundedRectangle(cornerRadius: AppRadius.md) - .stroke(focusedField == .password ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 1.5) + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(focusedField == .password ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5) ) - .shadow(color: focusedField == .password ? Color.appPrimary.opacity(0.1) : .clear, radius: 8) .animation(.easeInOut(duration: 0.2), value: focusedField) .onChange(of: viewModel.password) { _, _ in viewModel.clearError() @@ -172,24 +198,24 @@ struct LoginView: View { Button(L10n.Auth.forgotPassword) { showPasswordReset = true } - .font(.subheadline.weight(.medium)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundColor(Color.appPrimary) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.forgotPasswordButton) } // Error Message if let errorMessage = viewModel.errorMessage { - HStack(spacing: AppSpacing.sm) { + HStack(spacing: 10) { Image(systemName: "exclamationmark.circle.fill") .foregroundColor(Color.appError) Text(errorMessage) - .font(.callout) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appError) Spacer() } - .padding(AppSpacing.md) + .padding(16) .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } // Login Button @@ -200,19 +226,14 @@ struct LoginView: View { .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton) // Divider - HStack { - Rectangle() - .fill(Color.appTextSecondary.opacity(0.3)) - .frame(height: 1) + HStack(spacing: 12) { + OrganicDivider() Text(L10n.Auth.orDivider) - .font(.subheadline) + .font(.system(size: 13, weight: .medium)) .foregroundColor(Color.appTextSecondary) - .padding(.horizontal, AppSpacing.sm) - Rectangle() - .fill(Color.appTextSecondary.opacity(0.3)) - .frame(height: 1) + OrganicDivider() } - .padding(.vertical, AppSpacing.xs) + .padding(.vertical, 8) // Sign in with Apple Button SignInWithAppleButton( @@ -221,8 +242,8 @@ struct LoginView: View { }, onCompletion: { _ in } ) - .frame(height: 56) - .cornerRadius(AppRadius.md) + .frame(height: 54) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .signInWithAppleButtonStyle(.black) .disabled(appleSignInViewModel.isLoading) .opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0) @@ -238,51 +259,52 @@ struct LoginView: View { // Apple Sign In loading indicator if appleSignInViewModel.isLoading { - HStack { + HStack(spacing: 8) { ProgressView() .progressViewStyle(CircularProgressViewStyle()) + .tint(Color.appPrimary) Text(L10n.Auth.signingInWithApple) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) } - .padding(.top, AppSpacing.xs) + .padding(.top, 8) } // Apple Sign In Error if let appleError = appleSignInViewModel.errorMessage { - HStack(spacing: AppSpacing.sm) { + HStack(spacing: 10) { Image(systemName: "exclamationmark.circle.fill") .foregroundColor(Color.appError) Text(appleError) - .font(.callout) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appError) Spacer() } - .padding(AppSpacing.md) + .padding(16) .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.md) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } // Sign Up Link - HStack(spacing: AppSpacing.xs) { + HStack(spacing: 6) { Text(L10n.Auth.dontHaveAccount) - .font(.body) + .font(.system(size: 15, weight: .medium)) .foregroundColor(Color.appTextSecondary) Button(L10n.Auth.signUp) { showingRegister = true } - .font(.body) - .fontWeight(.semibold) + .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundColor(Color.appPrimary) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.signUpButton) } + .padding(.top, 8) } - .padding(AppSpacing.xl) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.xxl) - .shadow(color: .black.opacity(0.08), radius: 20, y: 10) - .padding(.horizontal, AppSpacing.lg) + .padding(OrganicSpacing.cozy) + .background(LoginCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + .padding(.horizontal, 16) Spacer() } @@ -387,6 +409,40 @@ struct LoginView: View { } } +// MARK: - Login Card Background + +private struct LoginCardBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + Color.appBackgroundSecondary + + // Organic blob accent + GeometryReader { geo in + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05), + Color.appPrimary.opacity(0.01) + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.5 + ) + ) + .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.5) + .offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.3) + .blur(radius: 20) + } + + // Grain texture for natural feel + GrainTexture(opacity: 0.015) + } + } +} + // MARK: - Preview #Preview { LoginView() diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift index 698a0d1..68975d7 100644 --- a/iosApp/iosApp/Profile/NotificationPreferencesView.swift +++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift @@ -7,28 +7,48 @@ struct NotificationPreferencesView: View { var body: some View { NavigationStack { - Form { - // Header Section - Section { - VStack(spacing: 16) { - Image(systemName: "bell.badge.fill") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) + ZStack { + WarmGradientBackground() + .ignoresSafeArea() - Text(L10n.Profile.notificationPreferences) - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) + Form { + // Header Section + Section { + VStack(spacing: OrganicSpacing.cozy) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 50 + ) + ) + .frame(width: 100, height: 100) - Text(L10n.Profile.notificationPreferencesSubtitle) - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .multilineTextAlignment(.center) + Image(systemName: "bell.badge.fill") + .font(.system(size: 48)) + .foregroundStyle(Color.appPrimary.gradient) + } + + Text(L10n.Profile.notificationPreferences) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text(L10n.Profile.notificationPreferencesSubtitle) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical) } - .frame(maxWidth: .infinity) - .padding(.vertical) - } - .listRowBackground(Color.clear) + .listRowBackground(Color.clear) if viewModel.isLoading { Section { @@ -282,9 +302,10 @@ struct NotificationPreferencesView: View { .listRowBackground(Color.appBackgroundSecondary) } } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.clear) + } .navigationTitle(L10n.Profile.notifications) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -292,6 +313,7 @@ struct NotificationPreferencesView: View { Button(L10n.Common.done) { dismiss() } + .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundColor(Color.appPrimary) } } diff --git a/iosApp/iosApp/Profile/ProfileView.swift b/iosApp/iosApp/Profile/ProfileView.swift index 325e3c3..c1fa682 100644 --- a/iosApp/iosApp/Profile/ProfileView.swift +++ b/iosApp/iosApp/Profile/ProfileView.swift @@ -11,31 +11,57 @@ struct ProfileView: View { var body: some View { NavigationView { - if viewModel.isLoadingUser { - VStack { - ProgressView() - Text(L10n.Profile.loadingProfile) - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) - .padding(.top, 8) - } - } else { - Form { - Section { - VStack(spacing: 16) { - Image(systemName: "person.circle.fill") - .font(.system(size: 60)) - .foregroundStyle(Color.appPrimary.gradient) + ZStack { + WarmGradientBackground() + .ignoresSafeArea() - Text(L10n.Profile.profileSettings) - .font(.title2) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) + if viewModel.isLoadingUser { + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 64, height: 64) + ProgressView() + .scaleEffect(1.2) + .tint(Color.appPrimary) } - .frame(maxWidth: .infinity) - .padding(.vertical) + Text(L10n.Profile.loadingProfile) + .font(.system(size: 15, weight: .medium, design: .rounded)) + .foregroundColor(Color.appTextSecondary) } - .listRowBackground(Color.clear) + } else { + Form { + Section { + VStack(spacing: OrganicSpacing.cozy) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 50 + ) + ) + .frame(width: 100, height: 100) + + Image(systemName: "person.circle.fill") + .font(.system(size: 56)) + .foregroundStyle(Color.appPrimary.gradient) + } + + Text(L10n.Profile.profileSettings) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + } + .frame(maxWidth: .infinity) + .padding(.vertical) + } + .listRowBackground(Color.clear) Section { TextField(L10n.Profile.firstName, text: $viewModel.firstName) @@ -122,30 +148,32 @@ struct ProfileView: View { } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) - .navigationTitle(L10n.Profile.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button(L10n.Common.cancel) { - dismiss() - } - } + .background(Color.clear) } - .onChange(of: viewModel.firstName) { _, _ in - viewModel.clearMessages() - } - .onChange(of: viewModel.lastName) { _, _ in - viewModel.clearMessages() - } - .onChange(of: viewModel.email) { _, _ in - viewModel.clearMessages() - } - .handleErrors( - error: viewModel.errorMessage, - onRetry: { viewModel.updateProfile() } - ) } + .navigationTitle(L10n.Profile.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(L10n.Common.cancel) { + dismiss() + } + .font(.system(size: 16, weight: .semibold, design: .rounded)) + } + } + .onChange(of: viewModel.firstName) { _, _ in + viewModel.clearMessages() + } + .onChange(of: viewModel.lastName) { _, _ in + viewModel.clearMessages() + } + .onChange(of: viewModel.email) { _, _ in + viewModel.clearMessages() + } + .handleErrors( + error: viewModel.errorMessage, + onRetry: { viewModel.updateProfile() } + ) } } } diff --git a/iosApp/iosApp/Profile/ThemeSelectionView.swift b/iosApp/iosApp/Profile/ThemeSelectionView.swift index 40299c1..ded7c58 100644 --- a/iosApp/iosApp/Profile/ThemeSelectionView.swift +++ b/iosApp/iosApp/Profile/ThemeSelectionView.swift @@ -6,22 +6,27 @@ struct ThemeSelectionView: View { var body: some View { NavigationStack { - List { - ForEach(ThemeID.allCases, id: \.self) { theme in - Button(action: { - selectTheme(theme) - }) { - ThemeRow( - theme: theme, - isSelected: themeManager.currentTheme == theme - ) + ZStack { + WarmGradientBackground() + .ignoresSafeArea() + + List { + ForEach(ThemeID.allCases, id: \.self) { theme in + Button(action: { + selectTheme(theme) + }) { + ThemeRow( + theme: theme, + isSelected: themeManager.currentTheme == theme + ) + } + .listRowBackground(Color.appBackgroundSecondary) } - .listRowBackground(Color.appBackgroundSecondary) } + .listStyle(.plain) + .scrollContentBackground(.hidden) + .background(Color.clear) } - .listStyle(.plain) - .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) .navigationTitle(L10n.Profile.appearance) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -29,6 +34,7 @@ struct ThemeSelectionView: View { Button(L10n.Common.done) { dismiss() } + .font(.system(size: 16, weight: .semibold, design: .rounded)) } } } @@ -53,27 +59,31 @@ struct ThemeRow: View { let isSelected: Bool var body: some View { - HStack(spacing: AppSpacing.md) { + HStack(spacing: 14) { // Theme preview circles - HStack(spacing: 4) { + HStack(spacing: 5) { ForEach(0..<3, id: \.self) { index in Circle() .fill(theme.previewColors[index]) - .frame(width: 24, height: 24) + .frame(width: 26, height: 26) + .overlay( + Circle() + .stroke(Color.appTextSecondary.opacity(0.1), lineWidth: 1) + ) } } - .padding(AppSpacing.xs) + .padding(8) .background(Color.appBackgroundPrimary.opacity(0.5)) - .clipShape(RoundedRectangle(cornerRadius: AppRadius.md)) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) // Theme info - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { Text(theme.displayName) - .font(.headline) + .font(.system(size: 16, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text(theme.description) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) } @@ -81,12 +91,17 @@ struct ThemeRow: View { // Checkmark for selected theme if isSelected { - Image(systemName: "checkmark.circle.fill") - .font(.title3) - .foregroundColor(Color.appPrimary) + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.12)) + .frame(width: 32, height: 32) + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appPrimary) + } } } - .padding(.vertical, AppSpacing.xs) + .padding(.vertical, 6) .contentShape(Rectangle()) } } diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 5336c1d..a0155ac 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -47,8 +47,7 @@ struct ResidenceDetailView: View { var body: some View { ZStack { - Color.appBackgroundPrimary - .ignoresSafeArea() + WarmGradientBackground() mainContent } @@ -208,19 +207,19 @@ private extension ResidenceDetailView { @ViewBuilder func contentView(for residence: ResidenceResponse) -> some View { - ScrollView { - VStack(spacing: 16) { + ScrollView(showsIndicators: false) { + VStack(spacing: OrganicSpacing.comfortable) { PropertyHeaderCard(residence: residence) - .padding(.horizontal) - .padding(.top) + .padding(.horizontal, 16) + .padding(.top, 8) tasksSection - .padding(.horizontal) + .padding(.horizontal, 16) contractorsSection - .padding(.horizontal) + .padding(.horizontal, 16) } - .padding(.bottom) + .padding(.bottom, OrganicSpacing.airy) } } @@ -248,49 +247,81 @@ private extension ResidenceDetailView { @ViewBuilder var contractorsSection: some View { - VStack(alignment: .leading, spacing: AppSpacing.md) { + VStack(alignment: .leading, spacing: 16) { // Section Header - HStack(spacing: AppSpacing.sm) { - Image(systemName: "person.2.fill") - .font(.title2) - .foregroundColor(Color.appPrimary) + HStack(alignment: .center, spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.12)) + .frame(width: 40, height: 40) + + Image(systemName: "person.2.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + Text(L10n.Residences.contractors) - .font(.title2.weight(.bold)) - .foregroundColor(Color.appPrimary) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Spacer() } - .padding(.top, AppSpacing.sm) + .padding(.top, 8) if isLoadingContractors { HStack { Spacer() ProgressView() + .tint(Color.appPrimary) Spacer() } - .padding() + .padding(OrganicSpacing.cozy) } else if let error = contractorsError { Text("\(L10n.Common.error): \(error)") .foregroundColor(Color.appError) .padding() } else if contractors.isEmpty { - // Empty state - VStack(spacing: AppSpacing.md) { - Image(systemName: "person.crop.circle.badge.plus") - .font(.system(size: 48)) - .foregroundColor(Color.appTextSecondary.opacity(0.6)) - Text(L10n.Residences.noContractors) - .font(.headline) - .foregroundColor(Color.appTextPrimary) - Text(L10n.Residences.addContractorsPrompt) - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) + // Empty state with organic styling + VStack(spacing: 16) { + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.12), + Color.appPrimary.opacity(0.04) + ], + center: .center, + startRadius: 0, + endRadius: 50 + ) + ) + .frame(width: 80, height: 80) + + Image(systemName: "person.crop.circle.badge.plus") + .font(.system(size: 32, weight: .medium)) + .foregroundColor(Color.appPrimary.opacity(0.6)) + } + + VStack(spacing: 8) { + Text(L10n.Residences.noContractors) + .font(.system(size: 17, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text(L10n.Residences.addContractorsPrompt) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + } } .frame(maxWidth: .infinity) - .padding(AppSpacing.xl) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) + .padding(OrganicSpacing.spacious) + .background(OrganicCardBackground(showBlob: true, blobVariation: 1)) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .naturalShadow(.subtle) } else { // Contractors list - VStack(spacing: AppSpacing.sm) { + VStack(spacing: 12) { ForEach(contractors, id: \.id) { contractor in NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) { ContractorCard( @@ -300,7 +331,7 @@ private extension ResidenceDetailView { } ) } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(OrganicCardButtonStyle()) } } } @@ -308,6 +339,17 @@ private extension ResidenceDetailView { } } +// MARK: - Organic Card Button Style + +private struct OrganicCardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .opacity(configuration.isPressed ? 0.9 : 1.0) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } +} + // MARK: - Toolbars private extension ResidenceDetailView { diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 7a97c85..f681a1e 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -14,8 +14,8 @@ struct ResidencesListView: View { var body: some View { ZStack { - Color.appBackgroundPrimary - .ignoresSafeArea() + // Warm organic background + WarmGradientBackground() if let response = viewModel.myResidences { ListAsyncContentView( @@ -29,7 +29,7 @@ struct ResidencesListView: View { ) }, emptyContent: { - EmptyResidencesView() + OrganicEmptyResidencesView() }, onRefresh: { viewModel.loadMyResidences(forceRefresh: true) @@ -53,9 +53,7 @@ struct ResidencesListView: View { Button(action: { showingSettings = true }) { - Image(systemName: "gearshape.fill") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(Color.appPrimary) + OrganicToolbarButton(systemName: "gearshape.fill") } .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton) } @@ -70,9 +68,7 @@ struct ResidencesListView: View { showingJoinResidence = true } }) { - Image(systemName: "person.badge.plus") - .font(.system(size: 18, weight: .semibold)) - .foregroundColor(Color.appPrimary) + OrganicToolbarButton(systemName: "person.badge.plus") } Button(action: { @@ -84,9 +80,7 @@ struct ResidencesListView: View { showingAddResidence = true } }) { - Image(systemName: "plus.circle.fill") - .font(.system(size: 22, weight: .semibold)) - .foregroundColor(Color.appPrimary) + OrganicToolbarButton(systemName: "plus", isPrimary: true) } .accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton) } @@ -148,6 +142,35 @@ struct ResidencesListView: View { } } +// MARK: - Organic Toolbar Button + +private struct OrganicToolbarButton: View { + let systemName: String + var isPrimary: Bool = false + + var body: some View { + ZStack { + if isPrimary { + Circle() + .fill(Color.appPrimary) + .frame(width: 32, height: 32) + + Image(systemName: systemName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appTextOnPrimary) + } else { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + + Image(systemName: systemName) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + } + } +} + // MARK: - Residences Content View private struct ResidencesContent: View { @@ -156,36 +179,51 @@ private struct ResidencesContent: View { var body: some View { ScrollView(showsIndicators: false) { - VStack(spacing: AppSpacing.lg) { - // Summary Card + VStack(spacing: OrganicSpacing.comfortable) { + // Summary Card with enhanced styling SummaryCard(summary: summary) - .padding(.horizontal, AppSpacing.md) - .padding(.top, AppSpacing.sm) + .padding(.horizontal, 16) + .padding(.top, 8) - // Properties Header - HStack { - VStack(alignment: .leading, spacing: AppSpacing.xxs) { + // Properties Section Header + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { Text(L10n.Residences.yourProperties) - .font(.title3.weight(.semibold)) + .font(.system(size: 20, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) + Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)") - .font(.callout) + .font(.system(size: 13, weight: .medium, design: .rounded)) .foregroundColor(Color.appTextSecondary) } - Spacer() - } - .padding(.horizontal, AppSpacing.md) - // Residences List - ForEach(residences, id: \.id) { residence in - NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) { - ResidenceCard(residence: residence) - .padding(.horizontal, AppSpacing.md) + Spacer() + + // Decorative leaf + Image(systemName: "leaf.fill") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(Color.appPrimary.opacity(0.3)) + .rotationEffect(.degrees(-15)) + } + .padding(.horizontal, 20) + .padding(.top, 8) + + // Residences List with staggered animation + LazyVStack(spacing: 16) { + ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in + NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) { + ResidenceCard(residence: residence) + .padding(.horizontal, 16) + } + .buttonStyle(OrganicCardButtonStyle()) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .bottom)), + removal: .opacity + )) } - .buttonStyle(PlainButtonStyle()) } } - .padding(.bottom, AppSpacing.xxxl) + .padding(.bottom, OrganicSpacing.airy) } .safeAreaInset(edge: .bottom) { Color.clear.frame(height: 0) @@ -193,6 +231,97 @@ private struct ResidencesContent: View { } } +// MARK: - Organic Card Button Style + +private struct OrganicCardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .opacity(configuration.isPressed ? 0.9 : 1.0) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } +} + +// MARK: - Organic Empty Residences View + +private struct OrganicEmptyResidencesView: View { + @State private var isAnimating = false + + var body: some View { + VStack(spacing: OrganicSpacing.comfortable) { + Spacer() + + // Animated house illustration + ZStack { + // Background glow + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05), + Color.clear + ], + center: .center, + startRadius: 0, + endRadius: 80 + ) + ) + .frame(width: 160, height: 160) + .scaleEffect(isAnimating ? 1.1 : 1.0) + .animation( + Animation.easeInOut(duration: 3).repeatForever(autoreverses: true), + value: isAnimating + ) + + // House icon + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 100, height: 100) + + Image(systemName: "house.lodge.fill") + .font(.system(size: 44, weight: .medium)) + .foregroundColor(Color.appPrimary) + .offset(y: isAnimating ? -2 : 2) + .animation( + Animation.easeInOut(duration: 2).repeatForever(autoreverses: true), + value: isAnimating + ) + } + } + + VStack(spacing: 12) { + Text("Welcome to Your Space") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text("Add your first property to start\nmanaging your home with ease") + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) + } + .padding(.top, 8) + + Spacer() + + // Decorative footer elements + HStack(spacing: 40) { + FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary) + FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent) + FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary) + } + .opacity(0.6) + .padding(.bottom, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + isAnimating = true + } + } +} + #Preview { NavigationView { ResidencesListView() diff --git a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift index 7618dc8..e5bdd31 100644 --- a/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift +++ b/iosApp/iosApp/Subviews/Residence/PropertyHeaderCard.swift @@ -3,108 +3,310 @@ import ComposeApp struct PropertyHeaderCard: View { let residence: ResidenceResponse + @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(alignment: .leading, spacing: 16) { - HStack { - VStack { - Image("house_outline") - .resizable() - .frame(width: 38, height: 38) - .foregroundColor(Color.appTextOnPrimary) - .background(content: { - RoundedRectangle(cornerRadius: AppRadius.sm) - .fill(LinearGradient(colors: [Color.appPrimary, Color.appPrimary.opacity(0.8)], startPoint: .topLeading, endPoint: .bottomTrailing)) - .frame(width: 38, height: 38) - .shadow(color: Color.appPrimary.opacity(0.3), radius: 6, y: 3) - }) - - Spacer() - } + VStack(alignment: .leading, spacing: 0) { + // Header Section + HStack(alignment: .top, spacing: 16) { + // Property Icon + PropertyDetailIcon() - VStack(alignment: .leading, spacing: 4) { + // Property Info + VStack(alignment: .leading, spacing: 6) { Text(residence.name) - .font(.title2) - .fontWeight(.bold) + .font(.system(size: 24, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) if let propertyTypeName = residence.propertyTypeName { - Text(propertyTypeName) - .font(.caption) + Text(propertyTypeName.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextSecondary) + .tracking(1.5) } } Spacer() + + // Primary badge if applicable + if residence.isPrimary { + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 11, weight: .bold)) + + Text("Primary") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundColor(Color.appAccent) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.appAccent.opacity(0.12)) + ) + } } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.top, OrganicSpacing.cozy) + .padding(.bottom, 20) - Divider() + // Divider + OrganicDivider() + .padding(.horizontal, 20) - VStack(alignment: .leading, spacing: 4) { + // Address Section + VStack(alignment: .leading, spacing: 10) { if !residence.streetAddress.isEmpty { - Label(residence.streetAddress, systemImage: "mappin.circle.fill") - .font(.subheadline) - .foregroundColor(Color.appTextPrimary) + HStack(spacing: 10) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + + Image(systemName: "mappin.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + + Text(residence.streetAddress) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(Color.appTextPrimary) + } } if !residence.city.isEmpty || !residence.stateProvince.isEmpty || !residence.postalCode.isEmpty { - Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)") - .font(.subheadline) - .foregroundColor(Color.appTextSecondary) + HStack(spacing: 10) { + Color.clear.frame(width: 32, height: 1) // Alignment spacer + + Text("\(residence.city), \(residence.stateProvince) \(residence.postalCode)") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } } if !residence.country.isEmpty { - Text(residence.country) - .font(.caption) - .foregroundColor(Color.appTextSecondary) - } - } + HStack(spacing: 10) { + Color.clear.frame(width: 32, height: 1) // Alignment spacer - if let bedrooms = residence.bedrooms, - let bathrooms = residence.bathrooms { - Divider() - - HStack(spacing: 24) { - PropertyDetailItem(icon: "bed.double.fill", value: "\(bedrooms.intValue)", label: "Beds") - PropertyDetailItem(icon: "shower.fill", value: String(format: "%.1f", bathrooms.doubleValue), label: "Baths") - - if let sqft = residence.squareFootage { - PropertyDetailItem(icon: "square.fill", value: "\(sqft.intValue)", label: "Sq Ft") + Text(residence.country) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary.opacity(0.8)) } } } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.vertical, 16) + + // Property Details (if available) + if let bedrooms = residence.bedrooms, let bathrooms = residence.bathrooms { + OrganicDivider() + .padding(.horizontal, 20) + + // Property Features Row + HStack(spacing: 0) { + PropertyFeaturePill( + icon: "bed.double.fill", + value: "\(bedrooms.intValue)", + label: "Beds" + ) + + PropertyFeaturePill( + icon: "shower.fill", + value: String(format: "%.1f", bathrooms.doubleValue), + label: "Baths" + ) + + if let sqft = residence.squareFootage { + PropertyFeaturePill( + icon: "square.dashed", + value: formatNumber(sqft.intValue), + label: "Sq Ft" + ) + } + + if let yearBuilt = residence.yearBuilt { + PropertyFeaturePill( + icon: "calendar", + value: "\(yearBuilt.intValue)", + label: "Built" + ) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + } } - .padding(20) - .background(Color.appBackgroundSecondary) - .cornerRadius(16) - .shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) + .background(PropertyHeaderBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + } + + private func formatNumber(_ num: Int) -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: num)) ?? "\(num)" } } -//#Preview { -// PropertyHeaderCard(residence: Residence( -// id: 1, -// owner: "My Beautiful Home", -// ownerUsername: "House", -// name: "123 Main Street", -// propertyType: nil, -// streetAddress: "San Francisco", -// apartmentUnit: "CA", -// city: "94102", -// stateProvince: "USA", -// postalCode: 3, -// country: 2.5, -// bedrooms: 1800, -// bathrooms: 0.25, -// squareFootage: 2010, -// lotSize: nil, -// yearBuilt: nil, -// description: nil, -// purchaseDate: true, -// purchasePrice: "testuser", -// isPrimary: 1, -// createdAt: "2024-01-01T00:00:00Z", -// updatedAt: "2024-01-01T00:00:00Z" -// )) -// .padding() -//} +// MARK: - Property Detail Icon + +private struct PropertyDetailIcon: View { + var body: some View { + ZStack { + // Outer glow + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05) + ], + center: .center, + startRadius: 0, + endRadius: 32 + ) + ) + .frame(width: 64, height: 64) + + // Inner circle + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary, + Color.appPrimary.opacity(0.9) + ], + center: .topLeading, + startRadius: 0, + endRadius: 48 + ) + ) + .frame(width: 48, height: 48) + + // Highlight + Circle() + .fill( + RadialGradient( + colors: [ + Color.white.opacity(0.3), + Color.clear + ], + center: .topLeading, + startRadius: 0, + endRadius: 24 + ) + ) + .frame(width: 48, height: 48) + + // Icon + Image("house_outline") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(Color.appTextOnPrimary) + } + .naturalShadow(.subtle) + } +} + +// MARK: - Property Feature Pill + +private struct PropertyFeaturePill: View { + let icon: String + let value: String + let label: String + + var body: some View { + VStack(spacing: 6) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color.appPrimary) + + Text(value) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + } + + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Property Header Background + +private struct PropertyHeaderBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + // Base + Color.appBackgroundSecondary + + // Decorative blob + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05), + Color.appPrimary.opacity(0.01) + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.4 + ) + ) + .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.7) + .offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1) + .blur(radius: 25) + } + + // Grain texture + GrainTexture(opacity: 0.012) + } + } +} + +// MARK: - Preview + +#Preview("Property Header Card") { + ScrollView { + VStack(spacing: 24) { + PropertyHeaderCard(residence: ResidenceResponse( + id: 1, + ownerId: 1, + owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"), + users: [], + name: "Sunset Villa", + propertyTypeId: 1, + propertyType: ResidenceType(id: 1, name: "House"), + streetAddress: "742 Evergreen Terrace", + apartmentUnit: "", + city: "San Francisco", + stateProvince: "CA", + postalCode: "94102", + country: "USA", + bedrooms: 4, + bathrooms: 2.5, + squareFootage: 2400, + lotSize: 0.35, + yearBuilt: 2018, + description: "Beautiful modern home with stunning views", + purchaseDate: nil, + purchasePrice: nil, + isPrimary: true, + isActive: true, + overdueCount: 0, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z" + )) + } + .padding(.horizontal, 16) + .padding(.vertical, 24) + } + .background(WarmGradientBackground()) +} diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index 31cee35..3b84348 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -9,136 +9,398 @@ struct ResidenceCard: View { Int(residence.overdueCount) > 0 } + /// Get task summary categories (max 3) + private var displayCategories: [TaskCategorySummary] { + Array(residence.taskSummary.categories.prefix(3)) + } + var body: some View { - VStack(alignment: .leading, spacing: AppSpacing.md) { - // Header with property type icon (pulses when overdue tasks exist) - HStack(spacing: AppSpacing.sm) { - VStack { - if hasOverdueTasks { - PulsingIconView(backgroundColor: Color.appPrimary) - .frame(width: 44, height: 44) - .padding([.trailing], AppSpacing.md) - } else { - CaseraIconView(backgroundColor: Color.appPrimary) - .frame(width: 44, height: 44) - .padding([.trailing], AppSpacing.md) + VStack(alignment: .leading, spacing: 0) { + // Top Section: Icon + Property Info + Primary Badge + HStack(alignment: .top, spacing: 16) { + // Property Icon with organic styling + PropertyIconView(hasOverdue: hasOverdueTasks) + + // Property Details + VStack(alignment: .leading, spacing: 6) { + // Property Name + Text(residence.name) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + .lineLimit(1) + + // Property Type + if let propertyTypeName = residence.propertyTypeName { + Text(propertyTypeName.uppercased()) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + .tracking(1.2) } - Spacer() - } - - VStack(alignment: .leading, spacing: AppSpacing.xxs) { - Text(residence.name) - .font(.title3.weight(.semibold)) - .fontWeight(.bold) - .foregroundColor(Color.appTextPrimary) - - if let propertyTypeName = residence.propertyTypeName { - Text(propertyTypeName) - .font(.caption.weight(.medium)) - .foregroundColor(Color.appTextSecondary) - .textCase(.uppercase) - .tracking(0.5) + // Address + if !residence.streetAddress.isEmpty { + HStack(spacing: 6) { + Image(systemName: "mappin") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(Color.appPrimary.opacity(0.7)) + + Text(residence.streetAddress) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .lineLimit(1) + } + .padding(.top, 2) } } - + Spacer() - + + // Primary Badge if residence.isPrimary { - VStack { - Image(systemName: "star.fill") - .font(.system(size: 14, weight: .bold)) - .foregroundColor(Color.appAccent) - .background(content: { - Circle() - .fill(Color.appAccent.opacity(0.2)) - .frame(width: 32, height: 32) - }) - .padding(AppSpacing.md) - - Spacer() - } + PrimaryBadgeView() } } - - // Address - VStack(alignment: .leading, spacing: AppSpacing.xxs) { - if !residence.streetAddress.isEmpty { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "mappin.circle.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - Text(residence.streetAddress) - .font(.callout) - .foregroundColor(Color.appTextSecondary) - } - } - - if !residence.city.isEmpty || !residence.stateProvince.isEmpty { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "location.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary) - Text("\(residence.city ?? ""), \(residence.stateProvince ?? "")") - .font(.callout) - .foregroundColor(Color.appTextSecondary) - } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.top, OrganicSpacing.cozy) + .padding(.bottom, 16) + + // Location Details (if available) + if !residence.city.isEmpty || !residence.stateProvince.isEmpty { + HStack(spacing: 8) { + Image(systemName: "location.fill") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(Color.appTextSecondary.opacity(0.6)) + + Text("\(residence.city), \(residence.stateProvince)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary.opacity(0.8)) } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.bottom, 16) } - .padding(.vertical, AppSpacing.xs) - - Divider() - - // Fully dynamic task stats from API - show first 3 categories - HStack(spacing: AppSpacing.sm) { - let displayCategories = Array(residence.taskSummary.categories.prefix(3)) - - ForEach(displayCategories, id: \.name) { category in - TaskStatChip( - icon: category.icons.ios, - value: "\(category.count)", - label: category.displayName, - color: Color(hex: category.color) ?? Color.appTextSecondary - ) + + // Divider + OrganicDivider() + .padding(.horizontal, 16) + + // Task Stats Section + if !displayCategories.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(displayCategories, id: \.name) { category in + TaskCategoryChip(category: category) + } + + // Show overdue count if any + if hasOverdueTasks { + OverdueChip(count: Int(residence.overdueCount)) + } + } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.vertical, 16) } + } else { + // Empty state for tasks + HStack(spacing: 8) { + Image(systemName: "checkmark.circle") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color.appPrimary.opacity(0.5)) + + Text("No tasks yet") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.vertical, 16) } } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) - .shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) + .background(CardBackgroundView(hasOverdue: hasOverdueTasks)) + .clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous)) + .naturalShadow(.medium) } } -#Preview { - ResidenceCard(residence: ResidenceResponse( - id: 1, - ownerId: 1, - owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "", lastName: ""), - users: [], - name: "My Home", - propertyTypeId: 1, - propertyType: ResidenceType(id: 1, name: "House"), - streetAddress: "123 Main St", - apartmentUnit: "", - city: "San Francisco", - stateProvince: "CA", - postalCode: "94102", - country: "USA", - bedrooms: 3, - bathrooms: 2.5, - squareFootage: 1800, - lotSize: 0.25, - yearBuilt: 2010, - description: "", - purchaseDate: nil, - purchasePrice: nil, - isPrimary: true, - isActive: true, - overdueCount: 1, - createdAt: "2024-01-01T00:00:00Z", - updatedAt: "2024-01-01T00:00:00Z" - )) - .padding() - .background(Color.appBackgroundPrimary) +// MARK: - Property Icon View + +private struct PropertyIconView: View { + let hasOverdue: Bool + + var body: some View { + ZStack { + // Background circle with gradient + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary, + Color.appPrimary.opacity(0.85) + ], + center: .topLeading, + startRadius: 0, + endRadius: 52 + ) + ) + .frame(width: 52, height: 52) + + // Inner highlight + Circle() + .fill( + RadialGradient( + colors: [ + Color.white.opacity(0.25), + Color.clear + ], + center: .topLeading, + startRadius: 0, + endRadius: 26 + ) + ) + .frame(width: 52, height: 52) + + // House icon + Image("house_outline") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(Color.appTextOnPrimary) + + // Pulse ring for overdue + if hasOverdue { + PulseRing() + } + } + .naturalShadow(.subtle) + } +} + +// MARK: - Pulse Ring Animation + +private struct PulseRing: View { + @State private var isPulsing = false + + var body: some View { + Circle() + .stroke(Color.appError.opacity(0.6), lineWidth: 2) + .frame(width: 60, height: 60) + .scaleEffect(isPulsing ? 1.15 : 1.0) + .opacity(isPulsing ? 0 : 1) + .animation( + Animation + .easeOut(duration: 1.5) + .repeatForever(autoreverses: false), + value: isPulsing + ) + .onAppear { + isPulsing = true + } + } +} + +// MARK: - Primary Badge + +private struct PrimaryBadgeView: View { + var body: some View { + ZStack { + // Soft background + Circle() + .fill(Color.appAccent.opacity(0.15)) + .frame(width: 36, height: 36) + + // Star icon + Image(systemName: "star.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(Color.appAccent) + } + } +} + +// MARK: - Task Category Chip + +private struct TaskCategoryChip: View { + let category: TaskCategorySummary + + private var chipColor: Color { + Color(hex: category.color) ?? Color.appPrimary + } + + var body: some View { + HStack(spacing: 6) { + // Icon background + ZStack { + Circle() + .fill(chipColor.opacity(0.15)) + .frame(width: 26, height: 26) + + Image(systemName: category.icons.ios) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(chipColor) + } + + // Count and label + VStack(alignment: .leading, spacing: 0) { + Text("\(category.count)") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text(category.displayName) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.appBackgroundPrimary.opacity(0.6)) + .overlay( + Capsule() + .stroke(chipColor.opacity(0.2), lineWidth: 1) + ) + ) + } +} + +// MARK: - Overdue Chip + +private struct OverdueChip: View { + let count: Int + + var body: some View { + HStack(spacing: 6) { + ZStack { + Circle() + .fill(Color.appError.opacity(0.15)) + .frame(width: 26, height: 26) + + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(Color.appError) + } + + VStack(alignment: .leading, spacing: 0) { + Text("\(count)") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundColor(Color.appError) + + Text("Overdue") + .font(.system(size: 9, weight: .medium)) + .foregroundColor(Color.appError.opacity(0.8)) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background( + Capsule() + .fill(Color.appError.opacity(0.08)) + .overlay( + Capsule() + .stroke(Color.appError.opacity(0.25), lineWidth: 1) + ) + ) + } +} + +// MARK: - Card Background + +private struct CardBackgroundView: View { + let hasOverdue: Bool + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + // Base fill + Color.appBackgroundSecondary + + // Subtle organic blob accent in corner + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill( + LinearGradient( + colors: [ + Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.04), + Color.appPrimary.opacity(0.01) + ], + startPoint: .topTrailing, + endPoint: .bottomLeading + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.6) + .offset(x: geo.size.width * 0.55, y: -geo.size.height * 0.05) + .blur(radius: 15) + } + + // Subtle grain texture + GrainTexture(opacity: 0.012) + } + } +} + +// MARK: - Preview + +#Preview("Residence Card") { + ScrollView { + VStack(spacing: 20) { + ResidenceCard(residence: ResidenceResponse( + id: 1, + ownerId: 1, + owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"), + users: [], + name: "Sunset Villa", + propertyTypeId: 1, + propertyType: ResidenceType(id: 1, name: "House"), + streetAddress: "742 Evergreen Terrace", + apartmentUnit: "", + city: "San Francisco", + stateProvince: "CA", + postalCode: "94102", + country: "USA", + bedrooms: 4, + bathrooms: 2.5, + squareFootage: 2400, + lotSize: 0.35, + yearBuilt: 2018, + description: "Beautiful modern home", + purchaseDate: nil, + purchasePrice: nil, + isPrimary: true, + isActive: true, + overdueCount: 2, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z" + )) + + ResidenceCard(residence: ResidenceResponse( + id: 2, + ownerId: 1, + owner: ResidenceUserResponse(id: 1, username: "testuser", email: "test@test.com", firstName: "John", lastName: "Doe"), + users: [], + name: "Downtown Loft", + propertyTypeId: 2, + propertyType: ResidenceType(id: 2, name: "Apartment"), + streetAddress: "100 Market Street, Unit 502", + apartmentUnit: "502", + city: "San Francisco", + stateProvince: "CA", + postalCode: "94105", + country: "USA", + bedrooms: 2, + bathrooms: 1.0, + squareFootage: 1100, + lotSize: nil, + yearBuilt: 2020, + description: "", + purchaseDate: nil, + purchasePrice: nil, + isPrimary: false, + isActive: true, + overdueCount: 0, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z" + )) + } + .padding(.horizontal, 16) + .padding(.vertical, 24) + } + .background(WarmGradientBackground()) } diff --git a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift index 1fef596..3fd3b8e 100644 --- a/iosApp/iosApp/Subviews/Residence/SummaryCard.swift +++ b/iosApp/iosApp/Subviews/Residence/SummaryCard.swift @@ -3,67 +3,289 @@ import ComposeApp struct SummaryCard: View { let summary: TotalSummary + @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(spacing: 16) { - HStack { - Text("Overview") - .font(.title3) - .fontWeight(.bold) + VStack(spacing: 0) { + // Header with greeting + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text(greetingText) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundColor(Color.appTextSecondary) + + Text("Your Home Dashboard") + .font(.system(size: 22, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + } + Spacer() - } - HStack(spacing: 20) { - SummaryStatView( - icon: "house_outline", + // Decorative icon + ZStack { + Circle() + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(0.15), + Color.appPrimary.opacity(0.05) + ], + center: .center, + startRadius: 0, + endRadius: 28 + ) + ) + .frame(width: 56, height: 56) + + Image(systemName: "house.lodge.fill") + .font(.system(size: 24, weight: .medium)) + .foregroundColor(Color.appPrimary) + } + } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.top, OrganicSpacing.cozy) + .padding(.bottom, 20) + + // Main Stats Row + HStack(spacing: 0) { + OrganicStatItem( + icon: "house.fill", value: "\(summary.totalResidences)", - label: "Properties" + label: "Properties", + accentColor: Color.appPrimary ) - SummaryStatView( - icon: "list.bullet", + // Vertical divider + Rectangle() + .fill(Color.appTextSecondary.opacity(0.12)) + .frame(width: 1, height: 50) + + OrganicStatItem( + icon: "checklist", value: "\(summary.totalTasks)", - label: "Total Tasks" + label: "Total Tasks", + accentColor: Color.appSecondary ) } + .padding(.horizontal, 12) + .padding(.bottom, 16) - Divider() + // Organic divider + OrganicDivider() + .padding(.horizontal, 24) - HStack(spacing: 20) { - SummaryStatView( - icon: "calendar", + // Timeline Stats + HStack(spacing: 8) { + TimelineStatPill( + icon: "exclamationmark.circle.fill", value: "\(summary.totalOverdue)", - label: "Over Due" - ) - - SummaryStatView( - icon: "calendar", - value: "\(summary.tasksDueNextWeek)", - label: "Due This Week" + label: "Overdue", + color: summary.totalOverdue > 0 ? Color.appError : Color.appTextSecondary, + isAlert: summary.totalOverdue > 0 ) - SummaryStatView( + TimelineStatPill( icon: "calendar.badge.clock", + value: "\(summary.tasksDueNextWeek)", + label: "This Week", + color: Color.appAccent + ) + + TimelineStatPill( + icon: "calendar", value: "\(summary.tasksDueNextMonth)", - label: "Next 30 Days" + label: "30 Days", + color: Color.appPrimary.opacity(0.7) ) } + .padding(.horizontal, OrganicSpacing.cozy) + .padding(.top, 16) + .padding(.bottom, OrganicSpacing.cozy) + } + .background(SummaryCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 32, style: .continuous)) + .naturalShadow(.pronounced) + } + + private var greetingText: String { + let hour = Calendar.current.component(.hour, from: Date()) + switch hour { + case 5..<12: + return "Good morning" + case 12..<17: + return "Good afternoon" + case 17..<21: + return "Good evening" + default: + return "Good night" } - .padding(20) - .background(Color.appBackgroundSecondary) - .cornerRadius(16) - .shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) } } -#Preview { - SummaryCard(summary: TotalSummary( - totalResidences: 3, - totalTasks: 12, - totalPending: 2, - totalOverdue: 1, - tasksDueNextWeek: 4, - tasksDueNextMonth: 8 - )) - .padding() +// MARK: - Organic Stat Item + +private struct OrganicStatItem: View { + let icon: String + let value: String + let label: String + var accentColor: Color = Color.appPrimary + + var body: some View { + VStack(spacing: 8) { + // Icon with soft background + ZStack { + Circle() + .fill(accentColor.opacity(0.12)) + .frame(width: 40, height: 40) + + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(accentColor) + } + + // Value + Text(value) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + // Label + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Timeline Stat Pill + +private struct TimelineStatPill: View { + let icon: String + let value: String + let label: String + var color: Color = Color.appPrimary + var isAlert: Bool = false + + var body: some View { + VStack(spacing: 6) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(color) + + Text(value) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(isAlert ? color : Color.appTextPrimary) + } + + Text(label) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + isAlert + ? color.opacity(0.08) + : Color.appBackgroundPrimary.opacity(0.5) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke( + isAlert ? color.opacity(0.2) : Color.clear, + lineWidth: 1 + ) + ) + ) + } +} + +// MARK: - Summary Card Background + +private struct SummaryCardBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + // Base gradient + LinearGradient( + colors: [ + Color.appBackgroundSecondary, + Color.appBackgroundSecondary.opacity(0.95) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + // Decorative blob in top-right + GeometryReader { geo in + OrganicBlobShape(variation: 0) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(colorScheme == .dark ? 0.08 : 0.05), + Color.appPrimary.opacity(0.01) + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.4 + ) + ) + .frame(width: geo.size.width * 0.6, height: geo.size.height * 0.8) + .offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.15) + .blur(radius: 25) + } + + // Secondary blob in bottom-left + GeometryReader { geo in + OrganicBlobShape(variation: 2) + .fill( + RadialGradient( + colors: [ + Color.appAccent.opacity(colorScheme == .dark ? 0.06 : 0.04), + Color.appAccent.opacity(0.01) + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.3 + ) + ) + .frame(width: geo.size.width * 0.4, height: geo.size.height * 0.5) + .offset(x: -geo.size.width * 0.15, y: geo.size.height * 0.6) + .blur(radius: 20) + } + + // Grain texture + GrainTexture(opacity: 0.015) + } + } +} + +// MARK: - Preview + +#Preview("Summary Card") { + VStack(spacing: 24) { + SummaryCard(summary: TotalSummary( + totalResidences: 3, + totalTasks: 24, + totalPending: 8, + totalOverdue: 2, + tasksDueNextWeek: 5, + tasksDueNextMonth: 12 + )) + + SummaryCard(summary: TotalSummary( + totalResidences: 1, + totalTasks: 8, + totalPending: 3, + totalOverdue: 0, + tasksDueNextWeek: 2, + tasksDueNextMonth: 4 + )) + } + .padding(.horizontal, 16) + .padding(.vertical, 24) + .background(WarmGradientBackground()) } diff --git a/iosApp/iosApp/Subviews/Task/PriorityBadge.swift b/iosApp/iosApp/Subviews/Task/PriorityBadge.swift index 98bc1a2..f4564f5 100644 --- a/iosApp/iosApp/Subviews/Task/PriorityBadge.swift +++ b/iosApp/iosApp/Subviews/Task/PriorityBadge.swift @@ -4,19 +4,33 @@ struct PriorityBadge: View { let priority: String var body: some View { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "exclamationmark.circle.fill") + HStack(spacing: 5) { + Image(systemName: priorityIcon) .font(.system(size: 10, weight: .bold)) Text(priority.capitalized) - .font(.caption.weight(.medium)) - .fontWeight(.semibold) + .font(.system(size: 11, weight: .semibold, design: .rounded)) } - .padding(.horizontal, AppSpacing.sm) - .padding(.vertical, AppSpacing.xxs) - .background(priorityColor.opacity(0.15)) + .padding(.horizontal, 10) + .padding(.vertical, 5) .foregroundColor(priorityColor) - .cornerRadius(AppRadius.xs) + .background( + Capsule() + .fill(priorityColor.opacity(0.12)) + .overlay( + Capsule() + .stroke(priorityColor.opacity(0.2), lineWidth: 1) + ) + ) + } + + private var priorityIcon: String { + switch priority.lowercased() { + case "high": return "exclamationmark.triangle.fill" + case "medium": return "exclamationmark.circle.fill" + case "low": return "minus.circle.fill" + default: return "circle.fill" + } } private var priorityColor: Color { @@ -24,7 +38,7 @@ struct PriorityBadge: View { case "high": return Color.appError case "medium": return Color.appAccent case "low": return Color.appPrimary - default: return Color.appTextSecondary.opacity(0.7) + default: return Color.appTextSecondary } } } @@ -36,5 +50,5 @@ struct PriorityBadge: View { PriorityBadge(priority: "low") } .padding() - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) } diff --git a/iosApp/iosApp/Subviews/Task/StatusBadge.swift b/iosApp/iosApp/Subviews/Task/StatusBadge.swift index 7b3063c..0c7213d 100644 --- a/iosApp/iosApp/Subviews/Task/StatusBadge.swift +++ b/iosApp/iosApp/Subviews/Task/StatusBadge.swift @@ -4,14 +4,24 @@ struct StatusBadge: View { let status: String var body: some View { - Text(formatStatus(status)) - .font(.caption.weight(.medium)) - .fontWeight(.semibold) - .padding(.horizontal, AppSpacing.sm) - .padding(.vertical, AppSpacing.xxs) - .background(statusColor.opacity(0.15)) - .foregroundColor(statusColor) - .cornerRadius(AppRadius.xs) + HStack(spacing: 5) { + Image(systemName: statusIcon) + .font(.system(size: 10, weight: .bold)) + + Text(formatStatus(status)) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .foregroundColor(statusColor) + .background( + Capsule() + .fill(statusColor.opacity(0.12)) + .overlay( + Capsule() + .stroke(statusColor.opacity(0.2), lineWidth: 1) + ) + ) } private func formatStatus(_ status: String) -> String { @@ -22,13 +32,23 @@ struct StatusBadge: View { } } + private var statusIcon: String { + switch status { + case "completed": return "checkmark.circle.fill" + case "in_progress": return "play.circle.fill" + case "pending": return "clock.fill" + case "cancelled": return "xmark.circle.fill" + default: return "circle.fill" + } + } + private var statusColor: Color { switch status { case "completed": return Color.appPrimary case "in_progress": return Color.appAccent - case "pending": return Color.appAccent + case "pending": return Color.appSecondary case "cancelled": return Color.appError - default: return Color.appTextSecondary.opacity(0.7) + default: return Color.appTextSecondary } } } @@ -41,5 +61,5 @@ struct StatusBadge: View { StatusBadge(status: "cancelled") } .padding() - .background(Color.appBackgroundPrimary) + .background(WarmGradientBackground()) } diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index cc69de9..a5fd78a 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -12,14 +12,15 @@ struct TaskCard: View { let onUnarchive: (() -> Void)? @State private var isCompletionsExpanded = false + @Environment(\.colorScheme) var colorScheme var body: some View { - VStack(alignment: .leading, spacing: AppSpacing.md) { + VStack(alignment: .leading, spacing: 16) { // Header - HStack(alignment: .top, spacing: AppSpacing.sm) { - VStack(alignment: .leading, spacing: AppSpacing.xs) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { Text(task.title) - .font(.title3.weight(.semibold)) + .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) .lineLimit(2) @@ -36,66 +37,50 @@ struct TaskCard: View { // Description if !task.description_.isEmpty { Text(task.description_) - .font(.callout) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) .lineLimit(3) } - // Metadata - HStack(spacing: AppSpacing.md) { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "repeat") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary.opacity(0.7)) - Text(task.frequencyDisplayName ?? "") - .font(.caption.weight(.medium)) - .foregroundColor(Color.appTextSecondary) - } - .padding(.horizontal, AppSpacing.sm) - .padding(.vertical, AppSpacing.xxs) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.xs) + // Metadata Pills + HStack(spacing: 10) { + TaskMetadataPill( + icon: "repeat", + text: task.frequencyDisplayName ?? "" + ) Spacer() if let effectiveDate = task.effectiveDueDate { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "calendar") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(Color.appTextSecondary.opacity(0.7)) - Text(DateUtils.formatDate(effectiveDate)) - .font(.caption.weight(.medium)) - .foregroundColor(Color.appTextSecondary) - } - .padding(.horizontal, AppSpacing.sm) - .padding(.vertical, AppSpacing.xxs) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.xs) + TaskMetadataPill( + icon: "calendar", + text: DateUtils.formatDate(effectiveDate), + color: DateUtils.isOverdue(effectiveDate) ? Color.appError : Color.appTextSecondary + ) } } // Completions if task.completions.count > 0 { - Divider() - .padding(.vertical, AppSpacing.xxs) + OrganicDivider() + .padding(.vertical, 4) - VStack(alignment: .leading, spacing: AppSpacing.sm) { - HStack(spacing: AppSpacing.xs) { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { ZStack { Circle() - .fill(Color.appAccent.opacity(0.1)) - .frame(width: 24, height: 24) + .fill(Color.appAccent.opacity(0.12)) + .frame(width: 28, height: 28) Image(systemName: "checkmark.circle.fill") .font(.system(size: 14, weight: .semibold)) .foregroundColor(Color.appAccent) } Text("\(L10n.Tasks.completions.capitalized) (\(task.completions.count))") - .font(.footnote.weight(.medium)) - .fontWeight(.semibold) + .font(.system(size: 13, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Spacer() Image(systemName: isCompletionsExpanded ? "chevron.up" : "chevron.down") - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) } .contentShape(Rectangle()) @@ -115,130 +100,205 @@ struct TaskCard: View { // Primary Actions if task.showCompletedButton { - VStack(spacing: AppSpacing.xs) { + VStack(spacing: 10) { if let onMarkInProgress = onMarkInProgress, !task.inProgress { Button(action: onMarkInProgress) { - HStack(spacing: AppSpacing.xs) { + HStack(spacing: 8) { Image(systemName: "play.circle.fill") .font(.system(size: 16, weight: .semibold)) Text(L10n.Tasks.inProgress) - .font(.subheadline.weight(.medium)) - .fontWeight(.semibold) + .font(.system(size: 15, weight: .semibold, design: .rounded)) } .frame(maxWidth: .infinity) - .frame(height: 44) + .frame(height: 48) .foregroundColor(Color.appAccent) - .background(Color.appAccent.opacity(0.1)) - .cornerRadius(AppRadius.md) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.appAccent.opacity(0.12)) + ) } } if task.showCompletedButton, let onComplete = onComplete { Button(action: onComplete) { - HStack(spacing: AppSpacing.xs) { + HStack(spacing: 8) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 16, weight: .semibold)) Text(L10n.Tasks.complete) - .font(.subheadline.weight(.medium)) - .fontWeight(.semibold) + .font(.system(size: 15, weight: .semibold, design: .rounded)) } .frame(maxWidth: .infinity) - .frame(height: 44) + .frame(height: 48) .foregroundColor(Color.appTextOnPrimary) - .background(Color.appPrimary) - .cornerRadius(AppRadius.md) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill( + LinearGradient( + colors: [Color.appPrimary, Color.appPrimary.opacity(0.9)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) } } } } // Secondary Actions - VStack(spacing: AppSpacing.xs) { - HStack(spacing: AppSpacing.xs) { - Button(action: onEdit) { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "pencil") - .font(.system(size: 14, weight: .medium)) - Text(L10n.Tasks.edit) - .font(.footnote.weight(.medium)) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - .foregroundColor(Color.appPrimary) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.sm) - } + VStack(spacing: 10) { + HStack(spacing: 10) { + TaskSecondaryButton( + icon: "pencil", + text: L10n.Tasks.edit, + color: Color.appPrimary, + action: onEdit + ) if let onCancel = onCancel { - Button(action: onCancel) { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "xmark.circle") - .font(.system(size: 14, weight: .medium)) - Text(L10n.Tasks.cancel) - .font(.footnote.weight(.medium)) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - .foregroundColor(Color.appError) - .background(Color.appError.opacity(0.1)) - .cornerRadius(AppRadius.sm) - } + TaskSecondaryButton( + icon: "xmark.circle", + text: L10n.Tasks.cancel, + color: Color.appError, + isDestructive: true, + action: onCancel + ) } else if let onUncancel = onUncancel { - Button(action: onUncancel) { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "arrow.uturn.backward") - .font(.system(size: 14, weight: .medium)) - Text(L10n.Tasks.restore) - .font(.footnote.weight(.medium)) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - .foregroundColor(Color.appTextOnPrimary) - .background(Color.appPrimary) - .cornerRadius(AppRadius.sm) - } + TaskSecondaryButton( + icon: "arrow.uturn.backward", + text: L10n.Tasks.restore, + color: Color.appPrimary, + isFilled: true, + action: onUncancel + ) } } if task.archived { if let onUnarchive = onUnarchive { - Button(action: onUnarchive) { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "tray.and.arrow.up") - .font(.system(size: 14, weight: .medium)) - Text(L10n.Tasks.unarchive) - .font(.footnote.weight(.medium)) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - .foregroundColor(Color.appPrimary) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.sm) - } + TaskSecondaryButton( + icon: "tray.and.arrow.up", + text: L10n.Tasks.unarchive, + color: Color.appPrimary, + action: onUnarchive + ) } } else { if let onArchive = onArchive { - Button(action: onArchive) { - HStack(spacing: AppSpacing.xxs) { - Image(systemName: "archivebox") - .font(.system(size: 14, weight: .medium)) - Text(L10n.Tasks.archive) - .font(.footnote.weight(.medium)) - } - .frame(maxWidth: .infinity) - .frame(height: 36) - .foregroundColor(Color.appTextSecondary) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.sm) - } + TaskSecondaryButton( + icon: "archivebox", + text: L10n.Tasks.archive, + color: Color.appTextSecondary, + action: onArchive + ) } } } } - .padding(AppSpacing.md) - .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) - .shadow(color: AppShadow.md.color, radius: AppShadow.md.radius, x: AppShadow.md.x, y: AppShadow.md.y) + .padding(OrganicSpacing.cozy) + .background(TaskCardBackground()) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) + } +} + +// MARK: - Task Metadata Pill + +private struct TaskMetadataPill: View { + let icon: String + let text: String + var color: Color = Color.appTextSecondary + + var body: some View { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(color.opacity(0.8)) + Text(text) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(color) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color.appBackgroundPrimary.opacity(0.6)) + .overlay( + Capsule() + .stroke(color.opacity(0.15), lineWidth: 1) + ) + ) + } +} + +// MARK: - Task Secondary Button + +private struct TaskSecondaryButton: View { + let icon: String + let text: String + var color: Color = Color.appPrimary + var isDestructive: Bool = false + var isFilled: Bool = false + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 13, weight: .semibold)) + Text(text) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + } + .frame(maxWidth: .infinity) + .frame(height: 40) + .foregroundColor(isFilled ? Color.appTextOnPrimary : color) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + isFilled + ? color + : (isDestructive ? color.opacity(0.1) : Color.appBackgroundPrimary.opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(color.opacity(isFilled ? 0 : 0.2), lineWidth: 1) + ) + ) + } + } +} + +// MARK: - Task Card Background + +private struct TaskCardBackground: View { + @Environment(\.colorScheme) var colorScheme + + var body: some View { + ZStack { + Color.appBackgroundSecondary + + // Subtle organic blob + GeometryReader { geo in + OrganicBlobShape(variation: 1) + .fill( + RadialGradient( + colors: [ + Color.appPrimary.opacity(colorScheme == .dark ? 0.06 : 0.04), + Color.appPrimary.opacity(0.01) + ], + center: .center, + startRadius: 0, + endRadius: geo.size.width * 0.4 + ) + ) + .frame(width: geo.size.width * 0.5, height: geo.size.height * 0.6) + .offset(x: geo.size.width * 0.55, y: -geo.size.height * 0.1) + .blur(radius: 15) + } + + // Grain texture + GrainTexture(opacity: 0.012) + } } } diff --git a/iosApp/iosApp/Task/CompletionHistorySheet.swift b/iosApp/iosApp/Task/CompletionHistorySheet.swift index 6507eaf..ea3183b 100644 --- a/iosApp/iosApp/Task/CompletionHistorySheet.swift +++ b/iosApp/iosApp/Task/CompletionHistorySheet.swift @@ -10,15 +10,20 @@ struct CompletionHistorySheet: View { var body: some View { NavigationStack { - Group { - if viewModel.isLoadingCompletions { - loadingView - } else if let error = viewModel.completionsError { - errorView(error) - } else if viewModel.completions.isEmpty { - emptyView - } else { - completionsList + ZStack { + WarmGradientBackground() + .ignoresSafeArea() + + Group { + if viewModel.isLoadingCompletions { + loadingView + } else if let error = viewModel.completionsError { + errorView(error) + } else if viewModel.completions.isEmpty { + emptyView + } else { + completionsList + } } } .navigationTitle(L10n.Tasks.completionHistory) @@ -28,9 +33,9 @@ struct CompletionHistorySheet: View { Button(L10n.Common.done) { isPresented = false } + .font(.system(size: 16, weight: .semibold, design: .rounded)) } } - .background(Color.appBackgroundPrimary) } .onAppear { viewModel.loadCompletions(taskId: taskId) @@ -43,56 +48,80 @@ struct CompletionHistorySheet: View { // MARK: - Subviews private var loadingView: some View { - VStack(spacing: AppSpacing.md) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary)) - .scaleEffect(1.5) + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 64, height: 64) + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary)) + .scaleEffect(1.2) + } Text(L10n.Tasks.loadingCompletions) - .font(.subheadline) + .font(.system(size: 15, weight: .medium, design: .rounded)) .foregroundColor(Color.appTextSecondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func errorView(_ error: String) -> some View { - VStack(spacing: AppSpacing.md) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 48)) - .foregroundColor(Color.appError) + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill(Color.appError.opacity(0.1)) + .frame(width: 80, height: 80) + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 32, weight: .semibold)) + .foregroundColor(Color.appError) + } Text(L10n.Tasks.failedToLoad) - .font(.headline) + .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text(error) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) .multilineTextAlignment(.center) - .padding(.horizontal) + .padding(.horizontal, OrganicSpacing.spacious) Button(action: { viewModel.loadCompletions(taskId: taskId) }) { - Label(L10n.Common.retry, systemImage: "arrow.clockwise") - .foregroundColor(Color.appPrimary) + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .semibold)) + Text(L10n.Common.retry) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + } + .foregroundColor(Color.appTextOnPrimary) + .padding(.horizontal, OrganicSpacing.comfortable) + .padding(.vertical, 12) + .background(Color.appPrimary) + .clipShape(Capsule()) } - .padding(.top, AppSpacing.sm) + .padding(.top, 8) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var emptyView: some View { - VStack(spacing: AppSpacing.md) { - Image(systemName: "checkmark.circle") - .font(.system(size: 48)) - .foregroundColor(Color.appTextSecondary.opacity(0.5)) + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill(Color.appTextSecondary.opacity(0.08)) + .frame(width: 80, height: 80) + Image(systemName: "checkmark.circle") + .font(.system(size: 36, weight: .medium)) + .foregroundColor(Color.appTextSecondary.opacity(0.5)) + } Text(L10n.Tasks.noCompletionsYet) - .font(.headline) + .font(.system(size: 18, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) Text(L10n.Tasks.notCompleted) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -100,30 +129,46 @@ struct CompletionHistorySheet: View { private var completionsList: some View { ScrollView { - VStack(spacing: AppSpacing.sm) { + VStack(spacing: OrganicSpacing.cozy) { // Task title header - HStack { - Image(systemName: "doc.text") - .foregroundColor(Color.appPrimary) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.12)) + .frame(width: 36, height: 36) + Image(systemName: "doc.text") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } + Text(taskTitle) - .font(.subheadline) - .fontWeight(.semibold) + .font(.system(size: 15, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextPrimary) + .lineLimit(2) + Spacer() + Text("\(viewModel.completions.count) \(viewModel.completions.count == 1 ? L10n.Tasks.completion : L10n.Tasks.completions)") - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background( + Capsule() + .fill(Color.appTextSecondary.opacity(0.1)) + ) } - .padding() + .padding(OrganicSpacing.cozy) .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.md) + .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + .naturalShadow(.subtle) // Completions list ForEach(viewModel.completions, id: \.id) { completion in CompletionHistoryCard(completion: completion) } } - .padding() + .padding(OrganicSpacing.cozy) } } } @@ -134,20 +179,20 @@ struct CompletionHistoryCard: View { @State private var showPhotoSheet = false var body: some View { - VStack(alignment: .leading, spacing: AppSpacing.sm) { + VStack(alignment: .leading, spacing: 14) { // Header with date and completed by HStack { VStack(alignment: .leading, spacing: 4) { Text(DateUtils.formatDateTimeWithTime(completion.completionDate)) - .font(.headline) + .font(.system(size: 16, weight: .bold, design: .rounded)) .foregroundColor(Color.appTextPrimary) if let completedBy = completion.completedByName, !completedBy.isEmpty { - HStack(spacing: 4) { + HStack(spacing: 5) { Image(systemName: "person.fill") - .font(.caption2) + .font(.system(size: 10, weight: .medium)) Text("\(L10n.Tasks.completedByName) \(completedBy)") - .font(.caption) + .font(.system(size: 12, weight: .medium)) } .foregroundColor(Color.appTextSecondary) } @@ -157,39 +202,48 @@ struct CompletionHistoryCard: View { // Rating badge if let rating = completion.rating { - HStack(spacing: 2) { + HStack(spacing: 4) { Image(systemName: "star.fill") - .font(.caption) + .font(.system(size: 11, weight: .bold)) Text("\(rating)") - .font(.subheadline) - .fontWeight(.bold) + .font(.system(size: 13, weight: .bold, design: .rounded)) } .foregroundColor(Color.appAccent) - .padding(.horizontal, 10) + .padding(.horizontal, 12) .padding(.vertical, 6) - .background(Color.appAccent.opacity(0.1)) - .cornerRadius(AppRadius.sm) + .background( + Capsule() + .fill(Color.appAccent.opacity(0.12)) + .overlay( + Capsule() + .stroke(Color.appAccent.opacity(0.2), lineWidth: 1) + ) + ) } } - Divider() + OrganicDivider() // Contractor info if let contractor = completion.contractorDetails { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "wrench.and.screwdriver.fill") - .foregroundColor(Color.appPrimary) - .frame(width: 24) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + Image(systemName: "wrench.and.screwdriver.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } VStack(alignment: .leading, spacing: 2) { Text(contractor.name) - .font(.subheadline) - .fontWeight(.medium) + .font(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextPrimary) if let company = contractor.company { Text(company) - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) } } @@ -198,28 +252,33 @@ struct CompletionHistoryCard: View { // Cost if let cost = completion.actualCost { - HStack(spacing: AppSpacing.sm) { - Image(systemName: "dollarsign.circle.fill") - .foregroundColor(Color.appPrimary) - .frame(width: 24) + HStack(spacing: 12) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 32, height: 32) + Image(systemName: "dollarsign.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } Text("$\(cost)") - .font(.subheadline) - .fontWeight(.semibold) + .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundColor(Color.appPrimary) } } // Notes if !completion.notes.isEmpty { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { Text(L10n.Tasks.notes) - .font(.caption) - .fontWeight(.semibold) + .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundColor(Color.appTextSecondary) + .textCase(.uppercase) + .tracking(0.5) Text(completion.notes) - .font(.subheadline) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextPrimary) } .padding(.top, 4) @@ -230,26 +289,27 @@ struct CompletionHistoryCard: View { Button(action: { showPhotoSheet = true }) { - HStack { + HStack(spacing: 8) { Image(systemName: "photo.on.rectangle.angled") - .font(.subheadline) + .font(.system(size: 14, weight: .semibold)) Text("\(L10n.Tasks.viewPhotos) (\(completion.images.count))") - .font(.subheadline) - .fontWeight(.semibold) + .font(.system(size: 14, weight: .semibold, design: .rounded)) } .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .background(Color.appPrimary.opacity(0.1)) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color.appPrimary.opacity(0.12)) + ) .foregroundColor(Color.appPrimary) - .cornerRadius(AppRadius.sm) } - .padding(.top, 4) + .padding(.top, 6) } } - .padding() + .padding(OrganicSpacing.cozy) .background(Color.appBackgroundSecondary) - .cornerRadius(AppRadius.lg) - .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .naturalShadow(.medium) .sheet(isPresented: $showPhotoSheet) { PhotoViewerSheet(images: completion.images) } diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index d1ac16c..802e160 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -98,6 +98,9 @@ struct TaskFormView: View { var body: some View { NavigationStack { ZStack { + WarmGradientBackground() + .ignoresSafeArea() + Form { // Residence Picker (only if needed) if needsResidenceSelection, let residences = residences { @@ -130,31 +133,40 @@ struct TaskFormView: View { Button { showingTemplatesBrowser = true } label: { - HStack { - Image(systemName: "list.bullet.rectangle") - .font(.system(size: 18)) - .foregroundColor(Color.appPrimary) - .frame(width: 28) + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.12)) + .frame(width: 40, height: 40) + Image(systemName: "list.bullet.rectangle") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.appPrimary) + } - Text("Browse Task Templates") - .foregroundColor(Color.appTextPrimary) + VStack(alignment: .leading, spacing: 2) { + Text("Browse Task Templates") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + .foregroundColor(Color.appTextPrimary) + + Text("\(dataManager.taskTemplateCount) common tasks") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Color.appTextSecondary) + } Spacer() - Text("\(dataManager.taskTemplateCount) tasks") - .font(.caption) - .foregroundColor(Color.appTextSecondary) - Image(systemName: "chevron.right") - .font(.caption) + .font(.system(size: 14, weight: .medium)) .foregroundColor(Color.appTextSecondary) } + .padding(.vertical, 4) } } header: { Text("Quick Start") + .font(.system(size: 13, weight: .semibold, design: .rounded)) } footer: { Text("Choose from common home maintenance tasks or create your own below") - .font(.caption) + .font(.system(size: 12, weight: .medium)) .foregroundColor(Color.appTextSecondary) } .listRowBackground(Color.appBackgroundSecondary) @@ -291,19 +303,36 @@ struct TaskFormView: View { .blur(radius: isLoadingLookups ? 3 : 0) if isLoadingLookups { - VStack(spacing: 16) { - ProgressView() - .scaleEffect(1.5) + VStack(spacing: OrganicSpacing.comfortable) { + ZStack { + Circle() + .fill(Color.appPrimary.opacity(0.1)) + .frame(width: 64, height: 64) + ProgressView() + .scaleEffect(1.2) + .tint(Color.appPrimary) + } Text(L10n.Tasks.loading) + .font(.system(size: 15, weight: .medium, design: .rounded)) .foregroundColor(Color.appTextSecondary) } + .padding(OrganicSpacing.spacious) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color.appBackgroundSecondary) + .overlay( + GrainTexture(opacity: 0.015) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + ) + ) + .naturalShadow(.medium) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.appBackgroundPrimary.opacity(0.8)) + .background(Color.appBackgroundPrimary.opacity(0.9)) } } .listStyle(.plain) .scrollContentBackground(.hidden) - .background(Color.appBackgroundPrimary) + .background(Color.clear) .navigationTitle(isEditMode ? L10n.Tasks.editTitle : L10n.Tasks.addTitle) .navigationBarTitleDisplayMode(.inline) .toolbar {