Add regional task templates to onboarding with multiple bug fixes
- Fetch regional templates by ZIP during onboarding and display categorized task suggestions (iOS + KMM shared layer) - Fix multi-expand for task categories (toggle independently, not exclusive) - Fix ScrollViewReader auto-scroll to expanded category sections - Fix UUID stability bug: cache task categories to prevent ID regeneration that caused silent task creation failures - Fix stale data after onboarding: force refresh residences and tasks in RootView onComplete callback - Fix address formatting: show just ZIP code when city/state are empty instead of showing ", 75028" with leading comma Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
251
iosApp/iosApp/Onboarding/OnboardingLocationView.swift
Normal file
251
iosApp/iosApp/Onboarding/OnboardingLocationView.swift
Normal file
@@ -0,0 +1,251 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Screen: ZIP code entry for regional task templates - Content only (no navigation bar)
|
||||
struct OnboardingLocationContent: View {
|
||||
var onLocationDetected: (String) -> Void
|
||||
var onSkip: () -> Void
|
||||
|
||||
@State private var zipCode: String = ""
|
||||
@State private var isAnimating = false
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
private var isValid: Bool {
|
||||
zipCode.count == 5 && zipCode.allSatisfy(\.isNumber)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
WarmGradientBackground()
|
||||
|
||||
// Decorative blobs
|
||||
GeometryReader { geo in
|
||||
OrganicBlobShape(variation: 1)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appPrimary.opacity(0.06),
|
||||
Color.appPrimary.opacity(0.01),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.3
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.5, height: geo.size.height * 0.25)
|
||||
.offset(x: -geo.size.width * 0.1, y: geo.size.height * 0.1)
|
||||
.blur(radius: 20)
|
||||
|
||||
OrganicBlobShape(variation: 2)
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Color.appAccent.opacity(0.05),
|
||||
Color.appAccent.opacity(0.01),
|
||||
Color.clear
|
||||
],
|
||||
center: .center,
|
||||
startRadius: 0,
|
||||
endRadius: geo.size.width * 0.25
|
||||
)
|
||||
)
|
||||
.frame(width: geo.size.width * 0.4, height: geo.size.height * 0.2)
|
||||
.offset(x: geo.size.width * 0.65, y: geo.size.height * 0.75)
|
||||
.blur(radius: 15)
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Content
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
// Animated location icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [Color.appPrimary.opacity(0.15), Color.clear],
|
||||
center: .center,
|
||||
startRadius: 30,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.offset(x: -20, y: -20)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
isAnimating
|
||||
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
Circle()
|
||||
.fill(
|
||||
RadialGradient(
|
||||
colors: [Color.appAccent.opacity(0.15), Color.clear],
|
||||
center: .center,
|
||||
startRadius: 30,
|
||||
endRadius: 80
|
||||
)
|
||||
)
|
||||
.frame(width: 160, height: 160)
|
||||
.offset(x: 20, y: 20)
|
||||
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||
.animation(
|
||||
isAnimating
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
// Main icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appSecondary],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 90, height: 90)
|
||||
|
||||
Image(systemName: "location.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
// Title
|
||||
VStack(spacing: 12) {
|
||||
Text("Where's your home?")
|
||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text("Enter your ZIP code so we can suggest\nmaintenance tasks for your climate region.")
|
||||
.font(.system(size: 15, weight: .medium))
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineSpacing(4)
|
||||
}
|
||||
|
||||
// ZIP code input
|
||||
VStack(alignment: .center, spacing: 12) {
|
||||
HStack(spacing: 14) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary.opacity(0.15), Color.appAccent.opacity(0.1)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [Color.appPrimary, Color.appAccent],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
TextField("12345", text: $zipCode)
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
.keyboardType(.numberPad)
|
||||
.focused($isTextFieldFocused)
|
||||
.multilineTextAlignment(.center)
|
||||
.onChange(of: zipCode) { _, newValue in
|
||||
// Only allow digits, max 5
|
||||
let filtered = String(newValue.filter(\.isNumber).prefix(5))
|
||||
if filtered != newValue {
|
||||
zipCode = filtered
|
||||
}
|
||||
}
|
||||
|
||||
if !zipCode.isEmpty {
|
||||
Button(action: { zipCode = "" }) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 20))
|
||||
.foregroundColor(Color.appTextSecondary.opacity(0.5))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
.frame(maxWidth: 240)
|
||||
.background(
|
||||
ZStack {
|
||||
Color.appBackgroundSecondary
|
||||
GrainTexture(opacity: 0.01)
|
||||
}
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(
|
||||
isTextFieldFocused
|
||||
? LinearGradient(colors: [Color.appPrimary, Color.appAccent], startPoint: .leading, endPoint: .trailing)
|
||||
: LinearGradient(colors: [Color.appTextSecondary.opacity(0.2), Color.appTextSecondary.opacity(0.2)], startPoint: .leading, endPoint: .trailing),
|
||||
lineWidth: 2
|
||||
)
|
||||
)
|
||||
.naturalShadow(isTextFieldFocused ? .medium : .subtle)
|
||||
.animation(.easeInOut(duration: 0.2), value: isTextFieldFocused)
|
||||
}
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Continue button
|
||||
Button(action: { onLocationDetected(zipCode) }) {
|
||||
HStack(spacing: 10) {
|
||||
Text("Continue")
|
||||
.font(.system(size: 17, weight: .bold))
|
||||
|
||||
Image(systemName: "arrow.right")
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 56)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.background(
|
||||
isValid
|
||||
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
|
||||
: AnyShapeStyle(Color.appTextSecondary.opacity(0.4))
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
.naturalShadow(isValid ? .medium : .subtle)
|
||||
}
|
||||
.disabled(!isValid)
|
||||
.padding(.horizontal, OrganicSpacing.comfortable)
|
||||
.padding(.bottom, OrganicSpacing.airy)
|
||||
.animation(.easeInOut(duration: 0.2), value: isValid)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
OnboardingLocationContent(
|
||||
onLocationDetected: { _ in },
|
||||
onSkip: {}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user