Files
honeyDueKMP/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift
Trey T 4609d5a953 Smart onboarding: home profile, tabbed tasks, free app
New onboarding step: "Tell us about your home" with chip-based pickers
for systems (heating/cooling/water heater), features (pool, fireplace,
garage, etc.), exterior (roof, siding), interior (flooring, landscaping).
All optional, skippable.

Tabbed task selection: "For You" tab shows personalized suggestions
based on home profile, "Browse All" has existing category browser.
Removed 5-task limit — users can add unlimited tasks.

Removed subscription upsell from onboarding flow — app is free.
Fixed picker capsule squishing bug with .fixedSize() modifier.

Both iOS and Compose implementations updated.
2026-03-30 09:02:27 -05:00

383 lines
14 KiB
Swift

import SwiftUI
import ComposeApp
struct ResidenceCard: View {
let residence: ResidenceResponse
let taskMetrics: WidgetDataManager.TaskMetrics
/// Check if this residence has any overdue tasks
private var hasOverdueTasks: Bool {
taskMetrics.overdueCount > 0
}
/// Open the address in Apple Maps
private func openInMaps() {
var addressComponents: [String] = []
if !residence.streetAddress.isEmpty {
addressComponents.append(residence.streetAddress)
}
if !residence.city.isEmpty {
addressComponents.append(residence.city)
}
if !residence.stateProvince.isEmpty {
addressComponents.append(residence.stateProvince)
}
if !residence.postalCode.isEmpty {
addressComponents.append(residence.postalCode)
}
let address = addressComponents.joined(separator: ", ")
guard let encodedAddress = address.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: "maps://?address=\(encodedAddress)") else {
return
}
UIApplication.shared.open(url)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Top Section: Icon + Property Info + Primary Badge
HStack(alignment: .top, spacing: 16) {
// Property Icon with organic styling
PropertyIconView(hasOverdue: hasOverdueTasks)
// Property Details
VStack(alignment: .leading, spacing: 6) {
// Property Name
Text(residence.name)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.lineLimit(1)
// Property Type
if let propertyTypeName = residence.propertyTypeName {
Text(propertyTypeName.uppercased())
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundColor(Color.appTextSecondary)
.tracking(1.2)
}
// Address - tappable to open maps
if !residence.streetAddress.isEmpty {
Button(action: {
openInMaps()
}) {
HStack(spacing: 6) {
Image(systemName: "mappin")
.font(.system(size: 10, weight: .bold))
.foregroundColor(Color.appPrimary.opacity(0.7))
Text(residence.streetAddress)
.font(.system(size: 13, weight: .medium))
.foregroundColor(Color.appPrimary)
.lineLimit(1)
Image(systemName: "arrow.up.right")
.font(.system(size: 9, weight: .semibold))
.foregroundColor(Color.appPrimary.opacity(0.6))
}
}
.accessibilityLabel("Open \(residence.streetAddress) in Maps")
.padding(.top, 2)
}
}
Spacer()
// Primary Badge
if residence.isPrimary {
PrimaryBadgeView()
}
}
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.top, OrganicSpacing.cozy)
.padding(.bottom, 16)
// Divider
OrganicDivider()
.padding(.horizontal, 16)
// Task Stats Section
if taskMetrics.totalCount > 0 {
HStack(spacing: 0) {
// Total Tasks
TaskStatItem(
value: taskMetrics.totalCount,
label: "Tasks",
color: Color.appPrimary
)
// Overdue
TaskStatItem(
value: taskMetrics.overdueCount,
label: "Overdue",
color: taskMetrics.overdueCount > 0 ? Color.appError : Color.appTextSecondary
)
// Due Next 7 Days
TaskStatItem(
value: taskMetrics.upcoming7Days,
label: "7 Days",
color: Color.appAccent
)
// Next 30 Days
TaskStatItem(
value: taskMetrics.upcoming30Days,
label: "30 Days",
color: Color.appPrimary.opacity(0.7)
)
}
.padding(.horizontal, OrganicSpacing.cozy)
.padding(.vertical, 14)
} 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)
}
}
.background(CardBackgroundView(hasOverdue: hasOverdueTasks))
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
.naturalShadow(.medium)
.accessibilityElement(children: .combine)
.accessibilityLabel({
var parts = [residence.name]
if let propertyTypeName = residence.propertyTypeName {
parts.append(propertyTypeName)
}
if !residence.streetAddress.isEmpty {
parts.append(residence.streetAddress)
}
if taskMetrics.totalCount > 0 {
parts.append("\(taskMetrics.totalCount) tasks")
}
if residence.isPrimary {
parts.append("Primary property")
}
return parts.joined(separator: ", ")
}())
}
}
// MARK: - Property Icon View
private struct PropertyIconView: View {
let hasOverdue: Bool
var body: some View {
ZStack {
// Theme-colored background
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.appPrimary,
Color.appPrimary.opacity(0.85)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 56, height: 56)
// House icon
Image("outline")
.resizable()
.scaledToFit()
.frame(width: 48, height: 48)
.foregroundColor(Color.appTextOnPrimary)
}
.naturalShadow(.subtle)
}
}
// 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)
}
.accessibilityLabel("Primary property")
}
}
// MARK: - Task Stat Item
private struct TaskStatItem: View {
let value: Int
let label: String
let color: Color
var body: some View {
HStack(spacing: 4) {
Text("\(value)")
.font(.system(size: 15, weight: .bold, design: .rounded))
.foregroundColor(color)
Text(label)
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
.frame(maxWidth: .infinity)
}
}
// 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,
completionSummary: nil,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),
taskMetrics: WidgetDataManager.TaskMetrics(totalCount: 8, overdueCount: 2, upcoming7Days: 3, upcoming30Days: 2)
)
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,
completionSummary: nil,
heatingType: nil,
coolingType: nil,
waterHeaterType: nil,
roofType: nil,
hasPool: false,
hasSprinklerSystem: false,
hasSeptic: false,
hasFireplace: false,
hasGarage: false,
hasBasement: false,
hasAttic: false,
exteriorType: nil,
flooringPrimary: nil,
landscapingType: nil,
createdAt: "2024-01-01T00:00:00Z",
updatedAt: "2024-01-01T00:00:00Z"
),
taskMetrics: WidgetDataManager.TaskMetrics(totalCount: 0, overdueCount: 0, upcoming7Days: 0, upcoming30Days: 0)
)
}
.padding(.horizontal, 16)
.padding(.vertical, 24)
}
.background(WarmGradientBackground())
}