Add Warm Organic design system to iOS app

- Add OrganicDesign.swift with reusable components:
  - WarmGradientBackground, OrganicBlobShape, GrainTexture
  - OrganicDivider, OrganicCardBackground, NaturalShadow modifier
  - OrganicSpacing constants (cozy, comfortable, spacious, airy)

- Update high-priority screens with organic styling:
  - LoginView: hero glow, organic card background, rounded fonts
  - ResidenceDetailView, ResidencesListView: warm backgrounds
  - ResidenceCard, SummaryCard, PropertyHeaderCard: organic cards
  - TaskCard: metadata pills, secondary buttons, card background
  - TaskFormView: organic loading overlay, templates button
  - CompletionHistorySheet: organic loading/error/empty states
  - ProfileView, NotificationPreferencesView, ThemeSelectionView

- Update task badges with icons and capsule styling:
  - PriorityBadge: priority-specific icons
  - StatusBadge: status-specific icons

- Fix TaskCard isOverdue error using DateUtils.isOverdue()

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-16 20:15:32 -06:00
parent 67f8dcc80f
commit 3598a8d57f
15 changed files with 2318 additions and 703 deletions

View File

@@ -0,0 +1,454 @@
import SwiftUI
// MARK: - Organic Design System
// Warm, natural aesthetic with soft shapes, subtle textures, and flowing layouts
// MARK: - Organic Shapes
/// Soft organic blob shape for backgrounds
struct OrganicBlobShape: Shape {
var variation: Int = 0
func path(in rect: CGRect) -> Path {
var path = Path()
let w = rect.width
let h = rect.height
switch variation % 3 {
case 0:
// Soft cloud-like blob
path.move(to: CGPoint(x: w * 0.1, y: h * 0.5))
path.addCurve(
to: CGPoint(x: w * 0.5, y: h * 0.05),
control1: CGPoint(x: w * 0.0, y: h * 0.1),
control2: CGPoint(x: w * 0.25, y: h * 0.0)
)
path.addCurve(
to: CGPoint(x: w * 0.95, y: h * 0.45),
control1: CGPoint(x: w * 0.75, y: h * 0.1),
control2: CGPoint(x: w * 1.0, y: h * 0.25)
)
path.addCurve(
to: CGPoint(x: w * 0.55, y: h * 0.95),
control1: CGPoint(x: w * 0.9, y: h * 0.7),
control2: CGPoint(x: w * 0.8, y: h * 0.95)
)
path.addCurve(
to: CGPoint(x: w * 0.1, y: h * 0.5),
control1: CGPoint(x: w * 0.25, y: h * 0.95),
control2: CGPoint(x: w * 0.05, y: h * 0.75)
)
case 1:
// Pebble shape
path.move(to: CGPoint(x: w * 0.15, y: h * 0.4))
path.addCurve(
to: CGPoint(x: w * 0.6, y: h * 0.08),
control1: CGPoint(x: w * 0.1, y: h * 0.15),
control2: CGPoint(x: w * 0.35, y: h * 0.05)
)
path.addCurve(
to: CGPoint(x: w * 0.9, y: h * 0.55),
control1: CGPoint(x: w * 0.85, y: h * 0.12),
control2: CGPoint(x: w * 0.95, y: h * 0.35)
)
path.addCurve(
to: CGPoint(x: w * 0.45, y: h * 0.92),
control1: CGPoint(x: w * 0.85, y: h * 0.8),
control2: CGPoint(x: w * 0.65, y: h * 0.95)
)
path.addCurve(
to: CGPoint(x: w * 0.15, y: h * 0.4),
control1: CGPoint(x: w * 0.2, y: h * 0.88),
control2: CGPoint(x: w * 0.08, y: h * 0.65)
)
default:
// Leaf-like shape
path.move(to: CGPoint(x: w * 0.05, y: h * 0.5))
path.addCurve(
to: CGPoint(x: w * 0.5, y: h * 0.02),
control1: CGPoint(x: w * 0.05, y: h * 0.2),
control2: CGPoint(x: w * 0.25, y: h * 0.02)
)
path.addCurve(
to: CGPoint(x: w * 0.95, y: h * 0.5),
control1: CGPoint(x: w * 0.75, y: h * 0.02),
control2: CGPoint(x: w * 0.95, y: h * 0.2)
)
path.addCurve(
to: CGPoint(x: w * 0.5, y: h * 0.98),
control1: CGPoint(x: w * 0.95, y: h * 0.8),
control2: CGPoint(x: w * 0.75, y: h * 0.98)
)
path.addCurve(
to: CGPoint(x: w * 0.05, y: h * 0.5),
control1: CGPoint(x: w * 0.25, y: h * 0.98),
control2: CGPoint(x: w * 0.05, y: h * 0.8)
)
}
return path
}
}
/// Super soft rounded rectangle for organic cards
struct OrganicRoundedRectangle: Shape {
var cornerRadius: CGFloat = 28
func path(in rect: CGRect) -> Path {
Path(roundedRect: rect, cornerRadius: cornerRadius, style: .continuous)
}
}
// MARK: - Grain Texture Overlay
struct GrainTexture: View {
var opacity: Double = 0.03
var animated: Bool = false
@State private var phase: Double = 0
var body: some View {
GeometryReader { geometry in
Canvas { context, size in
for _ in 0..<Int(size.width * size.height / 50) {
let x = CGFloat.random(in: 0...size.width)
let y = CGFloat.random(in: 0...size.height)
let grainOpacity = Double.random(in: 0.3...1.0)
context.fill(
Path(ellipseIn: CGRect(x: x, y: y, width: 1, height: 1)),
with: .color(.black.opacity(grainOpacity * opacity))
)
}
}
}
.allowsHitTesting(false)
}
}
// MARK: - Organic Card Background
struct OrganicCardBackground: View {
var accentColor: Color = Color.appPrimary
var showBlob: Bool = true
var blobVariation: Int = 0
var body: some View {
ZStack {
// Main card fill
RoundedRectangle(cornerRadius: 28, style: .continuous)
.fill(Color.appBackgroundSecondary)
// Subtle accent blob in corner
if showBlob {
GeometryReader { geo in
OrganicBlobShape(variation: blobVariation)
.fill(
LinearGradient(
colors: [
accentColor.opacity(0.08),
accentColor.opacity(0.02)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: geo.size.width * 0.6, height: geo.size.height * 0.7)
.offset(x: geo.size.width * 0.5, y: -geo.size.height * 0.1)
.blur(radius: 20)
}
.clipped()
}
// Grain texture
GrainTexture(opacity: 0.015)
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
}
}
}
// MARK: - Natural Shadow Modifier
struct NaturalShadow: ViewModifier {
var intensity: ShadowIntensity = .medium
enum ShadowIntensity {
case subtle, medium, pronounced
var values: (color: Color, radius: CGFloat, y: CGFloat) {
switch self {
case .subtle:
return (Color.black.opacity(0.04), 8, 4)
case .medium:
return (Color.black.opacity(0.08), 16, 8)
case .pronounced:
return (Color.black.opacity(0.12), 24, 12)
}
}
}
func body(content: Content) -> some View {
content
.shadow(
color: intensity.values.color,
radius: intensity.values.radius,
x: 0,
y: intensity.values.y
)
// Secondary ambient shadow for depth
.shadow(
color: intensity.values.color.opacity(0.5),
radius: intensity.values.radius * 2,
x: 0,
y: intensity.values.y * 0.5
)
}
}
// MARK: - Organic Icon Container
struct OrganicIconContainer: View {
let systemName: String
var size: CGFloat = 48
var iconScale: CGFloat = 0.5
var backgroundColor: Color = Color.appPrimary
var iconColor: Color = Color.appTextOnPrimary
var body: some View {
ZStack {
// Soft organic background
Circle()
.fill(
RadialGradient(
colors: [
backgroundColor,
backgroundColor.opacity(0.85)
],
center: .topLeading,
startRadius: 0,
endRadius: size
)
)
.frame(width: size, height: size)
// Inner glow
Circle()
.fill(
RadialGradient(
colors: [
Color.white.opacity(0.2),
Color.clear
],
center: .topLeading,
startRadius: 0,
endRadius: size * 0.5
)
)
.frame(width: size, height: size)
// Icon
Image(systemName: systemName)
.font(.system(size: size * iconScale, weight: .semibold))
.foregroundColor(iconColor)
}
.modifier(NaturalShadow(intensity: .subtle))
}
}
// MARK: - Organic Stat Pill
struct OrganicStatPill: View {
let icon: String
let value: String
let label: String
var color: Color = Color.appPrimary
var isSystemIcon: Bool = true
var body: some View {
HStack(spacing: 8) {
// Icon with soft background
ZStack {
Circle()
.fill(color.opacity(0.12))
.frame(width: 32, height: 32)
if isSystemIcon {
Image(systemName: icon)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(color)
} else {
Image(icon)
.resizable()
.scaledToFit()
.frame(width: 16, height: 16)
.foregroundColor(color)
}
}
VStack(alignment: .leading, spacing: 1) {
Text(value)
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
Capsule()
.fill(Color.appBackgroundSecondary)
.overlay(
Capsule()
.stroke(color.opacity(0.15), lineWidth: 1)
)
)
}
}
// MARK: - Organic Divider
struct OrganicDivider: View {
var color: Color = Color.appTextSecondary.opacity(0.15)
var height: CGFloat = 1
var horizontalPadding: CGFloat = 0
var body: some View {
Rectangle()
.fill(
LinearGradient(
colors: [
color.opacity(0),
color,
color,
color.opacity(0)
],
startPoint: .leading,
endPoint: .trailing
)
)
.frame(height: height)
.padding(.horizontal, horizontalPadding)
}
}
// MARK: - Warm Gradient Background
struct WarmGradientBackground: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
ZStack {
Color.appBackgroundPrimary
// Subtle warm gradient overlay
LinearGradient(
colors: colorScheme == .dark
? [Color.appPrimary.opacity(0.05), Color.clear]
: [Color.appPrimary.opacity(0.03), Color.clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
// Grain for natural feel
GrainTexture(opacity: 0.02)
}
.ignoresSafeArea()
}
}
// MARK: - View Extensions
extension View {
func organicCard(
accentColor: Color = Color.appPrimary,
showBlob: Bool = true,
shadowIntensity: NaturalShadow.ShadowIntensity = .medium
) -> some View {
self
.background(OrganicCardBackground(accentColor: accentColor, showBlob: showBlob))
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.modifier(NaturalShadow(intensity: shadowIntensity))
}
func naturalShadow(_ intensity: NaturalShadow.ShadowIntensity = .medium) -> some View {
modifier(NaturalShadow(intensity: intensity))
}
}
// MARK: - Organic Spacing
struct OrganicSpacing {
static let cozy: CGFloat = 20
static let comfortable: CGFloat = 24
static let spacious: CGFloat = 32
static let airy: CGFloat = 40
}
// MARK: - Animated Leaf Decoration
struct FloatingLeaf: View {
@State private var rotation: Double = 0
@State private var offset: CGFloat = 0
var delay: Double = 0
var size: CGFloat = 20
var color: Color = Color.appPrimary
var body: some View {
Image(systemName: "leaf.fill")
.font(.system(size: size))
.foregroundColor(color.opacity(0.15))
.rotationEffect(.degrees(rotation))
.offset(y: offset)
.onAppear {
withAnimation(
Animation
.easeInOut(duration: 4)
.repeatForever(autoreverses: true)
.delay(delay)
) {
rotation = 15
offset = 8
}
}
}
}
// MARK: - Preview
#Preview("Organic Components") {
ScrollView {
VStack(spacing: 24) {
// Organic Icon Container
HStack(spacing: 16) {
OrganicIconContainer(systemName: "house.fill", size: 56)
OrganicIconContainer(systemName: "wrench.fill", size: 48, backgroundColor: .orange)
OrganicIconContainer(systemName: "leaf.fill", size: 40, backgroundColor: .green)
}
// Organic Stat Pills
HStack(spacing: 12) {
OrganicStatPill(icon: "house.fill", value: "3", label: "Properties")
OrganicStatPill(icon: "checklist", value: "12", label: "Tasks", color: .orange)
}
// Organic Card
VStack(alignment: .leading, spacing: 16) {
Text("Organic Card Example")
.font(.title3.weight(.bold))
Text("This card uses soft shapes, subtle gradients, and natural shadows.")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(OrganicSpacing.cozy)
.organicCard()
// Organic Divider
OrganicDivider()
.padding(.horizontal, 40)
}
.padding()
}
.background(WarmGradientBackground())
}

View File

@@ -40,49 +40,70 @@ struct LoginView: View {
var body: some View {
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()

View File

@@ -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)
}
}

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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)
}

View File

@@ -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 {