Add onboarding UI tests and improve app data management
- Add Suite0_OnboardingTests with fresh install and login test flows - Add accessibility identifiers to onboarding views for UI testing - Remove deprecated DataCache in favor of unified DataManager - Update API layer to support public upgrade-triggers endpoint - Improve onboarding first task view with better date handling - Update various views with accessibility identifiers for testing - Fix subscription feature comparison view layout - Update document detail view improvements 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ struct ContractorFormSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
|
||||
let contractor: Contractor?
|
||||
let onSave: () -> Void
|
||||
@@ -41,7 +42,7 @@ struct ContractorFormSheet: View {
|
||||
@FocusState private var focusedField: ContractorFormField?
|
||||
|
||||
private var specialties: [ContractorSpecialty] {
|
||||
return DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] ?? []
|
||||
return dataManager.contractorSpecialties
|
||||
}
|
||||
|
||||
private var canSave: Bool {
|
||||
|
||||
@@ -4,6 +4,7 @@ import ComposeApp
|
||||
struct ContractorsListView: View {
|
||||
@StateObject private var viewModel = ContractorViewModel()
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@State private var searchText = ""
|
||||
@State private var showingAddSheet = false
|
||||
@State private var selectedSpecialty: String? = nil
|
||||
@@ -11,8 +12,8 @@ struct ContractorsListView: View {
|
||||
@State private var showSpecialtyFilter = false
|
||||
@State private var showingUpgradePrompt = false
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var contractorSpecialties: [ContractorSpecialty] = []
|
||||
// Lookups from DataManagerObservable
|
||||
private var contractorSpecialties: [ContractorSpecialty] { dataManager.contractorSpecialties }
|
||||
|
||||
var specialties: [String] {
|
||||
contractorSpecialties.map { $0.name }
|
||||
@@ -171,9 +172,9 @@ struct ContractorsListView: View {
|
||||
}
|
||||
.onAppear {
|
||||
loadContractors()
|
||||
loadContractorSpecialties()
|
||||
}
|
||||
// No need for onChange on searchText - filtering is client-side
|
||||
// Contractor specialties are loaded from DataManagerObservable
|
||||
}
|
||||
|
||||
private func loadContractors(forceRefresh: Bool = false) {
|
||||
@@ -181,23 +182,6 @@ struct ContractorsListView: View {
|
||||
viewModel.loadContractors(forceRefresh: forceRefresh)
|
||||
}
|
||||
|
||||
private func loadContractorSpecialties() {
|
||||
Task {
|
||||
// Small delay to ensure DataCache is populated
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
await MainActor.run {
|
||||
if let specialties = DataCache.shared.contractorSpecialties.value as? [ContractorSpecialty] {
|
||||
self.contractorSpecialties = specialties
|
||||
print("✅ ContractorsList: Loaded \(specialties.count) contractor specialties")
|
||||
} else {
|
||||
print("❌ ContractorsList: Failed to load contractor specialties from DataCache")
|
||||
self.contractorSpecialties = []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleFavorite(_ id: Int32) {
|
||||
viewModel.toggleFavorite(id: id) { success in
|
||||
if success {
|
||||
|
||||
@@ -94,6 +94,10 @@ class DataManagerObservable: ObservableObject {
|
||||
await MainActor.run {
|
||||
self.authToken = token
|
||||
self.isAuthenticated = token != nil
|
||||
// Clear widget cache on logout
|
||||
if token == nil {
|
||||
WidgetDataManager.shared.clearCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,6 +168,10 @@ class DataManagerObservable: ObservableObject {
|
||||
for await tasks in DataManager.shared.allTasks {
|
||||
await MainActor.run {
|
||||
self.allTasks = tasks
|
||||
// Save to widget shared container
|
||||
if let tasks = tasks {
|
||||
WidgetDataManager.shared.saveTasks(from: tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,26 +357,52 @@ class DataManagerObservable: ObservableObject {
|
||||
// MARK: - Map Conversion Helpers
|
||||
|
||||
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
|
||||
/// Uses ObjectIdentifier-based iteration to avoid Swift bridging issues with KotlinInt keys
|
||||
private func convertIntMap<V>(_ kotlinMap: Any?) -> [Int32: V] {
|
||||
guard let map = kotlinMap as? [KotlinInt: V] else {
|
||||
guard let kotlinMap = kotlinMap else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var result: [Int32: V] = [:]
|
||||
for (key, value) in map {
|
||||
result[key.int32Value] = value
|
||||
|
||||
// Cast to NSDictionary to avoid Swift's strict type bridging
|
||||
// which can crash when iterating [KotlinInt: V] dictionaries
|
||||
let nsDict = kotlinMap as! NSDictionary
|
||||
|
||||
for key in nsDict.allKeys {
|
||||
guard let value = nsDict[key], let typedValue = value as? V else { continue }
|
||||
|
||||
// Extract the int value from whatever key type we have
|
||||
if let kotlinKey = key as? KotlinInt {
|
||||
result[kotlinKey.int32Value] = typedValue
|
||||
} else if let nsNumberKey = key as? NSNumber {
|
||||
result[nsNumberKey.int32Value] = typedValue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
||||
private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
|
||||
guard let map = kotlinMap as? [KotlinInt: [V]] else {
|
||||
guard let kotlinMap = kotlinMap else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
var result: [Int32: [V]] = [:]
|
||||
for (key, value) in map {
|
||||
result[key.int32Value] = value
|
||||
|
||||
let nsDict = kotlinMap as! NSDictionary
|
||||
|
||||
for key in nsDict.allKeys {
|
||||
guard let value = nsDict[key], let typedValue = value as? [V] else { continue }
|
||||
|
||||
if let kotlinKey = key as? KotlinInt {
|
||||
result[kotlinKey.int32Value] = typedValue
|
||||
} else if let nsNumberKey = key as? NSNumber {
|
||||
result[nsNumberKey.int32Value] = typedValue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,11 @@ struct DocumentDetailView: View {
|
||||
@State private var showImageViewer = false
|
||||
@State private var selectedImageIndex = 0
|
||||
@State private var deleteSucceeded = false
|
||||
@State private var isDownloading = false
|
||||
@State private var downloadProgress: Double = 0
|
||||
@State private var downloadError: String?
|
||||
@State private var downloadedFileURL: URL?
|
||||
@State private var showShareSheet = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
@@ -99,6 +104,87 @@ struct DocumentDetailView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let fileURL = downloadedFileURL {
|
||||
ShareSheet(activityItems: [fileURL])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Download File
|
||||
|
||||
private func downloadFile(document: Document) {
|
||||
guard let fileUrl = document.fileUrl else {
|
||||
downloadError = "No file URL available"
|
||||
return
|
||||
}
|
||||
|
||||
guard let token = TokenStorage.shared.getToken() else {
|
||||
downloadError = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
isDownloading = true
|
||||
downloadError = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
// Build full URL
|
||||
let baseURL = ApiClient.shared.getMediaBaseUrl()
|
||||
let fullURLString = baseURL + fileUrl
|
||||
|
||||
guard let url = URL(string: fullURLString) else {
|
||||
await MainActor.run {
|
||||
downloadError = "Invalid URL"
|
||||
isDownloading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create authenticated request
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
|
||||
// Download the file
|
||||
let (tempURL, response) = try await URLSession.shared.download(for: request)
|
||||
|
||||
// Check response status
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
guard (200...299).contains(httpResponse.statusCode) else {
|
||||
await MainActor.run {
|
||||
downloadError = "Download failed: HTTP \(httpResponse.statusCode)"
|
||||
isDownloading = false
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Determine filename
|
||||
let filename = document.title.replacingOccurrences(of: " ", with: "_") + "." + (document.fileType ?? "file")
|
||||
|
||||
// Move to a permanent location
|
||||
let documentsPath = FileManager.default.temporaryDirectory
|
||||
let destinationURL = documentsPath.appendingPathComponent(filename)
|
||||
|
||||
// Remove existing file if present
|
||||
try? FileManager.default.removeItem(at: destinationURL)
|
||||
|
||||
// Move downloaded file
|
||||
try FileManager.default.moveItem(at: tempURL, to: destinationURL)
|
||||
|
||||
await MainActor.run {
|
||||
downloadedFileURL = destinationURL
|
||||
isDownloading = false
|
||||
showShareSheet = true
|
||||
}
|
||||
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
downloadError = "Download failed: \(error.localizedDescription)"
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -290,18 +376,32 @@ struct DocumentDetailView: View {
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
// TODO: Download file
|
||||
downloadFile(document: document)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text(L10n.Documents.downloadFile)
|
||||
if isDownloading {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.scaleEffect(0.8)
|
||||
Text("Downloading...")
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
Text(L10n.Documents.downloadFile)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.background(isDownloading ? Color.appPrimary.opacity(0.7) : Color.appPrimary)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.disabled(isDownloading)
|
||||
|
||||
if let error = downloadError {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
@@ -424,3 +524,19 @@ struct DocumentDetailView: View {
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
var applicationActivities: [UIActivity]? = nil
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(
|
||||
activityItems: activityItems,
|
||||
applicationActivities: applicationActivities
|
||||
)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
@@ -172,6 +172,65 @@ struct AccessibilityIdentifiers {
|
||||
static let downloadButton = "DocumentDetail.DownloadButton"
|
||||
}
|
||||
|
||||
// MARK: - Onboarding
|
||||
struct Onboarding {
|
||||
// Welcome Screen
|
||||
static let welcomeTitle = "Onboarding.WelcomeTitle"
|
||||
static let startFreshButton = "Onboarding.StartFreshButton"
|
||||
static let joinExistingButton = "Onboarding.JoinExistingButton"
|
||||
static let loginButton = "Onboarding.LoginButton"
|
||||
|
||||
// Value Props Screen
|
||||
static let valuePropsTitle = "Onboarding.ValuePropsTitle"
|
||||
static let valuePropsNextButton = "Onboarding.ValuePropsNextButton"
|
||||
|
||||
// Name Residence Screen
|
||||
static let nameResidenceTitle = "Onboarding.NameResidenceTitle"
|
||||
static let residenceNameField = "Onboarding.ResidenceNameField"
|
||||
static let nameResidenceContinueButton = "Onboarding.NameResidenceContinueButton"
|
||||
|
||||
// Create Account Screen
|
||||
static let createAccountTitle = "Onboarding.CreateAccountTitle"
|
||||
static let appleSignInButton = "Onboarding.AppleSignInButton"
|
||||
static let emailSignUpExpandButton = "Onboarding.EmailSignUpExpandButton"
|
||||
static let usernameField = "Onboarding.UsernameField"
|
||||
static let emailField = "Onboarding.EmailField"
|
||||
static let passwordField = "Onboarding.PasswordField"
|
||||
static let confirmPasswordField = "Onboarding.ConfirmPasswordField"
|
||||
static let createAccountButton = "Onboarding.CreateAccountButton"
|
||||
static let loginLinkButton = "Onboarding.LoginLinkButton"
|
||||
|
||||
// Verify Email Screen
|
||||
static let verifyEmailTitle = "Onboarding.VerifyEmailTitle"
|
||||
static let verificationCodeField = "Onboarding.VerificationCodeField"
|
||||
static let verifyButton = "Onboarding.VerifyButton"
|
||||
|
||||
// Join Residence Screen
|
||||
static let joinResidenceTitle = "Onboarding.JoinResidenceTitle"
|
||||
static let shareCodeField = "Onboarding.ShareCodeField"
|
||||
static let joinResidenceButton = "Onboarding.JoinResidenceButton"
|
||||
|
||||
// First Task Screen
|
||||
static let firstTaskTitle = "Onboarding.FirstTaskTitle"
|
||||
static let taskSelectionCounter = "Onboarding.TaskSelectionCounter"
|
||||
static let addPopularTasksButton = "Onboarding.AddPopularTasksButton"
|
||||
static let addTasksContinueButton = "Onboarding.AddTasksContinueButton"
|
||||
static let taskCategorySection = "Onboarding.TaskCategorySection"
|
||||
static let taskTemplateRow = "Onboarding.TaskTemplateRow"
|
||||
|
||||
// Subscription Screen
|
||||
static let subscriptionTitle = "Onboarding.SubscriptionTitle"
|
||||
static let yearlyPlanCard = "Onboarding.YearlyPlanCard"
|
||||
static let monthlyPlanCard = "Onboarding.MonthlyPlanCard"
|
||||
static let startTrialButton = "Onboarding.StartTrialButton"
|
||||
static let continueWithFreeButton = "Onboarding.ContinueWithFreeButton"
|
||||
|
||||
// Navigation
|
||||
static let backButton = "Onboarding.BackButton"
|
||||
static let skipButton = "Onboarding.SkipButton"
|
||||
static let progressIndicator = "Onboarding.ProgressIndicator"
|
||||
}
|
||||
|
||||
// MARK: - Profile
|
||||
struct Profile {
|
||||
static let logoutButton = "Profile.LogoutButton"
|
||||
|
||||
@@ -16890,6 +16890,9 @@
|
||||
"Done" : {
|
||||
"comment" : "A button that dismisses an image viewer sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Downloading..." : {
|
||||
|
||||
},
|
||||
"Edit" : {
|
||||
"comment" : "A label for an edit action.",
|
||||
@@ -29458,10 +29461,6 @@
|
||||
},
|
||||
"Unarchive Task" : {
|
||||
|
||||
},
|
||||
"Upgrade to Pro" : {
|
||||
"comment" : "A button label that says \"Upgrade to Pro\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Upgrade to Pro for unlimited access" : {
|
||||
"comment" : "A description of the benefit of upgrading to the Pro plan.",
|
||||
|
||||
@@ -78,19 +78,6 @@ class LoginViewModel: ObservableObject {
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
}
|
||||
|
||||
// Prefetch all data for caching
|
||||
Task {
|
||||
do {
|
||||
print("Starting data prefetch...")
|
||||
let prefetchManager = DataPrefetchManager.Companion().getInstance()
|
||||
_ = try await prefetchManager.prefetchAllData()
|
||||
print("Data prefetch completed successfully")
|
||||
} catch {
|
||||
print("Data prefetch failed: \(error.localizedDescription)")
|
||||
// Don't block login on prefetch failure
|
||||
}
|
||||
}
|
||||
|
||||
// Call login success callback
|
||||
self.onLoginSuccess?(self.isVerified)
|
||||
} else if let error = result as? ApiResultError {
|
||||
|
||||
@@ -92,9 +92,14 @@ struct OnboardingCoordinator: View {
|
||||
isPrimary: KotlinBoolean(bool: true)
|
||||
)
|
||||
|
||||
residenceViewModel.createResidence(request: request) { success in
|
||||
print("🏠 ONBOARDING: Residence creation result: \(success ? "SUCCESS" : "FAILED")")
|
||||
residenceViewModel.createResidence(request: request) { (residence: ResidenceResponse?) in
|
||||
self.isCreatingResidence = false
|
||||
if let residence = residence {
|
||||
print("🏠 ONBOARDING: Residence created successfully with ID: \(residence.id)")
|
||||
self.onboardingState.createdResidenceId = residence.id
|
||||
} else {
|
||||
print("🏠 ONBOARDING: Residence creation FAILED")
|
||||
}
|
||||
// Navigate regardless of success - user can create residence later if needed
|
||||
self.goForward(to: step)
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountTitle)
|
||||
|
||||
Text("Your data will be synced across devices")
|
||||
.font(.subheadline)
|
||||
@@ -121,6 +122,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.emailSignUpExpandButton)
|
||||
} else {
|
||||
// Expanded form
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
@@ -188,6 +190,7 @@ struct OnboardingCreateAccountContent: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: isFormValid ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.createAccountButton)
|
||||
.disabled(!isFormValid || viewModel.isLoading)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
|
||||
@@ -7,6 +7,8 @@ struct OnboardingFirstTaskContent: View {
|
||||
var onTaskAdded: () -> Void
|
||||
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||
@State private var selectedTasks: Set<UUID> = []
|
||||
@State private var isCreatingTasks = false
|
||||
@State private var showCustomTaskSheet = false
|
||||
@@ -318,10 +320,9 @@ struct OnboardingFirstTaskContent: View {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the first residence from cache (just created during onboarding)
|
||||
guard let residences = DataCache.shared.residences.value as? [ResidenceResponse],
|
||||
let residence = residences.first else {
|
||||
print("🏠 ONBOARDING: No residence found in cache, skipping task creation")
|
||||
// Get the residence ID from OnboardingState (set during residence creation)
|
||||
guard let residenceId = onboardingState.createdResidenceId else {
|
||||
print("🏠 ONBOARDING: No residence ID found in OnboardingState, skipping task creation")
|
||||
onTaskAdded()
|
||||
return
|
||||
}
|
||||
@@ -337,27 +338,25 @@ struct OnboardingFirstTaskContent: View {
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let todayString = dateFormatter.string(from: Date())
|
||||
|
||||
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residence.id)")
|
||||
print("🏠 ONBOARDING: Creating \(totalCount) tasks for residence \(residenceId)")
|
||||
|
||||
for template in selectedTemplates {
|
||||
// Look up category ID from DataCache
|
||||
// Look up category ID from DataManager
|
||||
let categoryId: Int32? = {
|
||||
guard let categories = DataCache.shared.taskCategories.value as? [ComposeApp.TaskCategory] else { return nil }
|
||||
let categoryName = template.category.lowercased()
|
||||
return categories.first { $0.name.lowercased() == categoryName }?.id
|
||||
return dataManager.taskCategories.first { $0.name.lowercased() == categoryName }?.id
|
||||
}()
|
||||
|
||||
// Look up frequency ID from DataCache
|
||||
// Look up frequency ID from DataManager
|
||||
let frequencyId: Int32? = {
|
||||
guard let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency] else { return nil }
|
||||
let frequencyName = template.frequency.lowercased()
|
||||
return frequencies.first { $0.name.lowercased() == frequencyName }?.id
|
||||
return dataManager.taskFrequencies.first { $0.name.lowercased() == frequencyName }?.id
|
||||
}()
|
||||
|
||||
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
|
||||
|
||||
let request = TaskCreateRequest(
|
||||
residenceId: residence.id,
|
||||
residenceId: residenceId,
|
||||
title: template.title,
|
||||
description: nil,
|
||||
categoryId: categoryId.map { KotlinInt(int: $0) },
|
||||
|
||||
@@ -68,6 +68,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceTitle)
|
||||
|
||||
Text("Don't worry, nothing's written in stone here.\nYou can always change it later in the app.")
|
||||
.font(.subheadline)
|
||||
@@ -96,6 +97,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.textInputAutocapitalization(.words)
|
||||
.focused($isTextFieldFocused)
|
||||
.submitLabel(.continue)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.residenceNameField)
|
||||
.onSubmit {
|
||||
if isValid {
|
||||
onContinue()
|
||||
@@ -182,6 +184,7 @@ struct OnboardingNameResidenceContent: View {
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.shadow(color: isValid ? Color.appPrimary.opacity(0.4) : .clear, radius: 15, y: 8)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.nameResidenceContinueButton)
|
||||
.disabled(!isValid)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
|
||||
@@ -18,6 +18,9 @@ class OnboardingState: ObservableObject {
|
||||
/// The name of the residence being created during onboarding
|
||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
|
||||
|
||||
/// The ID of the residence created during onboarding (used for task creation)
|
||||
@Published var createdResidenceId: Int32? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing) - persisted
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||
|
||||
@@ -86,6 +89,7 @@ class OnboardingState: ObservableObject {
|
||||
hasCompletedOnboarding = true
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
}
|
||||
|
||||
@@ -94,6 +98,7 @@ class OnboardingState: ObservableObject {
|
||||
hasCompletedOnboarding = false
|
||||
isOnboardingActive = false
|
||||
pendingResidenceName = ""
|
||||
createdResidenceId = nil
|
||||
userIntent = .unknown
|
||||
currentStep = .welcome
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyEmailTitle)
|
||||
|
||||
Text("We sent a 6-digit code to your email address. Enter it below to verify your account.")
|
||||
.font(.subheadline)
|
||||
@@ -50,6 +51,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.keyboardType(.numberPad)
|
||||
.textContentType(.oneTimeCode)
|
||||
.focused($isCodeFieldFocused)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||
.onChange(of: viewModel.code) { _, newValue in
|
||||
// Limit to 6 digits
|
||||
if newValue.count > 6 {
|
||||
@@ -124,6 +126,7 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: viewModel.code.count == 6 ? Color.appPrimary.opacity(0.3) : .clear, radius: 10, y: 5)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verifyButton)
|
||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
.padding(.bottom, AppSpacing.xxxl)
|
||||
|
||||
@@ -28,6 +28,7 @@ struct OnboardingWelcomeView: View {
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
||||
|
||||
Text("Your home maintenance companion")
|
||||
.font(.title3)
|
||||
@@ -64,6 +65,7 @@ struct OnboardingWelcomeView: View {
|
||||
.cornerRadius(AppRadius.md)
|
||||
.shadow(color: Color.appPrimary.opacity(0.3), radius: 10, y: 5)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.startFreshButton)
|
||||
|
||||
// Secondary CTA - Join Existing
|
||||
Button(action: onJoinExisting) {
|
||||
@@ -80,6 +82,7 @@ struct OnboardingWelcomeView: View {
|
||||
.background(Color.appPrimary.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.joinExistingButton)
|
||||
|
||||
// Returning user login
|
||||
Button(action: {
|
||||
@@ -89,6 +92,7 @@ struct OnboardingWelcomeView: View {
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.loginButton)
|
||||
.padding(.top, AppSpacing.sm)
|
||||
}
|
||||
.padding(.horizontal, AppSpacing.xl)
|
||||
|
||||
@@ -15,13 +15,8 @@ class RegisterViewModel: ObservableObject {
|
||||
@Published var errorMessage: String?
|
||||
@Published var isRegistered: Bool = false
|
||||
|
||||
// MARK: - Private Properties
|
||||
private let tokenStorage: TokenStorageProtocol
|
||||
|
||||
// MARK: - Initialization
|
||||
init(tokenStorage: TokenStorageProtocol? = nil) {
|
||||
self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage()
|
||||
}
|
||||
init() {}
|
||||
|
||||
// MARK: - Public Methods
|
||||
func register() {
|
||||
@@ -54,16 +49,15 @@ class RegisterViewModel: ObservableObject {
|
||||
let request = RegisterRequest(username: username, email: email, password: password, firstName: nil, lastName: nil)
|
||||
let result = try await APILayer.shared.register(request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<AuthResponse>, let response = success.data {
|
||||
let token = response.token
|
||||
self.tokenStorage.saveToken(token: token)
|
||||
if let success = result as? ApiResultSuccess<AuthResponse>, let _ = success.data {
|
||||
// APILayer.register() now handles:
|
||||
// - Setting auth token in DataManager
|
||||
// - Storing token in TokenManager
|
||||
// - Initializing lookups
|
||||
|
||||
// Update AuthenticationManager - user is authenticated but NOT verified
|
||||
AuthenticationManager.shared.login(verified: false)
|
||||
|
||||
// Initialize lookups via APILayer after successful registration
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
|
||||
self.isRegistered = true
|
||||
self.isLoading = false
|
||||
} else if let error = result as? ApiResultError {
|
||||
|
||||
@@ -3,9 +3,10 @@ import ComposeApp
|
||||
|
||||
struct ResidenceDetailView: View {
|
||||
let residenceId: Int32
|
||||
|
||||
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@StateObject private var taskViewModel = TaskViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
|
||||
// Use TaskViewModel's state instead of local state
|
||||
private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse }
|
||||
@@ -15,7 +16,7 @@ struct ResidenceDetailView: View {
|
||||
@State private var contractors: [ContractorSummary] = []
|
||||
@State private var isLoadingContractors = false
|
||||
@State private var contractorsError: String?
|
||||
|
||||
|
||||
@State private var showAddTask = false
|
||||
@State private var showEditResidence = false
|
||||
@State private var showEditTask = false
|
||||
@@ -37,7 +38,7 @@ struct ResidenceDetailView: View {
|
||||
|
||||
// Check if current user is the owner of the residence
|
||||
private func isCurrentUserOwner(of residence: ResidenceResponse) -> Bool {
|
||||
guard let currentUser = ComposeApp.DataCache.shared.currentUser.value else {
|
||||
guard let currentUser = dataManager.currentUser else {
|
||||
return false
|
||||
}
|
||||
return Int(residence.ownerId) == Int(currentUser.id)
|
||||
|
||||
@@ -134,28 +134,52 @@ class ResidenceViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func createResidence(request: ResidenceCreateRequest, completion: @escaping (Bool) -> Void) {
|
||||
createResidence(request: request) { result in
|
||||
completion(result != nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a residence and returns the created residence on success
|
||||
func createResidence(request: ResidenceCreateRequest, completion: @escaping (ResidenceResponse?) -> Void) {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
print("🏠 ResidenceVM: Calling API...")
|
||||
let result = try await APILayer.shared.createResidence(request: request)
|
||||
print("🏠 ResidenceVM: Got result: \(String(describing: result))")
|
||||
|
||||
if result is ApiResultSuccess<ResidenceResponse> {
|
||||
self.isLoading = false
|
||||
// DataManager is updated by APILayer (including refreshMyResidences),
|
||||
// which updates DataManagerObservable, which updates our @Published
|
||||
// myResidences via Combine subscription
|
||||
completion(true)
|
||||
} else if let error = result as? ApiResultError {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
await MainActor.run {
|
||||
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||
print("🏠 ResidenceVM: Is ApiResultSuccess, data = \(String(describing: success.data))")
|
||||
if let residence = success.data {
|
||||
print("🏠 ResidenceVM: Got residence with id \(residence.id)")
|
||||
self.isLoading = false
|
||||
completion(residence)
|
||||
} else {
|
||||
print("🏠 ResidenceVM: success.data is nil")
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
}
|
||||
} else if let error = result as? ApiResultError {
|
||||
print("🏠 ResidenceVM: Is ApiResultError: \(error.message ?? "nil")")
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
} else {
|
||||
print("🏠 ResidenceVM: Unknown result type: \(type(of: result))")
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
completion(false)
|
||||
print("🏠 ResidenceVM: Exception: \(error)")
|
||||
await MainActor.run {
|
||||
self.errorMessage = error.localizedDescription
|
||||
self.isLoading = false
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,11 @@ struct ResidenceFormView: View {
|
||||
@Binding var isPresented: Bool
|
||||
var onSuccess: (() -> Void)?
|
||||
@StateObject private var viewModel = ResidenceViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@FocusState private var focusedField: Field?
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var residenceTypes: [ResidenceType] = []
|
||||
// Lookups from DataManagerObservable
|
||||
private var residenceTypes: [ResidenceType] { dataManager.residenceTypes }
|
||||
|
||||
// Form fields
|
||||
@State private var name: String = ""
|
||||
@@ -196,21 +197,10 @@ struct ResidenceFormView: View {
|
||||
|
||||
private func loadResidenceTypes() {
|
||||
Task {
|
||||
// Get residence types from DataCache via APILayer
|
||||
let result = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
||||
if let success = result as? ApiResultSuccess<NSArray>,
|
||||
let types = success.data as? [ResidenceType] {
|
||||
await MainActor.run {
|
||||
self.residenceTypes = types
|
||||
}
|
||||
} else {
|
||||
// Fallback to DataCache directly
|
||||
await MainActor.run {
|
||||
if let cached = DataCache.shared.residenceTypes.value as? [ResidenceType] {
|
||||
self.residenceTypes = cached
|
||||
}
|
||||
}
|
||||
}
|
||||
// Trigger residence types refresh if needed
|
||||
// Residence types are now loaded from DataManagerObservable
|
||||
// Just trigger a refresh if needed
|
||||
_ = try? await APILayer.shared.getResidenceTypes(forceRefresh: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,20 +26,22 @@ class AuthenticationManager: ObservableObject {
|
||||
|
||||
isAuthenticated = true
|
||||
|
||||
// Fetch current user to check verification status
|
||||
// Fetch current user and initialize lookups immediately for all authenticated users
|
||||
Task { @MainActor in
|
||||
do {
|
||||
// Initialize lookups right away for any authenticated user
|
||||
// This fetches /static_data/ and /upgrade-triggers/ at app start
|
||||
print("🚀 Initializing lookups at app start...")
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized on app launch")
|
||||
|
||||
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
|
||||
|
||||
if let success = result as? ApiResultSuccess<User> {
|
||||
self.isVerified = success.data?.verified ?? false
|
||||
|
||||
// Initialize lookups if verified
|
||||
// Verify subscription entitlements with backend for verified users
|
||||
if self.isVerified {
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized on app launch for verified user")
|
||||
|
||||
// Verify subscription entitlements with backend
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
}
|
||||
} else if result is ApiResultError {
|
||||
@@ -68,17 +70,11 @@ class AuthenticationManager: ObservableObject {
|
||||
func markVerified() {
|
||||
isVerified = true
|
||||
|
||||
// Initialize lookups after verification
|
||||
// Lookups are already initialized at app start or during login/register
|
||||
// Just verify subscription entitlements after user becomes verified
|
||||
Task {
|
||||
do {
|
||||
_ = try await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized after email verification")
|
||||
|
||||
// Verify subscription entitlements with backend
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
} catch {
|
||||
print("❌ Failed to initialize lookups after verification: \(error)")
|
||||
}
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
print("✅ Subscription entitlements verified after email verification")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
import StoreKit
|
||||
|
||||
struct FeatureComparisonView: View {
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
|
||||
@StateObject private var storeKit = StoreKitManager.shared
|
||||
@State private var showUpgradePrompt = false
|
||||
@State private var selectedProduct: Product?
|
||||
@State private var isProcessing = false
|
||||
@State private var errorMessage: String?
|
||||
@State private var showSuccessAlert = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
@@ -70,20 +77,65 @@ struct FeatureComparisonView: View {
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Upgrade Button
|
||||
Button(action: {
|
||||
// TODO: Trigger upgrade flow
|
||||
isPresented = false
|
||||
}) {
|
||||
Text("Upgrade to Pro")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
// Subscription Products
|
||||
if storeKit.isLoading {
|
||||
ProgressView()
|
||||
.tint(Color.appPrimary)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
} else if !storeKit.products.isEmpty {
|
||||
VStack(spacing: AppSpacing.md) {
|
||||
ForEach(storeKit.products, id: \.id) { product in
|
||||
SubscriptionButton(
|
||||
product: product,
|
||||
isSelected: selectedProduct?.id == product.id,
|
||||
isProcessing: isProcessing,
|
||||
onSelect: {
|
||||
selectedProduct = product
|
||||
handlePurchase(product)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
} else {
|
||||
// Fallback if products fail to load
|
||||
Button(action: {
|
||||
Task { await storeKit.loadProducts() }
|
||||
}) {
|
||||
Text("Retry Loading Products")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
.padding()
|
||||
.background(Color.appPrimary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundColor(Color.appError)
|
||||
Text(error)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appError)
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appError.opacity(0.1))
|
||||
.cornerRadius(AppRadius.md)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Restore Purchases
|
||||
Button(action: {
|
||||
handleRestore()
|
||||
}) {
|
||||
Text("Restore Purchases")
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, AppSpacing.xl)
|
||||
}
|
||||
}
|
||||
@@ -96,8 +148,121 @@ struct FeatureComparisonView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Subscription Active", isPresented: $showSuccessAlert) {
|
||||
Button("Done") {
|
||||
isPresented = false
|
||||
}
|
||||
} message: {
|
||||
Text("You now have full access to all Pro features!")
|
||||
}
|
||||
.task {
|
||||
await storeKit.loadProducts()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Purchase Handling
|
||||
|
||||
private func handlePurchase(_ product: Product) {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let transaction = try await storeKit.purchase(product)
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if transaction != nil {
|
||||
showSuccessAlert = true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
errorMessage = "Purchase failed: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRestore() {
|
||||
isProcessing = true
|
||||
errorMessage = nil
|
||||
|
||||
Task {
|
||||
await storeKit.restorePurchases()
|
||||
|
||||
await MainActor.run {
|
||||
isProcessing = false
|
||||
|
||||
if !storeKit.purchasedProductIDs.isEmpty {
|
||||
showSuccessAlert = true
|
||||
} else {
|
||||
errorMessage = "No purchases found to restore"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Button
|
||||
|
||||
struct SubscriptionButton: View {
|
||||
let product: Product
|
||||
let isSelected: Bool
|
||||
let isProcessing: Bool
|
||||
let onSelect: () -> Void
|
||||
|
||||
var isAnnual: Bool {
|
||||
product.id.contains("annual")
|
||||
}
|
||||
|
||||
var savingsText: String? {
|
||||
if isAnnual {
|
||||
return "Save 17%"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(product.displayName)
|
||||
.font(.headline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
|
||||
if let savings = savingsText {
|
||||
Text(savings)
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.appPrimary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isProcessing && isSelected {
|
||||
ProgressView()
|
||||
.tint(Color.appTextOnPrimary)
|
||||
} else {
|
||||
Text(product.displayPrice)
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundColor(Color.appTextOnPrimary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isAnnual ? Color.appPrimary : Color.appSecondary)
|
||||
.cornerRadius(AppRadius.md)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: AppRadius.md)
|
||||
.stroke(isAnnual ? Color.appAccent : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.disabled(isProcessing)
|
||||
}
|
||||
}
|
||||
|
||||
struct ComparisonRow: View {
|
||||
|
||||
@@ -32,6 +32,12 @@ struct SummaryCard: View {
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
SummaryStatView(
|
||||
icon: "calendar",
|
||||
value: "\(summary.totalOverdue)",
|
||||
label: "Over Due"
|
||||
)
|
||||
|
||||
SummaryStatView(
|
||||
icon: "calendar",
|
||||
value: "\(summary.tasksDueNextWeek)",
|
||||
|
||||
@@ -13,6 +13,7 @@ struct TaskFormView: View {
|
||||
let existingTask: TaskResponse? // nil for add mode, populated for edit mode
|
||||
@Binding var isPresented: Bool
|
||||
@StateObject private var viewModel = TaskViewModel()
|
||||
@ObservedObject private var dataManager = DataManagerObservable.shared
|
||||
@FocusState private var focusedField: TaskFormField?
|
||||
|
||||
private var isEditMode: Bool {
|
||||
@@ -32,12 +33,12 @@ struct TaskFormView: View {
|
||||
selectedStatus != nil
|
||||
}
|
||||
|
||||
// Lookups from DataCache
|
||||
@State private var taskCategories: [TaskCategory] = []
|
||||
@State private var taskFrequencies: [TaskFrequency] = []
|
||||
@State private var taskPriorities: [TaskPriority] = []
|
||||
@State private var taskStatuses: [TaskStatus] = []
|
||||
@State private var isLoadingLookups: Bool = true
|
||||
// Lookups from DataManagerObservable
|
||||
private var taskCategories: [TaskCategory] { dataManager.taskCategories }
|
||||
private var taskFrequencies: [TaskFrequency] { dataManager.taskFrequencies }
|
||||
private var taskPriorities: [TaskPriority] { dataManager.taskPriorities }
|
||||
private var taskStatuses: [TaskStatus] { dataManager.taskStatuses }
|
||||
private var isLoadingLookups: Bool { !dataManager.lookupsInitialized }
|
||||
|
||||
// Form fields
|
||||
@State private var selectedResidence: ResidenceResponse?
|
||||
@@ -254,8 +255,16 @@ struct TaskFormView: View {
|
||||
.disabled(!canSave || viewModel.isLoading || isLoadingLookups)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await loadLookups()
|
||||
.onAppear {
|
||||
// Set defaults when lookups are available
|
||||
if dataManager.lookupsInitialized {
|
||||
setDefaults()
|
||||
}
|
||||
}
|
||||
.onChange(of: dataManager.lookupsInitialized) { initialized in
|
||||
if initialized {
|
||||
setDefaults()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.taskCreated) { created in
|
||||
if created {
|
||||
@@ -280,37 +289,6 @@ struct TaskFormView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadLookups() async {
|
||||
// Wait a bit for lookups to be initialized (they load on app launch or login)
|
||||
try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds
|
||||
|
||||
// Load lookups from DataCache
|
||||
await MainActor.run {
|
||||
if let categories = DataCache.shared.taskCategories.value as? [TaskCategory],
|
||||
let frequencies = DataCache.shared.taskFrequencies.value as? [TaskFrequency],
|
||||
let priorities = DataCache.shared.taskPriorities.value as? [TaskPriority],
|
||||
let statuses = DataCache.shared.taskStatuses.value as? [TaskStatus] {
|
||||
|
||||
self.taskCategories = categories
|
||||
self.taskFrequencies = frequencies
|
||||
self.taskPriorities = priorities
|
||||
self.taskStatuses = statuses
|
||||
|
||||
print("✅ TaskFormView: Loaded lookups - Categories: \(categories.count), Frequencies: \(frequencies.count), Priorities: \(priorities.count), Statuses: \(statuses.count)")
|
||||
|
||||
setDefaults()
|
||||
isLoadingLookups = false
|
||||
}
|
||||
}
|
||||
|
||||
// If lookups not loaded, retry
|
||||
if taskCategories.isEmpty {
|
||||
print("⏳ TaskFormView: Lookups not ready, retrying...")
|
||||
try? await Task.sleep(nanoseconds: 500_000_000) // 0.5 seconds
|
||||
await loadLookups()
|
||||
}
|
||||
}
|
||||
|
||||
private func setDefaults() {
|
||||
// Set default values if not already set
|
||||
if selectedCategory == nil && !taskCategories.isEmpty {
|
||||
|
||||
@@ -18,6 +18,14 @@ struct iOSApp: App {
|
||||
|
||||
// Initialize TokenStorage once at app startup (legacy support)
|
||||
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
|
||||
|
||||
// Initialize lookups at app start (public endpoints, no auth required)
|
||||
// This fetches /static_data/ and /upgrade-triggers/ immediately
|
||||
Task {
|
||||
print("🚀 Initializing lookups at app start...")
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized")
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
Reference in New Issue
Block a user