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