f5f02145a2
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>
971 lines
36 KiB
Swift
971 lines
36 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
|
|
|
|
@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: {}
|
|
)
|
|
}
|