Add Warm Organic design system to iOS app

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

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

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

- Fix TaskCard isOverdue error using DateUtils.isOverdue()

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

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

View File

@@ -47,8 +47,7 @@ struct ResidenceDetailView: View {
var body: some View {
ZStack {
Color.appBackgroundPrimary
.ignoresSafeArea()
WarmGradientBackground()
mainContent
}
@@ -208,19 +207,19 @@ private extension ResidenceDetailView {
@ViewBuilder
func contentView(for residence: ResidenceResponse) -> some View {
ScrollView {
VStack(spacing: 16) {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
PropertyHeaderCard(residence: residence)
.padding(.horizontal)
.padding(.top)
.padding(.horizontal, 16)
.padding(.top, 8)
tasksSection
.padding(.horizontal)
.padding(.horizontal, 16)
contractorsSection
.padding(.horizontal)
.padding(.horizontal, 16)
}
.padding(.bottom)
.padding(.bottom, OrganicSpacing.airy)
}
}
@@ -248,49 +247,81 @@ private extension ResidenceDetailView {
@ViewBuilder
var contractorsSection: some View {
VStack(alignment: .leading, spacing: AppSpacing.md) {
VStack(alignment: .leading, spacing: 16) {
// Section Header
HStack(spacing: AppSpacing.sm) {
Image(systemName: "person.2.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
HStack(alignment: .center, spacing: 12) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.12))
.frame(width: 40, height: 40)
Image(systemName: "person.2.fill")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
Text(L10n.Residences.contractors)
.font(.title2.weight(.bold))
.foregroundColor(Color.appPrimary)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Spacer()
}
.padding(.top, AppSpacing.sm)
.padding(.top, 8)
if isLoadingContractors {
HStack {
Spacer()
ProgressView()
.tint(Color.appPrimary)
Spacer()
}
.padding()
.padding(OrganicSpacing.cozy)
} else if let error = contractorsError {
Text("\(L10n.Common.error): \(error)")
.foregroundColor(Color.appError)
.padding()
} else if contractors.isEmpty {
// Empty state
VStack(spacing: AppSpacing.md) {
Image(systemName: "person.crop.circle.badge.plus")
.font(.system(size: 48))
.foregroundColor(Color.appTextSecondary.opacity(0.6))
Text(L10n.Residences.noContractors)
.font(.headline)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Residences.addContractorsPrompt)
.font(.subheadline)
.foregroundColor(Color.appTextSecondary)
// Empty state with organic styling
VStack(spacing: 16) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.12),
Color.appPrimary.opacity(0.04)
],
center: .center,
startRadius: 0,
endRadius: 50
)
)
.frame(width: 80, height: 80)
Image(systemName: "person.crop.circle.badge.plus")
.font(.system(size: 32, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.6))
}
VStack(spacing: 8) {
Text(L10n.Residences.noContractors)
.font(.system(size: 17, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text(L10n.Residences.addContractorsPrompt)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(AppSpacing.xl)
.background(Color.appBackgroundSecondary)
.cornerRadius(AppRadius.lg)
.padding(OrganicSpacing.spacious)
.background(OrganicCardBackground(showBlob: true, blobVariation: 1))
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.naturalShadow(.subtle)
} else {
// Contractors list
VStack(spacing: AppSpacing.sm) {
VStack(spacing: 12) {
ForEach(contractors, id: \.id) { contractor in
NavigationLink(destination: ContractorDetailView(contractorId: contractor.id)) {
ContractorCard(
@@ -300,7 +331,7 @@ private extension ResidenceDetailView {
}
)
}
.buttonStyle(PlainButtonStyle())
.buttonStyle(OrganicCardButtonStyle())
}
}
}
@@ -308,6 +339,17 @@ private extension ResidenceDetailView {
}
}
// MARK: - Organic Card Button Style
private struct OrganicCardButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Toolbars
private extension ResidenceDetailView {

View File

@@ -14,8 +14,8 @@ struct ResidencesListView: View {
var body: some View {
ZStack {
Color.appBackgroundPrimary
.ignoresSafeArea()
// Warm organic background
WarmGradientBackground()
if let response = viewModel.myResidences {
ListAsyncContentView(
@@ -29,7 +29,7 @@ struct ResidencesListView: View {
)
},
emptyContent: {
EmptyResidencesView()
OrganicEmptyResidencesView()
},
onRefresh: {
viewModel.loadMyResidences(forceRefresh: true)
@@ -53,9 +53,7 @@ struct ResidencesListView: View {
Button(action: {
showingSettings = true
}) {
Image(systemName: "gearshape.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
OrganicToolbarButton(systemName: "gearshape.fill")
}
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.settingsButton)
}
@@ -70,9 +68,7 @@ struct ResidencesListView: View {
showingJoinResidence = true
}
}) {
Image(systemName: "person.badge.plus")
.font(.system(size: 18, weight: .semibold))
.foregroundColor(Color.appPrimary)
OrganicToolbarButton(systemName: "person.badge.plus")
}
Button(action: {
@@ -84,9 +80,7 @@ struct ResidencesListView: View {
showingAddResidence = true
}
}) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 22, weight: .semibold))
.foregroundColor(Color.appPrimary)
OrganicToolbarButton(systemName: "plus", isPrimary: true)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Residence.addButton)
}
@@ -148,6 +142,35 @@ struct ResidencesListView: View {
}
}
// MARK: - Organic Toolbar Button
private struct OrganicToolbarButton: View {
let systemName: String
var isPrimary: Bool = false
var body: some View {
ZStack {
if isPrimary {
Circle()
.fill(Color.appPrimary)
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .bold))
.foregroundColor(Color.appTextOnPrimary)
} else {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 32, height: 32)
Image(systemName: systemName)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
}
}
}
// MARK: - Residences Content View
private struct ResidencesContent: View {
@@ -156,36 +179,51 @@ private struct ResidencesContent: View {
var body: some View {
ScrollView(showsIndicators: false) {
VStack(spacing: AppSpacing.lg) {
// Summary Card
VStack(spacing: OrganicSpacing.comfortable) {
// Summary Card with enhanced styling
SummaryCard(summary: summary)
.padding(.horizontal, AppSpacing.md)
.padding(.top, AppSpacing.sm)
.padding(.horizontal, 16)
.padding(.top, 8)
// Properties Header
HStack {
VStack(alignment: .leading, spacing: AppSpacing.xxs) {
// Properties Section Header
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(L10n.Residences.yourProperties)
.font(.title3.weight(.semibold))
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("\(residences.count) \(residences.count == 1 ? L10n.Residences.property : L10n.Residences.properties)")
.font(.callout)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundColor(Color.appTextSecondary)
}
Spacer()
}
.padding(.horizontal, AppSpacing.md)
// Residences List
ForEach(residences, id: \.id) { residence in
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
ResidenceCard(residence: residence)
.padding(.horizontal, AppSpacing.md)
Spacer()
// Decorative leaf
Image(systemName: "leaf.fill")
.font(.system(size: 16, weight: .medium))
.foregroundColor(Color.appPrimary.opacity(0.3))
.rotationEffect(.degrees(-15))
}
.padding(.horizontal, 20)
.padding(.top, 8)
// Residences List with staggered animation
LazyVStack(spacing: 16) {
ForEach(Array(residences.enumerated()), id: \.element.id) { index, residence in
NavigationLink(destination: ResidenceDetailView(residenceId: residence.id)) {
ResidenceCard(residence: residence)
.padding(.horizontal, 16)
}
.buttonStyle(OrganicCardButtonStyle())
.transition(.asymmetric(
insertion: .opacity.combined(with: .move(edge: .bottom)),
removal: .opacity
))
}
.buttonStyle(PlainButtonStyle())
}
}
.padding(.bottom, AppSpacing.xxxl)
.padding(.bottom, OrganicSpacing.airy)
}
.safeAreaInset(edge: .bottom) {
Color.clear.frame(height: 0)
@@ -193,6 +231,97 @@ private struct ResidencesContent: View {
}
}
// MARK: - Organic Card Button Style
private struct OrganicCardButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.9 : 1.0)
.animation(.easeOut(duration: 0.15), value: configuration.isPressed)
}
}
// MARK: - Organic Empty Residences View
private struct OrganicEmptyResidencesView: View {
@State private var isAnimating = false
var body: some View {
VStack(spacing: OrganicSpacing.comfortable) {
Spacer()
// Animated house illustration
ZStack {
// Background glow
Circle()
.fill(
RadialGradient(
colors: [
Color.appPrimary.opacity(0.15),
Color.appPrimary.opacity(0.05),
Color.clear
],
center: .center,
startRadius: 0,
endRadius: 80
)
)
.frame(width: 160, height: 160)
.scaleEffect(isAnimating ? 1.1 : 1.0)
.animation(
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
value: isAnimating
)
// House icon
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 100, height: 100)
Image(systemName: "house.lodge.fill")
.font(.system(size: 44, weight: .medium))
.foregroundColor(Color.appPrimary)
.offset(y: isAnimating ? -2 : 2)
.animation(
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
value: isAnimating
)
}
}
VStack(spacing: 12) {
Text("Welcome to Your Space")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
Text("Add your first property to start\nmanaging your home with ease")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(.top, 8)
Spacer()
// Decorative footer elements
HStack(spacing: 40) {
FloatingLeaf(delay: 0, size: 18, color: Color.appPrimary)
FloatingLeaf(delay: 0.5, size: 14, color: Color.appAccent)
FloatingLeaf(delay: 1.0, size: 20, color: Color.appPrimary)
}
.opacity(0.6)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
isAnimating = true
}
}
}
#Preview {
NavigationView {
ResidencesListView()