New framework: - AccessibilityLabels.swift: centralized A11y struct with VoiceOver strings - AccessibilityModifiers.swift: reusable .a11yHeader, .a11yDecorative, .a11yButton, .a11yCard, .a11yStatValue View extensions Shared components: decorative elements hidden, stat views combined, status/priority badges labeled, error views announced, empty states grouped Cards: ResidenceCard, TaskCard, DynamicTaskCard, ContractorCard, DocumentCard, WarrantyCard — all grouped with combined labels, chevrons hidden, action buttons labeled Main screens: Login, Register, Residences, Tasks, Contractors, Documents — toolbar buttons labeled, section headers marked, form field hints added Onboarding: all 10 views — header traits, button hints, task selection state, progress indicator, decorative backgrounds hidden Profile/Subscription: toggle hints, theme selection state, feature comparison table accessibility, subscription button labels iOS build verified: BUILD SUCCEEDED
256 lines
11 KiB
Swift
256 lines
11 KiB
Swift
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()
|
|
.a11yDecorative()
|
|
|
|
// 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)
|
|
}
|
|
.a11yDecorative()
|
|
|
|
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)
|
|
.a11yHeader()
|
|
|
|
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)
|
|
.accessibilityHint("Enter your ZIP code")
|
|
.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: {}
|
|
)
|
|
}
|