Files
honeyDueKMP/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift
T
Trey t 87771ef7f3 test: add accessibility identifiers along the onboarding-to-residence-detail path
Scaffolding for the gitea#2 regression XCUITest. No user-visible
change — pure metadata for UI automation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:30:58 -07:00

993 lines
37 KiB
Swift

import SwiftUI
import ComposeApp
/// Tab selection for task browsing
enum OnboardingTaskTab: String, CaseIterable {
case forYou = "For You"
case browse = "Browse All"
}
/// First-task onboarding content pure server-driven, no hardcoded catalog.
///
/// The "For You" tab shows scored suggestions from
/// `GET /api/tasks/suggestions/`. The "Browse All" tab shows the full
/// template catalog from `GET /api/tasks/templates/grouped/`. Both tabs
/// render loading, error, and empty states with a Retry + Skip option
/// if the network is flaky, users can still finish onboarding and add
/// tasks later.
///
/// Task creation goes through the single transactional bulk endpoint,
/// so either every selection lands or none do.
struct OnboardingFirstTaskContent: View {
var residenceName: String
var onTaskAdded: () -> Void
@StateObject private var vm: OnboardingTasksViewModel
@ObservedObject private var onboardingState = OnboardingState.shared
/// Default init used by the app. The VM starts empty and loads via
/// `loadSuggestions` / `loadGrouped` in `.task`.
init(residenceName: String, onTaskAdded: @escaping () -> Void) {
self.residenceName = residenceName
self.onTaskAdded = onTaskAdded
self._vm = StateObject(wrappedValue: OnboardingTasksViewModel())
}
/// Snapshot-test / preview init that accepts a pre-seeded VM.
/// Passing a VM built with `initialSuggestions: <fixture>` lets the
/// "For You" tab render populated content on the first composition
/// frame, bypassing the APILayer round-trip that fails hermetically.
init(
residenceName: String,
onTaskAdded: @escaping () -> Void,
viewModel: OnboardingTasksViewModel
) {
self.residenceName = residenceName
self.onTaskAdded = onTaskAdded
self._vm = StateObject(wrappedValue: viewModel)
}
@State private var selectedIds: Set<Int32> = []
@State private var selectedTab: OnboardingTaskTab = .forYou
@State private var expandedCategoryIds: Set<Int32> = []
@State private var isAnimating = false
@Environment(\.colorScheme) var colorScheme
private var selectedCount: Int { selectedIds.count }
/// Category colour palette, rotated deterministically by category id so
/// each category card has visual distinction without coupling to names.
private static let categoryPalette: [Color] = [
.appPrimary,
.appAccent,
.appSecondary,
Color(hex: "#34C759") ?? .green,
Color(hex: "#AF52DE") ?? .purple
]
/// Icon derivation hand-picked SF Symbols bucketed by category-name
/// keyword. Falls back to `wrench.and.screwdriver.fill`.
private static func icon(for name: String) -> String {
let lower = name.lowercased()
if lower.contains("hvac") || lower.contains("climate") { return "thermometer.medium" }
if lower.contains("safety") || lower.contains("security") { return "shield.checkered" }
if lower.contains("plumb") || lower.contains("water") { return "drop.fill" }
if lower.contains("outdoor") || lower.contains("yard") || lower.contains("landscap") { return "leaf.fill" }
if lower.contains("appliance") || lower.contains("kitchen") { return "refrigerator.fill" }
if lower.contains("exterior") || lower.contains("roof") { return "house.lodge.fill" }
if lower.contains("interior") { return "house.fill" }
if lower.contains("electric") { return "bolt.fill" }
if lower.contains("clean") { return "sparkles" }
return "wrench.and.screwdriver.fill"
}
private static func color(for categoryId: Int32?) -> Color {
let palette = Self.categoryPalette
let id = categoryId ?? 0
let index = Int(abs(Int64(id))) % palette.count
return palette[index]
}
/// Groups built from the server's grouped-templates response. Empty
/// until the catalog loads; the view shows its own loading / error
/// states so an empty array here is safe to render as nothing.
private var browseCategories: [OnboardingTaskCategory] {
guard let grouped = vm.grouped else { return [] }
return grouped.categories.map { group in
// Fall back to a negative hash-derived id for "Uncategorized"
// so the view's Identifiable conformance stays stable without
// colliding with any real category id.
let rawId = group.categoryId?.int32Value
let stableId: Int32 = rawId ?? Int32(truncatingIfNeeded: abs(group.categoryName.hashValue)) * -1
return OnboardingTaskCategory(
id: stableId,
name: group.categoryName.isEmpty ? "Uncategorized" : group.categoryName,
icon: Self.icon(for: group.categoryName),
color: Self.color(for: rawId),
tasks: group.templates.map { Self.template(from: $0, categoryName: group.categoryName) }
)
}
}
/// Flat template lookup keyed by backend id. Drives the submit path
/// without caring which tab supplied the selection.
private var templateLookup: [Int32: OnboardingTaskTemplate] {
var map: [Int32: OnboardingTaskTemplate] = [:]
for category in browseCategories {
for template in category.tasks { map[template.id] = template }
}
for suggestion in vm.suggestions {
let t = suggestion.template
if map[t.id] == nil {
map[t.id] = Self.template(
from: t,
categoryName: t.category?.name ?? "Suggested"
)
}
}
return map
}
private static func template(from t: TaskTemplate, categoryName: String) -> OnboardingTaskTemplate {
OnboardingTaskTemplate(
id: t.id,
title: t.title,
description: t.description_.isEmpty ? nil : t.description_,
categoryId: t.categoryId?.int32Value,
frequencyId: t.frequencyId?.int32Value,
frequencyLabel: t.frequency?.displayName ?? "One time",
icon: t.iconIos.isEmpty ? Self.icon(for: categoryName) : t.iconIos,
color: Self.color(for: t.categoryId?.int32Value)
)
}
var body: some View {
ZStack {
WarmGradientBackground()
.a11yDecorative()
backgroundBlobs
.a11yDecorative()
VStack(spacing: 0) {
ScrollView(showsIndicators: false) {
VStack(spacing: OrganicSpacing.comfortable) {
headerBlock
selectionChip
OnboardingTaskTabBar(selectedTab: $selectedTab)
.padding(.horizontal, OrganicSpacing.comfortable)
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.firstTaskTabBar)
switch selectedTab {
case .forYou:
forYouTab
.padding(.horizontal, OrganicSpacing.comfortable)
case .browse:
browseTab
.padding(.horizontal, OrganicSpacing.comfortable)
}
}
.padding(.bottom, 140) // Space for bottom action
}
bottomActionArea
}
if let submitError = vm.submitError {
bannerError(submitError)
}
}
.onAppear {
isAnimating = true
Task { await loadInitial() }
}
.onDisappear {
isAnimating = false
}
}
// MARK: - Sub-views
private var headerBlock: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(
RadialGradient(
colors: [Color.appPrimary.opacity(0.15), Color.clear],
center: .center,
startRadius: 30,
endRadius: 80
)
)
.frame(width: 140, height: 140)
.offset(x: -15, y: -15)
.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: 140, height: 140)
.offset(x: 15, y: 15)
ZStack {
Circle()
.fill(
LinearGradient(
colors: [Color.appPrimary, Color.appSecondary],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 80, height: 80)
Image(systemName: "party.popper.fill")
.font(.system(size: 36))
.foregroundColor(.white)
}
.naturalShadow(.pronounced)
}
Text("You're all set up!")
.font(.system(size: 26, weight: .bold, design: .rounded))
.foregroundColor(Color.appTextPrimary)
.a11yHeader()
Text("Let's get you started with some tasks.\nThe more you pick, the more we'll help you remember!")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(4)
}
.padding(.top, OrganicSpacing.comfortable)
}
private var selectionChip: some View {
HStack(spacing: 8) {
Image(systemName: selectedCount > 0 ? "checkmark.seal.fill" : "checkmark.circle.fill")
.foregroundColor(selectedCount > 0 ? Color.appAccent : Color.appPrimary)
Text("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
.padding(.horizontal, 18)
.padding(.vertical, 10)
.background(Color.appPrimary.opacity(0.1))
.clipShape(Capsule())
.animation(.spring(response: 0.3), value: selectedCount)
.accessibilityLabel("\(selectedCount) task\(selectedCount == 1 ? "" : "s") selected")
}
@ViewBuilder
private var forYouTab: some View {
if vm.isLoadingSuggestions {
OnboardingLoadingPane(message: "Finding tasks for your home...")
} else if let errorMessage = vm.suggestionsError {
OnboardingErrorPane(
headline: "Couldn't load your suggestions",
message: errorMessage,
retry: retrySuggestions,
skip: { skip(reason: "network_error_for_you") },
secondary: vm.grouped != nil ? .init(label: "Browse All", action: { selectedTab = .browse }) : nil
)
} else if vm.suggestions.isEmpty && vm.suggestionsAttempted {
OnboardingEmptyPane(
message: "No personalised suggestions yet — browse the full catalog or skip this step.",
primary: .init(label: vm.grouped != nil ? "Browse All" : "Skip", action: {
if vm.grouped != nil {
selectedTab = .browse
} else {
skip(reason: "no_suggestions_no_catalog")
}
})
)
} else {
VStack(spacing: 0) {
ForEach(vm.suggestions, id: \.template.id) { suggestion in
let isSelected = selectedIds.contains(suggestion.template.id)
OnboardingSuggestionRow(
suggestion: suggestion,
isSelected: isSelected,
onTap: {
toggleSuggestion(suggestion, wasSelected: isSelected)
}
)
if suggestion.template.id != vm.suggestions.last?.template.id {
Divider().padding(.leading, 60)
}
}
}
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
.naturalShadow(.subtle)
}
}
@ViewBuilder
private var browseTab: some View {
if vm.isLoadingGrouped && vm.grouped == nil {
OnboardingLoadingPane(message: "Loading the task catalog...")
} else if let errorMessage = vm.groupedError, vm.grouped == nil {
OnboardingErrorPane(
headline: "Couldn't load the task catalog",
message: errorMessage,
retry: retryGrouped,
skip: { skip(reason: "network_error_browse") }
)
} else if browseCategories.isEmpty {
OnboardingEmptyPane(
message: "No templates available right now.",
primary: .init(label: "Skip", action: { skip(reason: "empty_catalog") })
)
} else {
VStack(spacing: 12) {
ForEach(browseCategories) { category in
OnboardingCategorySection(
category: category,
selectedIds: selectedIds,
isExpanded: expandedCategoryIds.contains(category.id),
onToggleExpand: {
withAnimation(.spring(response: 0.3)) {
if expandedCategoryIds.contains(category.id) {
expandedCategoryIds.remove(category.id)
} else {
expandedCategoryIds.insert(category.id)
}
}
},
onToggleTask: { template in
toggleBrowse(template)
}
)
}
}
}
}
private var bottomActionArea: some View {
VStack(spacing: 14) {
Button(action: submit) {
HStack(spacing: 10) {
if vm.isSubmitting {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
} else {
Text(selectedCount > 0
? "Add \(selectedCount) Task\(selectedCount == 1 ? "" : "s") & Continue"
: "Skip for Now")
.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(
selectedCount > 0
? AnyShapeStyle(LinearGradient(colors: [Color.appPrimary, Color.appSecondary], startPoint: .leading, endPoint: .trailing))
: AnyShapeStyle(Color.appTextSecondary.opacity(0.5))
)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
.naturalShadow(selectedCount > 0 ? .medium : .subtle)
}
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.submitTasksButton)
.disabled(vm.isSubmitting)
.animation(.easeInOut(duration: 0.2), value: selectedCount)
}
.padding(.horizontal, OrganicSpacing.comfortable)
.padding(.bottom, OrganicSpacing.airy)
}
private var backgroundBlobs: some View {
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)
}
}
private func bannerError(_ message: String) -> some View {
VStack {
Spacer()
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(Color.appError.opacity(0.95))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.padding(.horizontal, 16)
.padding(.bottom, 80)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
// MARK: - Actions
private func loadInitial() async {
// Always hit the catalog endpoint both tabs benefit (For You falls
// back to "Browse All" button on empty suggestions).
async let groupedTask: Void = vm.loadGrouped()
if let residenceId = onboardingState.createdResidenceId {
async let suggestionsTask: Void = vm.loadSuggestions(residenceId: residenceId)
_ = await (groupedTask, suggestionsTask)
} else {
_ = await groupedTask
}
// Expand the first category by default once the catalog resolves.
if expandedCategoryIds.isEmpty, let first = browseCategories.first {
expandedCategoryIds.insert(first.id)
}
}
private func retrySuggestions() {
guard let residenceId = onboardingState.createdResidenceId else {
skip(reason: "no_residence")
return
}
Task { await vm.loadSuggestions(residenceId: residenceId) }
}
private func retryGrouped() {
Task { await vm.loadGrouped(forceRefresh: true) }
}
private func skip(reason: String) {
AnalyticsManager.shared.track(.onboardingTaskStepSkipped(reason: reason))
onTaskAdded()
}
private func toggleSuggestion(_ suggestion: TaskSuggestionResponse, wasSelected: Bool) {
withAnimation(.spring(response: 0.2)) {
let id = suggestion.template.id
if wasSelected {
selectedIds.remove(id)
} else {
selectedIds.insert(id)
AnalyticsManager.shared.track(.onboardingSuggestionAccepted(
templateId: id,
relevanceScore: suggestion.relevanceScore
))
}
}
}
private func toggleBrowse(_ template: OnboardingTaskTemplate) {
withAnimation(.spring(response: 0.2)) {
if selectedIds.contains(template.id) {
selectedIds.remove(template.id)
} else {
selectedIds.insert(template.id)
AnalyticsManager.shared.track(.onboardingBrowseTemplateAccepted(
templateId: template.id,
categoryId: template.categoryId
))
}
}
}
private func submit() {
// Zero selection pure skip path, no network call.
if selectedIds.isEmpty {
skip(reason: "user_skip")
return
}
guard let residenceId = onboardingState.createdResidenceId else {
// No residence means onboarding partially failed earlier; just
// advance so the user isn't stuck.
skip(reason: "no_residence")
return
}
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd"
let todayString = dateFormatter.string(from: Date())
let lookup = templateLookup
let requests: [TaskCreateRequest] = selectedIds.compactMap { id in
guard let template = lookup[id] else { return nil }
return TaskCreateRequest(
residenceId: residenceId,
title: template.title,
description: template.description,
categoryId: template.categoryId.map { KotlinInt(int: $0) },
priorityId: nil,
inProgress: false,
frequencyId: template.frequencyId.map { KotlinInt(int: $0) },
customIntervalDays: nil,
assignedToId: nil,
dueDate: todayString,
estimatedCost: nil,
contractorId: nil,
templateId: KotlinInt(int: template.id)
)
}
Task {
let ok = await vm.submit(residenceId: residenceId, requests: requests)
if ok {
onTaskAdded()
}
// On failure vm.submitError is displayed by the overlay banner;
// the user can retry by tapping the button again.
}
}
}
// MARK: - Adapter models
struct OnboardingTaskCategory: Identifiable {
let id: Int32
let name: String
let icon: String
let color: Color
let tasks: [OnboardingTaskTemplate]
}
struct OnboardingTaskTemplate: Identifiable {
let id: Int32 // backend TaskTemplate.id sent as template_id
let title: String
let description: String?
let categoryId: Int32?
let frequencyId: Int32?
let frequencyLabel: String
let icon: String
let color: Color
}
// MARK: - Tab bar
private struct OnboardingTaskTabBar: View {
@Binding var selectedTab: OnboardingTaskTab
var body: some View {
Picker("", selection: $selectedTab) {
ForEach(OnboardingTaskTab.allCases, id: \.self) { tab in
Text(tab.rawValue).tag(tab)
}
}
.pickerStyle(.segmented)
}
}
// MARK: - Suggestion row (For You)
private struct OnboardingSuggestionRow: View {
let suggestion: TaskSuggestionResponse
let isSelected: Bool
var onTap: () -> Void
private var relevancePercent: Int {
Int((suggestion.relevanceScore * 100).rounded())
}
var body: some View {
Button(action: onTap) {
HStack(spacing: 14) {
ZStack {
Circle()
.stroke(isSelected ? Color.appPrimary : Color.appTextSecondary.opacity(0.3), lineWidth: 2)
.frame(width: 28, height: 28)
if isSelected {
Circle()
.fill(Color.appPrimary)
.frame(width: 28, height: 28)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(suggestion.template.title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Text(suggestion.template.frequencyDisplay)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
if let reason = suggestion.matchReasons.first {
Text(reason)
.font(.system(size: 11, weight: .semibold))
.foregroundColor(Color.appPrimary.opacity(0.85))
}
}
Spacer()
Text("\(relevancePercent)%")
.font(.system(size: 11, weight: .bold))
.foregroundColor(Color.appPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.appPrimary.opacity(0.12))
.clipShape(Capsule())
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(suggestion.template.id)")
.accessibilityLabel("\(suggestion.template.title), \(suggestion.template.frequencyDisplay), \(relevancePercent)% match")
.accessibilityValue(isSelected ? "selected" : "not selected")
}
}
// MARK: - Category section (Browse)
private struct OnboardingCategorySection: View {
let category: OnboardingTaskCategory
let selectedIds: Set<Int32>
let isExpanded: Bool
var onToggleExpand: () -> Void
var onToggleTask: (OnboardingTaskTemplate) -> Void
private var selectedInCategory: Int {
category.tasks.filter { selectedIds.contains($0.id) }.count
}
var body: some View {
VStack(spacing: 0) {
Button(action: onToggleExpand) {
HStack(spacing: 14) {
ZStack {
Circle()
.fill(
LinearGradient(
colors: [category.color, category.color.opacity(0.7)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: 44, height: 44)
Image(systemName: category.icon)
.font(.system(size: 18, weight: .medium))
.foregroundColor(.white)
}
.naturalShadow(.subtle)
Text(category.name)
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
Spacer()
if selectedInCategory > 0 {
Text("\(selectedInCategory)")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
.frame(width: 24, height: 24)
.background(category.color)
.clipShape(Circle())
}
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
}
.padding(14)
.background(Color.appBackgroundSecondary)
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 18,
bottomLeadingRadius: isExpanded ? 0 : 18,
bottomTrailingRadius: isExpanded ? 0 : 18,
topTrailingRadius: 18,
style: .continuous
)
)
}
.buttonStyle(.plain)
if isExpanded {
VStack(spacing: 0) {
ForEach(category.tasks) { task in
let isSelected = selectedIds.contains(task.id)
OnboardingTemplateRow(
template: task,
isSelected: isSelected,
categoryColor: category.color,
onTap: { onToggleTask(task) }
)
if task.id != category.tasks.last?.id {
Divider().padding(.leading, 60)
}
}
}
.background(Color.appBackgroundSecondary.opacity(0.5))
.clipShape(
UnevenRoundedRectangle(
topLeadingRadius: 0,
bottomLeadingRadius: 18,
bottomTrailingRadius: 18,
topTrailingRadius: 0,
style: .continuous
)
)
}
}
.naturalShadow(.subtle)
}
}
private struct OnboardingTemplateRow: View {
let template: OnboardingTaskTemplate
let isSelected: Bool
let categoryColor: Color
var onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 14) {
ZStack {
Circle()
.stroke(isSelected ? categoryColor : Color.appTextSecondary.opacity(0.3), lineWidth: 2)
.frame(width: 28, height: 28)
if isSelected {
Circle()
.fill(categoryColor)
.frame(width: 28, height: 28)
Image(systemName: "checkmark")
.font(.system(size: 12, weight: .bold))
.foregroundColor(.white)
}
}
VStack(alignment: .leading, spacing: 2) {
Text(template.title)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextPrimary)
Text(template.frequencyLabel)
.font(.system(size: 12, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
Spacer()
Image(systemName: template.icon)
.font(.system(size: 18, weight: .medium))
.foregroundColor(categoryColor.opacity(0.6))
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityIdentifier("\(AccessibilityIdentifiers.Onboarding.templateRowPrefix).\(template.id)")
.accessibilityLabel("\(template.title), \(template.frequencyLabel)")
.accessibilityValue(isSelected ? "selected" : "not selected")
}
}
// MARK: - Shared panes
struct OnboardingSecondaryAction {
let label: String
let action: () -> Void
}
private struct OnboardingLoadingPane: View {
let message: String
var body: some View {
VStack(spacing: 16) {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: Color.appPrimary))
.scaleEffect(1.2)
Text(message)
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}
private struct OnboardingErrorPane: View {
let headline: String
let message: String
var retry: () -> Void
var skip: () -> Void
var secondary: OnboardingSecondaryAction?
var body: some View {
VStack(spacing: 14) {
ZStack {
Circle()
.fill(Color.appError.opacity(0.1))
.frame(width: 64, height: 64)
Image(systemName: "wifi.exclamationmark")
.font(.system(size: 28))
.foregroundColor(Color.appError)
}
Text(headline)
.font(.system(size: 17, weight: .semibold))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
HStack(spacing: 10) {
Button(action: skip) {
Text("Skip for now")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appTextSecondary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.appTextSecondary.opacity(0.08))
.clipShape(Capsule())
}
.buttonStyle(.plain)
if let secondary {
Button(action: secondary.action) {
Text(secondary.label)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.appPrimary.opacity(0.12))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
Button(action: retry) {
HStack(spacing: 6) {
Image(systemName: "arrow.clockwise")
.font(.system(size: 13, weight: .bold))
Text("Retry")
.font(.system(size: 14, weight: .bold))
}
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(Color.appPrimary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.padding(.horizontal, 20)
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.subtle)
}
}
private struct OnboardingEmptyPane: View {
let message: String
let primary: OnboardingSecondaryAction
var body: some View {
VStack(spacing: 16) {
ZStack {
Circle()
.fill(Color.appPrimary.opacity(0.1))
.frame(width: 64, height: 64)
Image(systemName: "tray")
.font(.system(size: 28))
.foregroundColor(Color.appPrimary)
}
Text(message)
.font(.system(size: 14, weight: .medium))
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.lineSpacing(3)
Button(action: primary.action) {
Text(primary.label)
.font(.system(size: 14, weight: .bold))
.foregroundColor(.white)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.background(Color.appPrimary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.padding(.horizontal, 20)
.background(Color.appBackgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.naturalShadow(.subtle)
}
}
// MARK: - Legacy wrapper with navigation bar
struct OnboardingFirstTaskView: View {
var residenceName: String
var onTaskAdded: () -> Void
var onSkip: () -> Void
var body: some View {
ZStack {
WarmGradientBackground()
VStack(spacing: 0) {
HStack {
Spacer()
Button(action: onSkip) {
Text("Skip")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.appTextSecondary)
}
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
OnboardingFirstTaskContent(
residenceName: residenceName,
onTaskAdded: onTaskAdded
)
}
}
}
}
#Preview {
OnboardingFirstTaskContent(
residenceName: "My Home",
onTaskAdded: {}
)
}