Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingLocationView.swift
Trey T af73f8861b iOS VoiceOver accessibility overhaul — 67 files
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
2026-03-26 14:51:29 -05:00

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: {}
)
}