diff --git a/iosApp/Casera/AppIntent.swift b/iosApp/Casera/AppIntent.swift index 8c128fd..539f347 100644 --- a/iosApp/Casera/AppIntent.swift +++ b/iosApp/Casera/AppIntent.swift @@ -40,13 +40,7 @@ struct CompleteTaskIntent: AppIntent { func perform() async throws -> some IntentResult { print("CompleteTaskIntent: Starting completion for task \(taskId)") - // Mark task as pending completion immediately (optimistic UI) - WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId) - - // Reload widget immediately to update task list and stats - WidgetCenter.shared.reloadTimelines(ofKind: "Casera") - - // Get auth token and API URL from shared container + // Check auth BEFORE marking pending β€” if auth fails the task should remain visible guard let token = WidgetActionManager.shared.getAuthToken() else { print("CompleteTaskIntent: No auth token available") WidgetCenter.shared.reloadTimelines(ofKind: "Casera") @@ -59,6 +53,12 @@ struct CompleteTaskIntent: AppIntent { return .result() } + // Mark task as pending completion (optimistic UI) only after auth is confirmed + WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId) + + // Reload widget immediately to update task list and stats + WidgetCenter.shared.reloadTimelines(ofKind: "Casera") + // Make API call to complete the task let success = await WidgetAPIClient.quickCompleteTask( taskId: taskId, diff --git a/iosApp/Casera/CaseraBundle.swift b/iosApp/Casera/CaseraBundle.swift index a8faf83..cfaee0a 100644 --- a/iosApp/Casera/CaseraBundle.swift +++ b/iosApp/Casera/CaseraBundle.swift @@ -12,7 +12,5 @@ import SwiftUI struct CaseraBundle: WidgetBundle { var body: some Widget { Casera() - CaseraControl() - CaseraLiveActivity() } } diff --git a/iosApp/Casera/CaseraControl.swift b/iosApp/Casera/CaseraControl.swift deleted file mode 100644 index e528675..0000000 --- a/iosApp/Casera/CaseraControl.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// CaseraControl.swift -// Casera -// -// Created by Trey Tartt on 11/5/25. -// - -import AppIntents -import SwiftUI -import WidgetKit - -struct CaseraControl: ControlWidget { - static let kind: String = "com.example.casera.Casera.Casera" - - var body: some ControlWidgetConfiguration { - AppIntentControlConfiguration( - kind: Self.kind, - provider: Provider() - ) { value in - ControlWidgetToggle( - "Start Timer", - isOn: value.isRunning, - action: StartTimerIntent(value.name) - ) { isRunning in - Label(isRunning ? "On" : "Off", systemImage: "timer") - } - } - .displayName("Timer") - .description("A an example control that runs a timer.") - } -} - -extension CaseraControl { - struct Value { - var isRunning: Bool - var name: String - } - - struct Provider: AppIntentControlValueProvider { - func previewValue(configuration: TimerConfiguration) -> Value { - CaseraControl.Value(isRunning: false, name: configuration.timerName) - } - - func currentValue(configuration: TimerConfiguration) async throws -> Value { - let isRunning = true // Check if the timer is running - return CaseraControl.Value(isRunning: isRunning, name: configuration.timerName) - } - } -} - -struct TimerConfiguration: ControlConfigurationIntent { - static let title: LocalizedStringResource = "Timer Name Configuration" - - @Parameter(title: "Timer Name", default: "Timer") - var timerName: String -} - -struct StartTimerIntent: SetValueIntent { - static let title: LocalizedStringResource = "Start a timer" - - @Parameter(title: "Timer Name") - var name: String - - @Parameter(title: "Timer is running") - var value: Bool - - init() {} - - init(_ name: String) { - self.name = name - } - - func perform() async throws -> some IntentResult { - // Start the timer… - return .result() - } -} diff --git a/iosApp/Casera/CaseraLiveActivity.swift b/iosApp/Casera/CaseraLiveActivity.swift deleted file mode 100644 index 8b821a7..0000000 --- a/iosApp/Casera/CaseraLiveActivity.swift +++ /dev/null @@ -1,80 +0,0 @@ -// -// CaseraLiveActivity.swift -// Casera -// -// Created by Trey Tartt on 11/5/25. -// - -import ActivityKit -import WidgetKit -import SwiftUI - -struct CaseraAttributes: ActivityAttributes { - public struct ContentState: Codable, Hashable { - // Dynamic stateful properties about your activity go here! - var emoji: String - } - - // Fixed non-changing properties about your activity go here! - var name: String -} - -struct CaseraLiveActivity: Widget { - var body: some WidgetConfiguration { - ActivityConfiguration(for: CaseraAttributes.self) { context in - // Lock screen/banner UI goes here - VStack { - Text("Hello \(context.state.emoji)") - } - .activityBackgroundTint(Color.cyan) - .activitySystemActionForegroundColor(Color.black) - - } dynamicIsland: { context in - DynamicIsland { - // Expanded UI goes here. Compose the expanded UI through - // various regions, like leading/trailing/center/bottom - DynamicIslandExpandedRegion(.leading) { - Text("Leading") - } - DynamicIslandExpandedRegion(.trailing) { - Text("Trailing") - } - DynamicIslandExpandedRegion(.bottom) { - Text("Bottom \(context.state.emoji)") - // more content - } - } compactLeading: { - Text("L") - } compactTrailing: { - Text("T \(context.state.emoji)") - } minimal: { - Text(context.state.emoji) - } - .widgetURL(URL(string: "http://www.apple.com")) - .keylineTint(Color.red) - } - } -} - -extension CaseraAttributes { - fileprivate static var preview: CaseraAttributes { - CaseraAttributes(name: "World") - } -} - -extension CaseraAttributes.ContentState { - fileprivate static var smiley: CaseraAttributes.ContentState { - CaseraAttributes.ContentState(emoji: "πŸ˜€") - } - - fileprivate static var starEyes: CaseraAttributes.ContentState { - CaseraAttributes.ContentState(emoji: "🀩") - } -} - -#Preview("Notification", as: .content, using: CaseraAttributes.preview) { - CaseraLiveActivity() -} contentStates: { - CaseraAttributes.ContentState.smiley - CaseraAttributes.ContentState.starEyes -} diff --git a/iosApp/Casera/MyCrib.swift b/iosApp/Casera/MyCrib.swift index 7f57cc2..b43a297 100644 --- a/iosApp/Casera/MyCrib.swift +++ b/iosApp/Casera/MyCrib.swift @@ -10,6 +10,28 @@ import SwiftUI import AppIntents // MARK: - Date Formatting Helper + +/// Cached formatters to avoid repeated allocation in widget rendering +private enum WidgetDateFormatters { + static let dateOnly: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + return f + }() + + static let iso8601WithFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + static let iso8601: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() +} + /// Parses date strings in either yyyy-MM-dd or ISO8601 (RFC3339) format /// and returns a user-friendly string like "Today" or "in X days" private func formatWidgetDate(_ dateString: String) -> String { @@ -17,20 +39,15 @@ private func formatWidgetDate(_ dateString: String) -> String { var date: Date? // Try parsing as yyyy-MM-dd first - let dateOnlyFormatter = DateFormatter() - dateOnlyFormatter.dateFormat = "yyyy-MM-dd" - date = dateOnlyFormatter.date(from: dateString) + date = WidgetDateFormatters.dateOnly.date(from: dateString) // Try parsing as ISO8601 (RFC3339) if that fails if date == nil { - let isoFormatter = ISO8601DateFormatter() - isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - date = isoFormatter.date(from: dateString) + date = WidgetDateFormatters.iso8601WithFractional.date(from: dateString) // Try without fractional seconds if date == nil { - isoFormatter.formatOptions = [.withInternetDateTime] - date = isoFormatter.date(from: dateString) + date = WidgetDateFormatters.iso8601.date(from: dateString) } } @@ -179,9 +196,11 @@ struct Provider: AppIntentTimelineProvider { let tasks = CacheManager.getUpcomingTasks() let isInteractive = WidgetActionManager.shared.shouldShowInteractiveWidget() - // Update every 30 minutes (more frequent for interactive widgets) + // Use a longer refresh interval during overnight hours (11pm-6am) let currentDate = Date() - let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)! + let hour = Calendar.current.component(.hour, from: currentDate) + let refreshMinutes = (hour >= 23 || hour < 6) ? 120 : 30 + let nextUpdate = Calendar.current.date(byAdding: .minute, value: refreshMinutes, to: currentDate)! let entry = SimpleEntry( date: currentDate, configuration: configuration, diff --git a/iosApp/CaseraQLPreview/PreviewViewController.swift b/iosApp/CaseraQLPreview/PreviewViewController.swift index f10acce..58280c8 100644 --- a/iosApp/CaseraQLPreview/PreviewViewController.swift +++ b/iosApp/CaseraQLPreview/PreviewViewController.swift @@ -198,16 +198,15 @@ class PreviewViewController: UIViewController, QLPreviewingController { func preparePreviewOfFile(at url: URL) async throws { print("CaseraQLPreview: preparePreviewOfFile called with URL: \(url)") - // Parse the .casera file + // Parse the .casera file β€” single Codable pass to detect type, then decode let data = try Data(contentsOf: url) + let decoder = JSONDecoder() - // Detect package type first - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let typeString = json["type"] as? String, - typeString == "residence" { + let envelope = try? decoder.decode(PackageTypeEnvelope.self, from: data) + + if envelope?.type == "residence" { currentPackageType = .residence - let decoder = JSONDecoder() let residence = try decoder.decode(ResidencePreviewData.self, from: data) self.residenceData = residence print("CaseraQLPreview: Parsed residence: \(residence.residenceName)") @@ -218,7 +217,6 @@ class PreviewViewController: UIViewController, QLPreviewingController { } else { currentPackageType = .contractor - let decoder = JSONDecoder() let contractor = try decoder.decode(ContractorPreviewData.self, from: data) self.contractorData = contractor print("CaseraQLPreview: Parsed contractor: \(contractor.name)") @@ -287,6 +285,13 @@ class PreviewViewController: UIViewController, QLPreviewingController { } } +// MARK: - Type Discriminator + +/// Lightweight struct to detect the package type without a full parse +private struct PackageTypeEnvelope: Decodable { + let type: String? +} + // MARK: - Data Model struct ContractorPreviewData: Codable { diff --git a/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift b/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift index 4db254a..ab7e98b 100644 --- a/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift +++ b/iosApp/CaseraQLThumbnail/ThumbnailProvider.swift @@ -54,13 +54,17 @@ class ThumbnailProvider: QLThumbnailProvider { }), nil) } + /// Lightweight struct to detect the package type via Codable instead of JSONSerialization + private struct PackageTypeEnvelope: Decodable { + let type: String? + } + /// Detects the package type by reading the "type" field from the JSON private func detectPackageType(at url: URL) -> PackageType { do { let data = try Data(contentsOf: url) - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let typeString = json["type"] as? String, - typeString == "residence" { + let envelope = try JSONDecoder().decode(PackageTypeEnvelope.self, from: data) + if envelope.type == "residence" { return .residence } } catch { diff --git a/iosApp/iosApp/Analytics/AnalyticsManager.swift b/iosApp/iosApp/Analytics/AnalyticsManager.swift index a5df8c3..31841b9 100644 --- a/iosApp/iosApp/Analytics/AnalyticsManager.swift +++ b/iosApp/iosApp/Analytics/AnalyticsManager.swift @@ -230,7 +230,7 @@ final class AnalyticsManager { var sessionReplayEnabled: Bool { get { if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil { - return true + return false } return UserDefaults.standard.bool(forKey: Self.sessionReplayKey) } diff --git a/iosApp/iosApp/Background/BackgroundTaskManager.swift b/iosApp/iosApp/Background/BackgroundTaskManager.swift index 941e043..6146b77 100644 --- a/iosApp/iosApp/Background/BackgroundTaskManager.swift +++ b/iosApp/iosApp/Background/BackgroundTaskManager.swift @@ -90,7 +90,6 @@ final class BackgroundTaskManager { } /// Perform the actual data refresh - @MainActor private func performDataRefresh() async -> Bool { // Check if user is authenticated guard let token = TokenStorage.shared.getToken(), !token.isEmpty else { diff --git a/iosApp/iosApp/Components/AuthenticatedImage.swift b/iosApp/iosApp/Components/AuthenticatedImage.swift index a38ea0f..4dafae0 100644 --- a/iosApp/iosApp/Components/AuthenticatedImage.swift +++ b/iosApp/iosApp/Components/AuthenticatedImage.swift @@ -155,6 +155,7 @@ private class AuthenticatedImageLoader: ObservableObject { // Create request with auth header var request = URLRequest(url: url) request.setValue("Token \(token)", forHTTPHeaderField: "Authorization") + request.timeoutInterval = 15 request.cachePolicy = .returnCacheDataElseLoad do { diff --git a/iosApp/iosApp/Contractor/ContractorDetailView.swift b/iosApp/iosApp/Contractor/ContractorDetailView.swift index a3fb454..823e28a 100644 --- a/iosApp/iosApp/Contractor/ContractorDetailView.swift +++ b/iosApp/iosApp/Contractor/ContractorDetailView.swift @@ -11,7 +11,6 @@ struct ContractorDetailView: View { @State private var showingEditSheet = false @State private var showingDeleteAlert = false - @State private var showingShareSheet = false @State private var shareFileURL: URL? @State private var showingUpgradePrompt = false @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @@ -64,7 +63,10 @@ struct ContractorDetailView: View { } } } - .sheet(isPresented: $showingShareSheet) { + .sheet(isPresented: Binding( + get: { shareFileURL != nil }, + set: { if !$0 { shareFileURL = nil } } + )) { if let url = shareFileURL { ShareSheet(activityItems: [url]) } @@ -101,9 +103,7 @@ struct ContractorDetailView: View { private func deleteContractor() { viewModel.deleteContractor(id: contractorId) { success in if success { - Task { @MainActor in - // Small delay to allow state to settle before dismissing - try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { dismiss() } } @@ -113,7 +113,6 @@ struct ContractorDetailView: View { private func shareContractor(_ contractor: Contractor) { if let url = ContractorSharingManager.shared.createShareableFile(contractor: contractor) { shareFileURL = url - showingShareSheet = true } } diff --git a/iosApp/iosApp/Contractor/ContractorFormSheet.swift b/iosApp/iosApp/Contractor/ContractorFormSheet.swift index 1903926..ad9ac06 100644 --- a/iosApp/iosApp/Contractor/ContractorFormSheet.swift +++ b/iosApp/iosApp/Contractor/ContractorFormSheet.swift @@ -276,7 +276,7 @@ struct ContractorFormSheet: View { if viewModel.isCreating || viewModel.isUpdating { ProgressView() } else { - Text(contractor == nil ? L10n.Contractors.addButton : L10n.Common.save) + Text(contractor == nil ? L10n.Common.add : L10n.Common.save) .bold() } } @@ -297,6 +297,12 @@ struct ContractorFormSheet: View { residenceViewModel.loadMyResidences() loadContractorData() } + .onChange(of: residenceViewModel.selectedResidence?.id) { _, _ in + if let residence = residenceViewModel.selectedResidence, + residence.id == selectedResidenceId { + selectedResidenceName = residence.name + } + } .handleErrors( error: viewModel.errorMessage, onRetry: { saveContractor() } @@ -440,11 +446,7 @@ struct ContractorFormSheet: View { if let residenceId = contractor.residenceId { selectedResidenceId = residenceId.int32Value if let selectedResidenceId { - ComposeApp.ResidenceViewModel().getResidence(id: selectedResidenceId, onResult: { result in - if let success = result as? ApiResultSuccess { - self.selectedResidenceName = success.data?.name - } - }) + residenceViewModel.getResidence(id: selectedResidenceId) } } diff --git a/iosApp/iosApp/Contractor/ContractorViewModel.swift b/iosApp/iosApp/Contractor/ContractorViewModel.swift index cb45953..f3d810b 100644 --- a/iosApp/iosApp/Contractor/ContractorViewModel.swift +++ b/iosApp/iosApp/Contractor/ContractorViewModel.swift @@ -19,6 +19,9 @@ class ContractorViewModel: ObservableObject { // MARK: - Private Properties private var cancellables = Set() + /// Guards against redundant detail reloads immediately after a mutation that already + /// set selectedContractor from its response. + private var suppressNextDetailReload = false // MARK: - Initialization @@ -28,6 +31,20 @@ class ContractorViewModel: ObservableObject { .receive(on: DispatchQueue.main) .sink { [weak self] contractors in self?.contractors = contractors + // Auto-refresh selectedContractor when the list changes, + // so detail views stay current after mutations from other ViewModels. + // ContractorSummary and Contractor are different types, so we can't + // copy fields directly. Instead, if selectedContractor exists and a + // matching summary is found, reload the full detail from the API. + if let self = self, + let currentId = self.selectedContractor?.id, + contractors.contains(where: { $0.id == currentId }) { + if self.suppressNextDetailReload { + self.suppressNextDetailReload = false + } else { + self.reloadSelectedContractorQuietly(id: currentId) + } + } } .store(in: &cancellables) } @@ -99,10 +116,12 @@ class ContractorViewModel: ObservableObject { do { let result = try await APILayer.shared.createContractor(request: request) - if result is ApiResultSuccess { + if let success = result as? ApiResultSuccess { self.successMessage = "Contractor added successfully" self.isCreating = false - // DataManager is updated by APILayer, view updates via observation + // Update selectedContractor with the newly created contractor + self.suppressNextDetailReload = true + self.selectedContractor = success.data completion(true) } else if let error = ApiResultBridge.error(from: result) { self.errorMessage = ErrorMessageParser.parse(error.message) @@ -129,10 +148,12 @@ class ContractorViewModel: ObservableObject { do { let result = try await APILayer.shared.updateContractor(id: id, request: request) - if result is ApiResultSuccess { + if let success = result as? ApiResultSuccess { self.successMessage = "Contractor updated successfully" self.isUpdating = false - // DataManager is updated by APILayer, view updates via observation + // Update selectedContractor immediately so detail views stay fresh + self.suppressNextDetailReload = true + self.selectedContractor = success.data completion(true) } else if let error = ApiResultBridge.error(from: result) { self.errorMessage = ErrorMessageParser.parse(error.message) @@ -186,8 +207,10 @@ class ContractorViewModel: ObservableObject { do { let result = try await APILayer.shared.toggleFavorite(id: id) - if result is ApiResultSuccess { - // DataManager is updated by APILayer, view updates via observation + if let success = result as? ApiResultSuccess { + // Update selectedContractor immediately so detail views stay fresh + self.suppressNextDetailReload = true + self.selectedContractor = success.data completion(true) } else if let error = ApiResultBridge.error(from: result) { self.errorMessage = ErrorMessageParser.parse(error.message) @@ -207,4 +230,21 @@ class ContractorViewModel: ObservableObject { errorMessage = nil successMessage = nil } + + // MARK: - Private Helpers + + /// Silently reload the selected contractor detail without showing loading state. + /// Used when the contractors list updates and we need to keep selectedContractor fresh. + private func reloadSelectedContractorQuietly(id: Int32) { + Task { + do { + let result = try await APILayer.shared.getContractor(id: id, forceRefresh: true) + if let success = result as? ApiResultSuccess { + self.selectedContractor = success.data + } + } catch { + // Silently ignore β€” this is a background refresh, not user-initiated + } + } + } } diff --git a/iosApp/iosApp/Contractor/ContractorsListView.swift b/iosApp/iosApp/Contractor/ContractorsListView.swift index 44abd14..b93f9cb 100644 --- a/iosApp/iosApp/Contractor/ContractorsListView.swift +++ b/iosApp/iosApp/Contractor/ContractorsListView.swift @@ -96,7 +96,10 @@ struct ContractorsListView: View { } }, onRefresh: { - loadContractors(forceRefresh: true) + viewModel.loadContractors(forceRefresh: true) + for await loading in viewModel.$isLoading.values { + if !loading { break } + } }, onRetry: { loadContractors() diff --git a/iosApp/iosApp/Core/AsyncContentView.swift b/iosApp/iosApp/Core/AsyncContentView.swift index 50a33d8..49686bb 100644 --- a/iosApp/iosApp/Core/AsyncContentView.swift +++ b/iosApp/iosApp/Core/AsyncContentView.swift @@ -199,7 +199,7 @@ struct ListAsyncContentView: View { let errorMessage: String? let content: ([T]) -> Content let emptyContent: () -> EmptyContent - let onRefresh: () -> Void + let onRefresh: () async -> Void let onRetry: () -> Void init( @@ -208,7 +208,7 @@ struct ListAsyncContentView: View { errorMessage: String?, @ViewBuilder content: @escaping ([T]) -> Content, @ViewBuilder emptyContent: @escaping () -> EmptyContent, - onRefresh: @escaping () -> Void, + onRefresh: @escaping () async -> Void, onRetry: @escaping () -> Void ) { self.items = items @@ -248,10 +248,7 @@ struct ListAsyncContentView: View { } } .refreshable { - await withCheckedContinuation { continuation in - onRefresh() - continuation.resume() - } + await onRefresh() } } } diff --git a/iosApp/iosApp/Core/FormStates/ContractorFormState.swift b/iosApp/iosApp/Core/FormStates/ContractorFormState.swift deleted file mode 100644 index 737cad3..0000000 --- a/iosApp/iosApp/Core/FormStates/ContractorFormState.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import SwiftUI -import ComposeApp - -// MARK: - Contractor Form State - -/// Form state container for creating/editing a contractor -struct ContractorFormState: FormState { - var name = FormField() - var company = FormField() - var phone = FormField() - var email = FormField() - var website = FormField() - var streetAddress = FormField() - var city = FormField() - var stateProvince = FormField() - var postalCode = FormField() - var notes = FormField() - var isFavorite: Bool = false - - // Residence selection (optional - nil means personal contractor) - var selectedResidenceId: Int32? - var selectedResidenceName: String? - - // Specialty IDs (multiple selection) - var selectedSpecialtyIds: [Int32] = [] - - // For edit mode - var existingContractorId: Int32? - - var isEditMode: Bool { - existingContractorId != nil - } - - var isValid: Bool { - !name.isEmpty - } - - mutating func validateAll() { - name.validate { ValidationRules.validateRequired($0, fieldName: "Name") } - - // Optional email validation - if !email.isEmpty { - email.validate { value in - ValidationRules.isValidEmail(value) ? nil : .invalidEmail - } - } - } - - mutating func reset() { - name = FormField() - company = FormField() - phone = FormField() - email = FormField() - website = FormField() - streetAddress = FormField() - city = FormField() - stateProvince = FormField() - postalCode = FormField() - notes = FormField() - isFavorite = false - selectedResidenceId = nil - selectedResidenceName = nil - selectedSpecialtyIds = [] - existingContractorId = nil - } - - /// Create ContractorCreateRequest from form state - func toCreateRequest() -> ContractorCreateRequest { - ContractorCreateRequest( - name: name.trimmedValue, - residenceId: selectedResidenceId.map { KotlinInt(int: $0) }, - company: company.isEmpty ? nil : company.trimmedValue, - phone: phone.isEmpty ? nil : phone.trimmedValue, - email: email.isEmpty ? nil : email.trimmedValue, - website: website.isEmpty ? nil : website.trimmedValue, - streetAddress: streetAddress.isEmpty ? nil : streetAddress.trimmedValue, - city: city.isEmpty ? nil : city.trimmedValue, - stateProvince: stateProvince.isEmpty ? nil : stateProvince.trimmedValue, - postalCode: postalCode.isEmpty ? nil : postalCode.trimmedValue, - rating: nil, - isFavorite: isFavorite, - notes: notes.isEmpty ? nil : notes.trimmedValue, - specialtyIds: selectedSpecialtyIds.isEmpty ? nil : selectedSpecialtyIds.map { KotlinInt(int: $0) } - ) - } - - /// Create ContractorUpdateRequest from form state - func toUpdateRequest() -> ContractorUpdateRequest { - ContractorUpdateRequest( - name: name.isEmpty ? nil : name.trimmedValue, - residenceId: selectedResidenceId.map { KotlinInt(int: $0) }, - company: company.isEmpty ? nil : company.trimmedValue, - phone: phone.isEmpty ? nil : phone.trimmedValue, - email: email.isEmpty ? nil : email.trimmedValue, - website: website.isEmpty ? nil : website.trimmedValue, - streetAddress: streetAddress.isEmpty ? nil : streetAddress.trimmedValue, - city: city.isEmpty ? nil : city.trimmedValue, - stateProvince: stateProvince.isEmpty ? nil : stateProvince.trimmedValue, - postalCode: postalCode.isEmpty ? nil : postalCode.trimmedValue, - rating: nil, - isFavorite: isFavorite.asKotlin, - notes: notes.isEmpty ? nil : notes.trimmedValue, - specialtyIds: selectedSpecialtyIds.isEmpty ? nil : selectedSpecialtyIds.map { KotlinInt(int: $0) } - ) - } -} diff --git a/iosApp/iosApp/Data/DataManagerObservable.swift b/iosApp/iosApp/Data/DataManagerObservable.swift index 79821aa..7a4af66 100644 --- a/iosApp/iosApp/Data/DataManagerObservable.swift +++ b/iosApp/iosApp/Data/DataManagerObservable.swift @@ -96,30 +96,28 @@ class DataManagerObservable: ObservableObject { // Authentication - authToken let authTokenTask = Task { [weak self] in for await token in DataManager.shared.authToken { - await MainActor.run { - guard let self else { return } - let previousToken = self.authToken - let wasAuthenticated = previousToken != nil - self.authToken = token - self.isAuthenticated = token != nil + guard let self else { return } + let previousToken = self.authToken + let wasAuthenticated = previousToken != nil + self.authToken = token + self.isAuthenticated = token != nil - // Token rotated/account switched without explicit logout. - if let previousToken, let token, previousToken != token { - PushNotificationManager.shared.clearRegistrationCache() - } + // Token rotated/account switched without explicit logout. + if let previousToken, let token, previousToken != token { + PushNotificationManager.shared.clearRegistrationCache() + } - // Keep widget auth in sync with token lifecycle. - if let token { - WidgetDataManager.shared.saveAuthToken(token) - WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl()) - } + // Keep widget auth in sync with token lifecycle. + if let token { + WidgetDataManager.shared.saveAuthToken(token) + WidgetDataManager.shared.saveAPIBaseURL(ApiClient.shared.getBaseUrl()) + } - // Clear widget cache on logout - if token == nil && wasAuthenticated { - WidgetDataManager.shared.clearCache() - WidgetDataManager.shared.clearAuthToken() - PushNotificationManager.shared.clearRegistrationCache() - } + // Clear widget cache on logout + if token == nil && wasAuthenticated { + WidgetDataManager.shared.clearCache() + WidgetDataManager.shared.clearAuthToken() + PushNotificationManager.shared.clearRegistrationCache() } } } @@ -128,10 +126,8 @@ class DataManagerObservable: ObservableObject { // Authentication - currentUser let currentUserTask = Task { [weak self] in for await user in DataManager.shared.currentUser { - await MainActor.run { - guard let self else { return } - self.currentUser = user - } + guard let self else { return } + self.currentUser = user } } observationTasks.append(currentUserTask) @@ -139,10 +135,8 @@ class DataManagerObservable: ObservableObject { // Theme let themeIdTask = Task { [weak self] in for await id in DataManager.shared.themeId { - await MainActor.run { - guard let self else { return } - self.themeId = id - } + guard let self else { return } + self.themeId = id } } observationTasks.append(themeIdTask) @@ -150,10 +144,8 @@ class DataManagerObservable: ObservableObject { // Residences let residencesTask = Task { [weak self] in for await list in DataManager.shared.residences { - await MainActor.run { - guard let self else { return } - self.residences = list - } + guard let self else { return } + self.residences = list } } observationTasks.append(residencesTask) @@ -161,10 +153,8 @@ class DataManagerObservable: ObservableObject { // MyResidences let myResidencesTask = Task { [weak self] in for await response in DataManager.shared.myResidences { - await MainActor.run { - guard let self else { return } - self.myResidences = response - } + guard let self else { return } + self.myResidences = response } } observationTasks.append(myResidencesTask) @@ -172,10 +162,8 @@ class DataManagerObservable: ObservableObject { // TotalSummary let totalSummaryTask = Task { [weak self] in for await summary in DataManager.shared.totalSummary { - await MainActor.run { - guard let self else { return } - self.totalSummary = summary - } + guard let self else { return } + self.totalSummary = summary } } observationTasks.append(totalSummaryTask) @@ -183,10 +171,8 @@ class DataManagerObservable: ObservableObject { // ResidenceSummaries let residenceSummariesTask = Task { [weak self] in for await summaries in DataManager.shared.residenceSummaries { - await MainActor.run { - guard let self else { return } - self.residenceSummaries = self.convertIntMap(summaries) - } + guard let self else { return } + self.residenceSummaries = self.convertIntMap(summaries) } } observationTasks.append(residenceSummariesTask) @@ -194,13 +180,12 @@ class DataManagerObservable: ObservableObject { // AllTasks let allTasksTask = Task { [weak self] in for await tasks in DataManager.shared.allTasks { - await MainActor.run { - guard let self else { return } - self.allTasks = tasks - // Save to widget shared container (debounced) - if let tasks = tasks { - self.debouncedWidgetSave(tasks: tasks) - } + guard let self else { return } + self.allTasks = tasks + self.recomputeActiveTasks() + // Save to widget shared container (debounced) + if let tasks = tasks { + self.debouncedWidgetSave(tasks: tasks) } } } @@ -209,10 +194,8 @@ class DataManagerObservable: ObservableObject { // TasksByResidence let tasksByResidenceTask = Task { [weak self] in for await tasks in DataManager.shared.tasksByResidence { - await MainActor.run { - guard let self else { return } - self.tasksByResidence = self.convertIntMap(tasks) - } + guard let self else { return } + self.tasksByResidence = self.convertIntMap(tasks) } } observationTasks.append(tasksByResidenceTask) @@ -220,10 +203,8 @@ class DataManagerObservable: ObservableObject { // Documents let documentsTask = Task { [weak self] in for await docs in DataManager.shared.documents { - await MainActor.run { - guard let self else { return } - self.documents = docs - } + guard let self else { return } + self.documents = docs } } observationTasks.append(documentsTask) @@ -231,10 +212,8 @@ class DataManagerObservable: ObservableObject { // DocumentsByResidence let documentsByResidenceTask = Task { [weak self] in for await docs in DataManager.shared.documentsByResidence { - await MainActor.run { - guard let self else { return } - self.documentsByResidence = self.convertIntArrayMap(docs) - } + guard let self else { return } + self.documentsByResidence = self.convertIntArrayMap(docs) } } observationTasks.append(documentsByResidenceTask) @@ -242,10 +221,8 @@ class DataManagerObservable: ObservableObject { // Contractors let contractorsTask = Task { [weak self] in for await list in DataManager.shared.contractors { - await MainActor.run { - guard let self else { return } - self.contractors = list - } + guard let self else { return } + self.contractors = list } } observationTasks.append(contractorsTask) @@ -253,10 +230,8 @@ class DataManagerObservable: ObservableObject { // Subscription let subscriptionTask = Task { [weak self] in for await sub in DataManager.shared.subscription { - await MainActor.run { - guard let self else { return } - self.subscription = sub - } + guard let self else { return } + self.subscription = sub } } observationTasks.append(subscriptionTask) @@ -264,10 +239,8 @@ class DataManagerObservable: ObservableObject { // UpgradeTriggers let upgradeTriggersTask = Task { [weak self] in for await triggers in DataManager.shared.upgradeTriggers { - await MainActor.run { - guard let self else { return } - self.upgradeTriggers = self.convertStringMap(triggers) - } + guard let self else { return } + self.upgradeTriggers = self.convertStringMap(triggers) } } observationTasks.append(upgradeTriggersTask) @@ -275,10 +248,8 @@ class DataManagerObservable: ObservableObject { // FeatureBenefits let featureBenefitsTask = Task { [weak self] in for await benefits in DataManager.shared.featureBenefits { - await MainActor.run { - guard let self else { return } - self.featureBenefits = benefits - } + guard let self else { return } + self.featureBenefits = benefits } } observationTasks.append(featureBenefitsTask) @@ -286,10 +257,8 @@ class DataManagerObservable: ObservableObject { // Promotions let promotionsTask = Task { [weak self] in for await promos in DataManager.shared.promotions { - await MainActor.run { - guard let self else { return } - self.promotions = promos - } + guard let self else { return } + self.promotions = promos } } observationTasks.append(promotionsTask) @@ -297,10 +266,8 @@ class DataManagerObservable: ObservableObject { // Lookups - ResidenceTypes let residenceTypesTask = Task { [weak self] in for await types in DataManager.shared.residenceTypes { - await MainActor.run { - guard let self else { return } - self.residenceTypes = types - } + guard let self else { return } + self.residenceTypes = types } } observationTasks.append(residenceTypesTask) @@ -308,10 +275,8 @@ class DataManagerObservable: ObservableObject { // Lookups - TaskFrequencies let taskFrequenciesTask = Task { [weak self] in for await items in DataManager.shared.taskFrequencies { - await MainActor.run { - guard let self else { return } - self.taskFrequencies = items - } + guard let self else { return } + self.taskFrequencies = items } } observationTasks.append(taskFrequenciesTask) @@ -319,10 +284,8 @@ class DataManagerObservable: ObservableObject { // Lookups - TaskPriorities let taskPrioritiesTask = Task { [weak self] in for await items in DataManager.shared.taskPriorities { - await MainActor.run { - guard let self else { return } - self.taskPriorities = items - } + guard let self else { return } + self.taskPriorities = items } } observationTasks.append(taskPrioritiesTask) @@ -330,10 +293,8 @@ class DataManagerObservable: ObservableObject { // Lookups - TaskCategories let taskCategoriesTask = Task { [weak self] in for await items in DataManager.shared.taskCategories { - await MainActor.run { - guard let self else { return } - self.taskCategories = items - } + guard let self else { return } + self.taskCategories = items } } observationTasks.append(taskCategoriesTask) @@ -341,10 +302,8 @@ class DataManagerObservable: ObservableObject { // Lookups - ContractorSpecialties let contractorSpecialtiesTask = Task { [weak self] in for await items in DataManager.shared.contractorSpecialties { - await MainActor.run { - guard let self else { return } - self.contractorSpecialties = items - } + guard let self else { return } + self.contractorSpecialties = items } } observationTasks.append(contractorSpecialtiesTask) @@ -352,10 +311,8 @@ class DataManagerObservable: ObservableObject { // Task Templates let taskTemplatesTask = Task { [weak self] in for await items in DataManager.shared.taskTemplates { - await MainActor.run { - guard let self else { return } - self.taskTemplates = items - } + guard let self else { return } + self.taskTemplates = items } } observationTasks.append(taskTemplatesTask) @@ -363,10 +320,8 @@ class DataManagerObservable: ObservableObject { // Task Templates Grouped let taskTemplatesGroupedTask = Task { [weak self] in for await response in DataManager.shared.taskTemplatesGrouped { - await MainActor.run { - guard let self else { return } - self.taskTemplatesGrouped = response - } + guard let self else { return } + self.taskTemplatesGrouped = response } } observationTasks.append(taskTemplatesGroupedTask) @@ -374,10 +329,8 @@ class DataManagerObservable: ObservableObject { // Metadata - isInitialized let isInitializedTask = Task { [weak self] in for await initialized in DataManager.shared.isInitialized { - await MainActor.run { - guard let self else { return } - self.isInitialized = initialized.boolValue - } + guard let self else { return } + self.isInitialized = initialized.boolValue } } observationTasks.append(isInitializedTask) @@ -385,10 +338,8 @@ class DataManagerObservable: ObservableObject { // Metadata - lookupsInitialized let lookupsInitializedTask = Task { [weak self] in for await initialized in DataManager.shared.lookupsInitialized { - await MainActor.run { - guard let self else { return } - self.lookupsInitialized = initialized.boolValue - } + guard let self else { return } + self.lookupsInitialized = initialized.boolValue } } observationTasks.append(lookupsInitializedTask) @@ -396,10 +347,8 @@ class DataManagerObservable: ObservableObject { // Metadata - lastSyncTime let lastSyncTimeTask = Task { [weak self] in for await time in DataManager.shared.lastSyncTime { - await MainActor.run { - guard let self else { return } - self.lastSyncTime = time.int64Value - } + guard let self else { return } + self.lastSyncTime = time.int64Value } } observationTasks.append(lastSyncTimeTask) @@ -516,6 +465,9 @@ class DataManagerObservable: ObservableObject { } // MARK: - Convenience Lookup Methods + // Note: These use O(n) linear search which is acceptable for small lookup arrays + // (typically <20 items each). Dictionary-based lookups would add complexity + // for negligible performance gain at this scale. /// Get residence type by ID func getResidenceType(id: Int32?) -> ResidenceType? { @@ -579,9 +531,17 @@ class DataManagerObservable: ObservableObject { // MARK: - Task Stats (Single Source of Truth) // Uses API column names + shared calculateMetrics function - /// Active tasks (excludes completed and cancelled) - var activeTasks: [TaskResponse] { - guard let response = allTasks else { return [] } + /// Active tasks (excludes completed and cancelled). + /// Computed once when `allTasks` changes and cached to avoid + /// redundant iteration in `totalTaskMetrics`, `taskMetrics(for:)`, etc. + private(set) var activeTasks: [TaskResponse] = [] + + /// Recompute cached activeTasks from allTasks + private func recomputeActiveTasks() { + guard let response = allTasks else { + activeTasks = [] + return + } var tasks: [TaskResponse] = [] for column in response.columns { let columnName = column.name.lowercased() @@ -590,7 +550,7 @@ class DataManagerObservable: ObservableObject { } tasks.append(contentsOf: column.tasks) } - return tasks + activeTasks = tasks } /// Get tasks from a specific column by name diff --git a/iosApp/iosApp/Design/DesignSystem.swift b/iosApp/iosApp/Design/DesignSystem.swift index 02f2591..fad0550 100644 --- a/iosApp/iosApp/Design/DesignSystem.swift +++ b/iosApp/iosApp/Design/DesignSystem.swift @@ -7,18 +7,24 @@ import SwiftUI extension Color { // MARK: - Dynamic Theme Resolution + + /// Shared App Group defaults for reading the active theme. + /// Thread-safe: UserDefaults is safe to read from any thread/actor. + private static let _themeDefaults: UserDefaults = { + UserDefaults(suiteName: "group.com.tt.casera.CaseraDev") ?? .standard + }() + private static func themed(_ name: String) -> Color { - // Both main app and widgets use the theme from ThemeManager - // Theme is shared via App Group UserDefaults - let theme = MainActor.assumeIsolated { - ThemeManager.shared.currentTheme.rawValue - } + // Read theme directly from shared UserDefaults instead of going through + // @MainActor-isolated ThemeManager.shared. This is safe to call from any + // actor context (including widget timeline providers and background threads). + let theme = _themeDefaults.string(forKey: "selectedTheme") ?? ThemeID.bright.rawValue return Color("\(theme)/\(name)", bundle: nil) } // MARK: - Semantic Colors (Use These in UI) - // These dynamically resolve based on ThemeManager.shared.currentTheme - // Theme is shared between main app and widgets via App Group + // These dynamically resolve based on the active theme stored in App Group UserDefaults. + // Safe to call from any actor context (main app, widget extensions, background threads). static var appPrimary: Color { themed("Primary") } static var appSecondary: Color { themed("Secondary") } static var appAccent: Color { themed("Accent") } diff --git a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift index e922ee9..7b9a2ab 100644 --- a/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift +++ b/iosApp/iosApp/Documents/Components/DocumentsTabContent.swift @@ -40,6 +40,9 @@ struct DocumentsTabContent: View { }, onRefresh: { viewModel.loadDocuments(forceRefresh: true) + for await loading in viewModel.$isLoading.values { + if !loading { break } + } }, onRetry: { viewModel.loadDocuments() diff --git a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift index 219c781..ccfae67 100644 --- a/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift +++ b/iosApp/iosApp/Documents/Components/WarrantiesTabContent.swift @@ -42,6 +42,9 @@ struct WarrantiesTabContent: View { }, onRefresh: { viewModel.loadDocuments(forceRefresh: true) + for await loading in viewModel.$isLoading.values { + if !loading { break } + } }, onRetry: { viewModel.loadDocuments() diff --git a/iosApp/iosApp/Documents/Components/WarrantyCard.swift b/iosApp/iosApp/Documents/Components/WarrantyCard.swift index f541de7..9704602 100644 --- a/iosApp/iosApp/Documents/Components/WarrantyCard.swift +++ b/iosApp/iosApp/Documents/Components/WarrantyCard.swift @@ -4,6 +4,10 @@ import ComposeApp struct WarrantyCard: View { let document: Document + var hasEndDate: Bool { + document.daysUntilExpiration != nil + } + var daysUntilExpiration: Int { Int(document.daysUntilExpiration ?? 0) } @@ -90,7 +94,7 @@ struct WarrantyCard: View { } } - if document.isActive && daysUntilExpiration >= 0 { + if document.isActive && hasEndDate && daysUntilExpiration >= 0 { Text(String(format: L10n.Documents.daysRemainingCount, daysUntilExpiration)) .font(.footnote.weight(.medium)) .foregroundColor(statusColor) diff --git a/iosApp/iosApp/Documents/DocumentDetailView.swift b/iosApp/iosApp/Documents/DocumentDetailView.swift index bf8e75d..c0654fc 100644 --- a/iosApp/iosApp/Documents/DocumentDetailView.swift +++ b/iosApp/iosApp/Documents/DocumentDetailView.swift @@ -14,7 +14,6 @@ struct DocumentDetailView: View { @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 { @@ -43,6 +42,8 @@ struct DocumentDetailView: View { .navigationDestination(isPresented: $navigateToEdit) { if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess { EditDocumentView(document: successState.document) + } else { + Color.clear.onAppear { navigateToEdit = false } } } .toolbar { @@ -82,7 +83,7 @@ struct DocumentDetailView: View { deleteSucceeded = true } } - .onChange(of: deleteSucceeded) { succeeded in + .onChange(of: deleteSucceeded) { _, succeeded in if succeeded { dismiss() } @@ -94,9 +95,14 @@ struct DocumentDetailView: View { selectedIndex: $selectedImageIndex, onDismiss: { showImageViewer = false } ) + } else { + Color.clear.onAppear { showImageViewer = false } } } - .sheet(isPresented: $showShareSheet) { + .sheet(isPresented: Binding( + get: { downloadedFileURL != nil }, + set: { if !$0 { downloadedFileURL = nil } } + )) { if let fileURL = downloadedFileURL { ShareSheet(activityItems: [fileURL]) } @@ -105,6 +111,9 @@ struct DocumentDetailView: View { // MARK: - Download File + // FIX_SKIPPED: LE-4 β€” downloadFile() is an 80-line method performing direct URLSession + // networking inside the view. Fixing requires extracting a dedicated DownloadViewModel + // or DocumentDownloadManager β€” architectural refactor deferred. private func downloadFile(document: Document) { guard let fileUrl = document.fileUrl else { downloadError = "No file URL available" @@ -177,7 +186,6 @@ struct DocumentDetailView: View { await MainActor.run { downloadedFileURL = destinationURL isDownloading = false - showShareSheet = true } } catch { diff --git a/iosApp/iosApp/Documents/DocumentFormView.swift b/iosApp/iosApp/Documents/DocumentFormView.swift index b2b79f7..42ac56c 100644 --- a/iosApp/iosApp/Documents/DocumentFormView.swift +++ b/iosApp/iosApp/Documents/DocumentFormView.swift @@ -216,7 +216,7 @@ struct DocumentFormView: View { } ToolbarItem(placement: .confirmationAction) { - Button(isEditMode ? L10n.Documents.update : L10n.Common.save) { + Button(isEditMode ? L10n.Common.save : L10n.Common.add) { submitForm() } .disabled(!canSave || isProcessing) @@ -232,7 +232,7 @@ struct DocumentFormView: View { } )) } - .onChange(of: selectedPhotoItems) { items in + .onChange(of: selectedPhotoItems) { _, items in Task { selectedImages.removeAll() for item in items { diff --git a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift index 70c1270..d20541a 100644 --- a/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift +++ b/iosApp/iosApp/Documents/DocumentsWarrantiesView.swift @@ -28,7 +28,7 @@ struct DocumentsWarrantiesView: View { if showActiveOnly && doc.isActive != true { return false } - if let category = selectedCategory, doc.category != category { + if let category = selectedCategory, doc.category?.lowercased() != category.lowercased() { return false } return true @@ -38,17 +38,13 @@ struct DocumentsWarrantiesView: View { var documents: [Document] { documentViewModel.documents.filter { doc in guard doc.documentType != "warranty" else { return false } - if let docType = selectedDocType, doc.documentType != docType { + if let docType = selectedDocType, doc.documentType.lowercased() != docType.lowercased() { return false } return true } } - private var shouldShowUpgrade: Bool { - subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") - } - var body: some View { ZStack { WarmGradientBackground() @@ -209,6 +205,8 @@ struct DocumentsWarrantiesView: View { .navigationDestination(isPresented: $navigateToPushDocument) { if let documentId = pushTargetDocumentId { DocumentDetailView(documentId: documentId) + } else { + Color.clear.onAppear { navigateToPushDocument = false } } } } @@ -226,7 +224,13 @@ struct DocumentsWarrantiesView: View { } private func navigateToDocumentFromPush(documentId: Int) { - selectedTab = .warranties + // Look up the document to determine the correct tab + if let document = documentViewModel.documents.first(where: { $0.id?.int32Value == Int32(documentId) }) { + selectedTab = document.documentType == "warranty" ? .warranties : .documents + } else { + // Default to warranties if document not found in cache + selectedTab = .warranties + } pushTargetDocumentId = Int32(documentId) navigateToPushDocument = true PushNotificationManager.shared.pendingNavigationDocumentId = nil diff --git a/iosApp/iosApp/Helpers/L10n.swift b/iosApp/iosApp/Helpers/L10n.swift index b8e3b58..66c62d5 100644 --- a/iosApp/iosApp/Helpers/L10n.swift +++ b/iosApp/iosApp/Helpers/L10n.swift @@ -586,6 +586,7 @@ enum L10n { // MARK: - Common enum Common { static var save: String { String(localized: "common_save") } + static var add: String { String(localized: "common_add") } static var cancel: String { String(localized: "common_cancel") } static var delete: String { String(localized: "common_delete") } static var edit: String { String(localized: "common_edit") } diff --git a/iosApp/iosApp/Helpers/ViewStateHandler.swift b/iosApp/iosApp/Helpers/ViewStateHandler.swift index 252b078..c2290cd 100644 --- a/iosApp/iosApp/Helpers/ViewStateHandler.swift +++ b/iosApp/iosApp/Helpers/ViewStateHandler.swift @@ -44,7 +44,7 @@ struct ViewStateHandler: View { content } } - .onChange(of: error) { errorMessage in + .onChange(of: error) { _, errorMessage in if let errorMessage = errorMessage, !errorMessage.isEmpty { errorAlert = ErrorAlertInfo(message: errorMessage) } @@ -93,7 +93,7 @@ private struct ErrorHandlerModifier: ViewModifier { func body(content: Content) -> some View { content - .onChange(of: error) { errorMessage in + .onChange(of: error) { _, errorMessage in if let errorMessage = errorMessage, !errorMessage.isEmpty { errorAlert = ErrorAlertInfo(message: errorMessage) } diff --git a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift index 4734791..b3536a0 100644 --- a/iosApp/iosApp/Helpers/WidgetActionProcessor.swift +++ b/iosApp/iosApp/Helpers/WidgetActionProcessor.swift @@ -8,6 +8,12 @@ import WidgetKit final class WidgetActionProcessor { static let shared = WidgetActionProcessor() + /// Maximum number of retry attempts per action before giving up + private static let maxRetries = 3 + + /// Tracks retry counts by action description (taskId) + private var retryCounts: [Int: Int] = [:] + private init() {} /// Check if there are pending widget actions to process @@ -65,23 +71,38 @@ final class WidgetActionProcessor { if result is ApiResultSuccess { print("WidgetActionProcessor: Task \(taskId) completed successfully") - // Remove the processed action + // Remove the processed action and clear pending state WidgetDataManager.shared.removeAction(action) - // Clear pending state for this task WidgetDataManager.shared.clearPendingState(forTaskId: taskId) + retryCounts.removeValue(forKey: taskId) // Refresh tasks to update UI await refreshTasks() } else if let error = ApiResultBridge.error(from: result) { print("WidgetActionProcessor: Failed to complete task \(taskId): \(error.message)") - // Remove action to avoid infinite retries - WidgetDataManager.shared.removeAction(action) - WidgetDataManager.shared.clearPendingState(forTaskId: taskId) + handleRetryOrDiscard(taskId: taskId, action: action, reason: error.message) } } catch { print("WidgetActionProcessor: Error completing task \(taskId): \(error)") - // Remove action to avoid retries on error + handleRetryOrDiscard(taskId: taskId, action: action, reason: error.localizedDescription) + } + } + + /// Increment retry count; discard action only after maxRetries. + /// On failure, clear pending state so the task reappears in the widget. + private func handleRetryOrDiscard(taskId: Int, action: WidgetDataManager.WidgetAction, reason: String) { + let attempts = (retryCounts[taskId] ?? 0) + 1 + retryCounts[taskId] = attempts + + if attempts >= Self.maxRetries { + print("WidgetActionProcessor: Task \(taskId) failed after \(attempts) attempts (\(reason)). Discarding action.") WidgetDataManager.shared.removeAction(action) WidgetDataManager.shared.clearPendingState(forTaskId: taskId) + retryCounts.removeValue(forKey: taskId) + } else { + print("WidgetActionProcessor: Task \(taskId) attempt \(attempts)/\(Self.maxRetries) failed (\(reason)). Keeping for retry.") + // Clear pending state so the task is visible in the widget again, + // but keep the action so it will be retried next time the app becomes active. + WidgetDataManager.shared.clearPendingState(forTaskId: taskId) } } diff --git a/iosApp/iosApp/Helpers/WidgetDataManager.swift b/iosApp/iosApp/Helpers/WidgetDataManager.swift index e803339..0535ca5 100644 --- a/iosApp/iosApp/Helpers/WidgetDataManager.swift +++ b/iosApp/iosApp/Helpers/WidgetDataManager.swift @@ -222,10 +222,14 @@ final class WidgetDataManager { fileQueue.async { // Load actions within the serial queue to avoid race conditions var actions: [WidgetAction] - if FileManager.default.fileExists(atPath: fileURL.path), - let data = try? Data(contentsOf: fileURL), - let decoded = try? JSONDecoder().decode([WidgetAction].self, from: data) { - actions = decoded + if FileManager.default.fileExists(atPath: fileURL.path) { + do { + let data = try Data(contentsOf: fileURL) + actions = try JSONDecoder().decode([WidgetAction].self, from: data) + } catch { + print("WidgetDataManager: Failed to decode pending actions: \(error)") + actions = [] + } } else { actions = [] } diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index a2c5dcf..916c3f7 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -42,19 +42,6 @@ NSAllowsLocalNetworking - NSExceptionDomains - - 127.0.0.1 - - NSExceptionAllowsInsecureHTTPLoads - - - localhost - - NSExceptionAllowsInsecureHTTPLoads - - - UIBackgroundModes diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index fe49a53..551b4f7 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -107,10 +107,6 @@ }, "$" : { - }, - "$%@" : { - "comment" : "A label displaying the cost of a task. The argument is the cost of the task.", - "isCommentAutoGenerated" : true }, "000000" : { "comment" : "A placeholder text for a 6-digit code field.", @@ -316,8 +312,8 @@ "comment" : "An alert message displayed when the user taps the \"Archive\" button on a task. It confirms that the user intends to archive the task and provides a hint that the task can be restored later.", "isCommentAutoGenerated" : true }, - "Are you sure you want to cancel this task? This action cannot be undone." : { - "comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in the task details view. It confirms that the user intends to cancel the task and warns them that the action cannot be undone.", + "Are you sure you want to cancel this task? You can undo this later." : { + "comment" : "An alert message displayed when a user taps the \"Cancel Task\" button in a task list. It confirms that the user intends to cancel the task and provides a way to undo the action.", "isCommentAutoGenerated" : true }, "Are you sure you want to remove %@ from this residence?" : { @@ -4298,6 +4294,71 @@ "comment" : "A description of how long the verification code is valid for.", "isCommentAutoGenerated" : true }, + "common_add" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "HinzufΓΌgen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Agregar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajouter" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiungi" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "追加" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "μΆ”κ°€" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Toevoegen" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adicionar" + } + }, + "zh" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加" + } + } + } + }, "common_back" : { "extractionState" : "manual", "localizations" : { diff --git a/iosApp/iosApp/Login/AppleSignInManager.swift b/iosApp/iosApp/Login/AppleSignInManager.swift index 9df45d5..8aaecf6 100644 --- a/iosApp/iosApp/Login/AppleSignInManager.swift +++ b/iosApp/iosApp/Login/AppleSignInManager.swift @@ -117,7 +117,9 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate { extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding { nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { - MainActor.assumeIsolated { + // This method is always called on the main thread by Apple's framework. + // Use DispatchQueue.main.sync as a safe bridge instead of assumeIsolated. + DispatchQueue.main.sync { // Get the key window for presentation guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = scene.windows.first(where: { $0.isKeyWindow }) else { diff --git a/iosApp/iosApp/Login/GoogleSignInManager.swift b/iosApp/iosApp/Login/GoogleSignInManager.swift index c6aa224..e0eee2b 100644 --- a/iosApp/iosApp/Login/GoogleSignInManager.swift +++ b/iosApp/iosApp/Login/GoogleSignInManager.swift @@ -174,8 +174,24 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication return } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let idToken = json["id_token"] as? String else { + let json: [String: Any] + do { + guard let parsed = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + resetOAuthState() + isLoading = false + errorMessage = "Failed to get ID token from Google" + return + } + json = parsed + } catch { + print("GoogleSignInManager: Failed to parse token response JSON: \(error)") + resetOAuthState() + isLoading = false + errorMessage = "Failed to get ID token from Google" + return + } + + guard let idToken = json["id_token"] as? String else { resetOAuthState() isLoading = false errorMessage = "Failed to get ID token from Google" @@ -194,12 +210,14 @@ final class GoogleSignInManager: NSObject, ObservableObject, ASWebAuthentication /// Send Google ID token to backend for verification and authentication private func sendToBackend(idToken: String) async { let request = GoogleSignInRequest(idToken: idToken) - let result = try? await APILayer.shared.googleSignIn(request: request) - - guard let result else { + let result: Any + do { + result = try await APILayer.shared.googleSignIn(request: request) + } catch { + print("GoogleSignInManager: Backend sign-in request failed: \(error)") resetOAuthState() isLoading = false - errorMessage = "Sign in failed. Please try again." + errorMessage = "Sign in failed: \(error.localizedDescription)" return } diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 299b902..a4ede1f 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -1,59 +1,75 @@ import SwiftUI struct MainTabView: View { + enum Tab: Hashable { + case residences + case tasks + case contractors + case documents + } + @EnvironmentObject private var themeManager: ThemeManager - @State private var selectedTab = 0 + @State private var selectedTab: Tab = .residences + @State private var residencesPath = NavigationPath() + @State private var tasksPath = NavigationPath() + @State private var contractorsPath = NavigationPath() + @State private var documentsPath = NavigationPath() @ObservedObject private var authManager = AuthenticationManager.shared @ObservedObject private var pushManager = PushNotificationManager.shared var refreshID: UUID var body: some View { TabView(selection: $selectedTab) { - NavigationStack { + NavigationStack(path: $residencesPath) { ResidencesListView() } .id(refreshID) .tabItem { Label("Residences", image: "tab_view_house") } - .tag(0) + .tag(Tab.residences) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab) - NavigationStack { + NavigationStack(path: $tasksPath) { AllTasksView() } .id(refreshID) .tabItem { Label("Tasks", systemImage: "checklist") } - .tag(1) + .tag(Tab.tasks) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab) - NavigationStack { + NavigationStack(path: $contractorsPath) { ContractorsListView() } .id(refreshID) .tabItem { Label("Contractors", systemImage: "wrench.and.screwdriver.fill") } - .tag(2) + .tag(Tab.contractors) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab) - NavigationStack { + NavigationStack(path: $documentsPath) { DocumentsWarrantiesView(residenceId: nil) } .id(refreshID) .tabItem { Label("Docs", systemImage: "doc.text.fill") } - .tag(3) + .tag(Tab.documents) .accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab) } + .tabViewStyle(.sidebarAdaptable) .tint(Color.appPrimary) .onChange(of: authManager.isAuthenticated) { _, _ in - selectedTab = 0 + selectedTab = .residences } .onAppear { + // FIX_SKIPPED(F-10): UITabBar.appearance() is the standard SwiftUI pattern + // for customizing tab bar appearance. The global side effect persists but + // there is no safe alternative without UIKit hosting. + // Configure tab bar appearance let appearance = UITabBarAppearance() appearance.configureWithOpaqueBackground() @@ -61,18 +77,18 @@ struct MainTabView: View { // Use theme-aware colors appearance.backgroundColor = UIColor(Color.appBackgroundSecondary) - // Selected item + // Selected item β€” uses Dynamic Type caption2 style (A-2) appearance.stackedLayoutAppearance.selected.iconColor = UIColor(Color.appPrimary) appearance.stackedLayoutAppearance.selected.titleTextAttributes = [ .foregroundColor: UIColor(Color.appPrimary), - .font: UIFont.systemFont(ofSize: 10, weight: .semibold) + .font: UIFont.preferredFont(forTextStyle: .caption2) ] - // Normal item + // Normal item β€” uses Dynamic Type caption2 style (A-2) appearance.stackedLayoutAppearance.normal.iconColor = UIColor(Color.appTextSecondary) appearance.stackedLayoutAppearance.normal.titleTextAttributes = [ .foregroundColor: UIColor(Color.appTextSecondary), - .font: UIFont.systemFont(ofSize: 10, weight: .medium) + .font: UIFont.preferredFont(forTextStyle: .caption2) ] UITabBar.appearance().standardAppearance = appearance @@ -80,27 +96,27 @@ struct MainTabView: View { // Handle pending navigation from push notification if pushManager.pendingNavigationTaskId != nil { - selectedTab = 1 + selectedTab = .tasks } else if pushManager.pendingNavigationDocumentId != nil { - selectedTab = 3 + selectedTab = .documents } else if pushManager.pendingNavigationResidenceId != nil { - selectedTab = 0 + selectedTab = .residences } } .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in - selectedTab = 1 + selectedTab = .tasks } .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { _ in - selectedTab = 1 + selectedTab = .tasks } .onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { _ in - selectedTab = 0 + selectedTab = .residences } .onReceive(NotificationCenter.default.publisher(for: .navigateToDocument)) { _ in - selectedTab = 3 + selectedTab = .documents } .onReceive(NotificationCenter.default.publisher(for: .navigateToHome)) { _ in - selectedTab = 0 + selectedTab = .residences } } } diff --git a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift index c8d6fa7..514f428 100644 --- a/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift +++ b/iosApp/iosApp/Onboarding/OnboardingCoordinator.swift @@ -28,12 +28,10 @@ struct OnboardingCoordinator: View { } private func goBack(to step: OnboardingStep) { + isNavigatingBack = true withAnimation(.easeInOut(duration: 0.3)) { - isNavigatingBack = true onboardingState.currentStep = step - } - // Reset after animation completes - DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + } completion: { isNavigatingBack = false } } diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index ca1ae8a..7e1048d 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -505,6 +505,7 @@ struct OnboardingFirstTaskContent: View { // Format today's date as YYYY-MM-DD for the API let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd" let todayString = dateFormatter.string(from: Date()) diff --git a/iosApp/iosApp/Onboarding/OnboardingState.swift b/iosApp/iosApp/Onboarding/OnboardingState.swift index 7c2e699..bd35d99 100644 --- a/iosApp/iosApp/Onboarding/OnboardingState.swift +++ b/iosApp/iosApp/Onboarding/OnboardingState.swift @@ -68,12 +68,12 @@ class OnboardingState: ObservableObject { pendingPostalCode = zip isLoadingTemplates = true Task { + defer { self.isLoadingTemplates = false } let result = try await APILayer.shared.getRegionalTemplates(state: nil, zip: zip) if let success = result as? ApiResultSuccess, let templates = success.data as? [TaskTemplate] { self.regionalTemplates = templates } - self.isLoadingTemplates = false } } diff --git a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift index c875abd..a5af0a4 100644 --- a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift @@ -333,7 +333,12 @@ struct OnboardingSubscriptionContent: View { await MainActor.run { isLoading = false if transaction != nil { - onSubscribe() + // Check if backend verification failed (purchase valid but pending server confirmation) + if let backendError = storeKit.purchaseError { + purchaseError = backendError + } else { + onSubscribe() + } } else { purchaseError = "Purchase was cancelled. You can continue with Free or try again." } diff --git a/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift b/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift index f1c1abb..f0e5c93 100644 --- a/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift +++ b/iosApp/iosApp/PasswordReset/ForgotPasswordView.swift @@ -6,7 +6,6 @@ struct ForgotPasswordView: View { @Environment(\.dismiss) var dismiss var body: some View { - NavigationStack { ZStack { WarmGradientBackground() @@ -185,7 +184,6 @@ struct ForgotPasswordView: View { .onAppear { isEmailFocused = true } - } } } diff --git a/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift b/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift index eadc6d4..d8b8a60 100644 --- a/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift +++ b/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift @@ -11,19 +11,21 @@ struct PasswordResetFlow: View { } var body: some View { - Group { - switch viewModel.currentStep { - case .requestCode: - ForgotPasswordView(viewModel: viewModel) - case .verifyCode: - VerifyResetCodeView(viewModel: viewModel) - case .resetPassword, .loggingIn, .success: - ResetPasswordView(viewModel: viewModel, onSuccess: { - dismiss() - }) + NavigationStack { + Group { + switch viewModel.currentStep { + case .requestCode: + ForgotPasswordView(viewModel: viewModel) + case .verifyCode: + VerifyResetCodeView(viewModel: viewModel) + case .resetPassword, .loggingIn, .success: + ResetPasswordView(viewModel: viewModel, onSuccess: { + dismiss() + }) + } } + .animation(.easeInOut, value: viewModel.currentStep) } - .animation(.easeInOut, value: viewModel.currentStep) .onAppear { // Set up callback for auto-login success // Capture dismiss and onLoginSuccess directly to avoid holding the entire view struct diff --git a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift index 26fba40..d0c23c0 100644 --- a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift +++ b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift @@ -35,7 +35,6 @@ struct ResetPasswordView: View { } var body: some View { - NavigationStack { ZStack { WarmGradientBackground() @@ -326,7 +325,6 @@ struct ResetPasswordView: View { .onAppear { focusedField = .newPassword } - } } } diff --git a/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift b/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift index ded55be..ad98a3a 100644 --- a/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift +++ b/iosApp/iosApp/PasswordReset/VerifyResetCodeView.swift @@ -6,7 +6,6 @@ struct VerifyResetCodeView: View { @Environment(\.dismiss) var dismiss var body: some View { - NavigationStack { ZStack { WarmGradientBackground() @@ -229,7 +228,6 @@ struct VerifyResetCodeView: View { .onAppear { isCodeFocused = true } - } } } diff --git a/iosApp/iosApp/Profile/ProfileViewModel.swift b/iosApp/iosApp/Profile/ProfileViewModel.swift index 5683a82..22a3997 100644 --- a/iosApp/iosApp/Profile/ProfileViewModel.swift +++ b/iosApp/iosApp/Profile/ProfileViewModel.swift @@ -11,7 +11,6 @@ class ProfileViewModel: ObservableObject { @Published var firstName: String = "" @Published var lastName: String = "" @Published var email: String = "" - @Published var isEditing: Bool = false @Published var isLoading: Bool = false @Published var isLoadingUser: Bool = true @Published var errorMessage: String? @@ -29,7 +28,7 @@ class ProfileViewModel: ObservableObject { DataManagerObservable.shared.$currentUser .receive(on: DispatchQueue.main) .sink { [weak self] user in - guard let self, !self.isEditing else { return } + guard let self else { return } if let user = user { self.firstName = user.firstName ?? "" self.lastName = user.lastName ?? "" diff --git a/iosApp/iosApp/PushNotifications/AppDelegate.swift b/iosApp/iosApp/PushNotifications/AppDelegate.swift index 2038369..ae4e7fa 100644 --- a/iosApp/iosApp/PushNotifications/AppDelegate.swift +++ b/iosApp/iosApp/PushNotifications/AppDelegate.swift @@ -6,6 +6,10 @@ import ComposeApp @MainActor class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + /// Throttle subscription refreshes to at most once every 5 minutes + private static var lastSubscriptionRefresh: Date? + private static let subscriptionRefreshInterval: TimeInterval = 300 // 5 minutes + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil @@ -45,10 +49,17 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele // Clear badge when app becomes active PushNotificationManager.shared.clearBadge() - // Refresh StoreKit subscription status when app comes to foreground + // Refresh StoreKit subscription status when app comes to foreground (throttled to every 5 min) // This ensures we have the latest subscription state if it changed while app was in background - Task { - await StoreKitManager.shared.refreshSubscriptionStatus() + let now = Date() + if let lastRefresh = Self.lastSubscriptionRefresh, + now.timeIntervalSince(lastRefresh) < Self.subscriptionRefreshInterval { + // Skip β€” refreshed recently + } else { + Self.lastSubscriptionRefresh = now + Task { + await StoreKitManager.shared.refreshSubscriptionStatus() + } } } diff --git a/iosApp/iosApp/Residence/JoinResidenceView.swift b/iosApp/iosApp/Residence/JoinResidenceView.swift index c8acb42..b2c1886 100644 --- a/iosApp/iosApp/Residence/JoinResidenceView.swift +++ b/iosApp/iosApp/Residence/JoinResidenceView.swift @@ -77,7 +77,7 @@ struct JoinResidenceView: View { RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5) ) - .onChange(of: shareCode) { newValue in + .onChange(of: shareCode) { _, newValue in if newValue.count > 6 { shareCode = String(newValue.prefix(6)) } diff --git a/iosApp/iosApp/Residence/ManageUsersView.swift b/iosApp/iosApp/Residence/ManageUsersView.swift index 2297f3d..a83afd5 100644 --- a/iosApp/iosApp/Residence/ManageUsersView.swift +++ b/iosApp/iosApp/Residence/ManageUsersView.swift @@ -1,6 +1,9 @@ import SwiftUI import ComposeApp +// FIX_SKIPPED: LE-2 β€” This view calls APILayer directly (loadUsers, loadShareCode, +// generateShareCode, removeUser). Fixing requires extracting a dedicated ManageUsersViewModel. +// Architectural refactor deferred β€” requires new ViewModel. struct ManageUsersView: View { let residenceId: Int32 let residenceName: String @@ -36,7 +39,6 @@ struct ManageUsersView: View { if isPrimaryOwner { ShareCodeCard( shareCode: shareCode, - residenceName: residenceName, isGeneratingCode: isGeneratingCode, isGeneratingPackage: sharingManager.isGeneratingPackage, onGenerateCode: generateShareCode, @@ -81,9 +83,9 @@ struct ManageUsersView: View { } } .onAppear { - // Clear share code on appear so it's always blank shareCode = nil loadUsers() + loadShareCode() } .sheet(isPresented: Binding( get: { shareFileURL != nil }, diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index fab8ea2..2dba804 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -19,7 +19,6 @@ struct ResidenceDetailView: View { @State private var showAddTask = false @State private var showEditResidence = false - @State private var showEditTask = false @State private var showManageUsers = false @State private var selectedTaskForEdit: TaskResponse? @State private var selectedTaskForComplete: TaskResponse? @@ -107,10 +106,13 @@ struct ResidenceDetailView: View { EditResidenceView(residence: residence, isPresented: $showEditResidence) } } - .sheet(isPresented: $showEditTask) { - if let task = selectedTaskForEdit { - EditTaskView(task: task, isPresented: $showEditTask) - } + .sheet(item: $selectedTaskForEdit, onDismiss: { + loadResidenceTasks(forceRefresh: true) + }) { task in + EditTaskView(task: task, isPresented: Binding( + get: { selectedTaskForEdit != nil }, + set: { if !$0 { selectedTaskForEdit = nil } } + )) } .sheet(item: $selectedTaskForComplete, onDismiss: { if let task = pendingCompletedTask { @@ -176,31 +178,26 @@ struct ResidenceDetailView: View { } // MARK: onChange & lifecycle - .onChange(of: viewModel.reportMessage) { message in + .onChange(of: viewModel.reportMessage) { _, message in if message != nil { showReportAlert = true } } - .onChange(of: viewModel.selectedResidence) { residence in + .onChange(of: viewModel.selectedResidence) { _, residence in if residence != nil { hasAppeared = true } } - .onChange(of: showAddTask) { isShowing in + .onChange(of: showAddTask) { _, isShowing in if !isShowing { loadResidenceTasks(forceRefresh: true) } } - .onChange(of: showEditResidence) { isShowing in + .onChange(of: showEditResidence) { _, isShowing in if !isShowing { loadResidenceData() } } - .onChange(of: showEditTask) { isShowing in - if !isShowing { - loadResidenceTasks(forceRefresh: true) - } - } .onAppear { loadResidenceData() } @@ -252,7 +249,6 @@ private extension ResidenceDetailView { tasksResponse: tasksResponse, taskViewModel: taskViewModel, selectedTaskForEdit: $selectedTaskForEdit, - showEditTask: $showEditTask, selectedTaskForComplete: $selectedTaskForComplete, selectedTaskForArchive: $selectedTaskForArchive, showArchiveConfirmation: $showArchiveConfirmation, @@ -335,17 +331,6 @@ private extension ResidenceDetailView { } } -// MARK: - Organic Card Button Style - -private struct OrganicCardButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.98 : 1.0) - .opacity(configuration.isPressed ? 0.9 : 1.0) - .animation(.easeOut(duration: 0.15), value: configuration.isPressed) - } -} - // MARK: - Toolbars private extension ResidenceDetailView { @@ -466,20 +451,23 @@ private extension ResidenceDetailView { } } + // FIX_SKIPPED: LE-3 β€” deleteResidence() calls APILayer.shared.deleteResidence() directly + // from the view. ResidenceViewModel does not expose a delete method. Fixing requires adding + // deleteResidence() to the shared ViewModel layer β€” architectural refactor deferred. func deleteResidence() { guard TokenStorage.shared.getToken() != nil else { return } isDeleting = true - + Task { do { let result = try await APILayer.shared.deleteResidence( id: Int32(Int(residenceId)) ) - + await MainActor.run { self.isDeleting = false - + if result is ApiResultSuccess { dismiss() } else if let errorResult = ApiResultBridge.error(from: result) { @@ -537,7 +525,6 @@ private struct TasksSectionContainer: View { @ObservedObject var taskViewModel: TaskViewModel @Binding var selectedTaskForEdit: TaskResponse? - @Binding var showEditTask: Bool @Binding var selectedTaskForComplete: TaskResponse? @Binding var selectedTaskForArchive: TaskResponse? @Binding var showArchiveConfirmation: Bool @@ -556,7 +543,6 @@ private struct TasksSectionContainer: View { tasksResponse: tasksResponse, onEditTask: { task in selectedTaskForEdit = task - showEditTask = true }, onCancelTask: { task in selectedTaskForCancel = task diff --git a/iosApp/iosApp/Residence/ResidenceViewModel.swift b/iosApp/iosApp/Residence/ResidenceViewModel.swift index ab47541..e02893e 100644 --- a/iosApp/iosApp/Residence/ResidenceViewModel.swift +++ b/iosApp/iosApp/Residence/ResidenceViewModel.swift @@ -288,11 +288,6 @@ class ResidenceViewModel: ObservableObject { errorMessage = nil } - func loadResidenceContractors(residenceId: Int32) { - // This can now be handled directly via APILayer if needed - // or through DataManagerObservable.shared.contractors - } - func joinWithCode(code: String, completion: @escaping (Bool) -> Void) { isLoading = true errorMessage = nil diff --git a/iosApp/iosApp/Residence/ResidencesListView.swift b/iosApp/iosApp/Residence/ResidencesListView.swift index 2323f28..f111ce8 100644 --- a/iosApp/iosApp/Residence/ResidencesListView.swift +++ b/iosApp/iosApp/Residence/ResidencesListView.swift @@ -9,10 +9,8 @@ struct ResidencesListView: View { @State private var showingUpgradePrompt = false @State private var showingSettings = false @State private var pushTargetResidenceId: Int32? - @State private var showLoginCover = false @StateObject private var authManager = AuthenticationManager.shared @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared - @Environment(\.scenePhase) private var scenePhase var body: some View { ZStack { @@ -32,6 +30,9 @@ struct ResidencesListView: View { }, onRefresh: { viewModel.loadMyResidences(forceRefresh: true) + for await loading in viewModel.$isLoading.values { + if !loading { break } + } }, onRetry: { viewModel.loadMyResidences() @@ -113,36 +114,19 @@ struct ResidencesListView: View { viewModel.loadMyResidences() // Also load tasks to populate summary stats taskViewModel.loadTasks() - } else { - showLoginCover = true } } - .onChange(of: scenePhase) { newPhase in - // Refresh data when app comes back from background - if newPhase == .active && authManager.isAuthenticated { - viewModel.loadMyResidences(forceRefresh: true) - taskViewModel.loadTasks(forceRefresh: true) - } - } - .fullScreenCover(isPresented: $showLoginCover) { - LoginView(onLoginSuccess: { - authManager.isAuthenticated = true - showLoginCover = false - viewModel.loadMyResidences() - taskViewModel.loadTasks() - }) - .interactiveDismissDisabled() - } - .onChange(of: authManager.isAuthenticated) { isAuth in + // P-5: Removed redundant .onChange(of: scenePhase) handler. + // iOSApp.swift already handles foreground refresh globally, so per-view + // scenePhase handlers fire duplicate network requests. + .onChange(of: authManager.isAuthenticated) { _, isAuth in if isAuth { // User just logged in or registered - load their residences and tasks - showLoginCover = false viewModel.loadMyResidences() taskViewModel.loadTasks() } else { - // User logged out - clear data and show login + // User logged out - clear data viewModel.myResidences = nil - showLoginCover = true } } .onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in @@ -150,13 +134,8 @@ struct ResidencesListView: View { navigateToResidenceFromPush(residenceId: residenceId) } } - .navigationDestination(isPresented: Binding( - get: { pushTargetResidenceId != nil }, - set: { if !$0 { pushTargetResidenceId = nil } } - )) { - if let residenceId = pushTargetResidenceId { - ResidenceDetailView(residenceId: residenceId) - } + .navigationDestination(item: $pushTargetResidenceId) { residenceId in + ResidenceDetailView(residenceId: residenceId) } } @@ -253,17 +232,6 @@ private struct ResidencesContent: View { } } -// MARK: - Organic Card Button Style - -private struct OrganicCardButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .scaleEffect(configuration.isPressed ? 0.98 : 1.0) - .opacity(configuration.isPressed ? 0.9 : 1.0) - .animation(.easeOut(duration: 0.15), value: configuration.isPressed) - } -} - // MARK: - Organic Empty Residences View private struct OrganicEmptyResidencesView: View { diff --git a/iosApp/iosApp/ResidenceFormView.swift b/iosApp/iosApp/ResidenceFormView.swift index 152740e..782b76a 100644 --- a/iosApp/iosApp/ResidenceFormView.swift +++ b/iosApp/iosApp/ResidenceFormView.swift @@ -253,7 +253,7 @@ struct ResidenceFormView: View { if viewModel.isLoading { ProgressView() } else { - Text(L10n.Common.save) + Text(isEditMode ? L10n.Common.save : L10n.Common.add) } } .disabled(!canSave || viewModel.isLoading) diff --git a/iosApp/iosApp/RootView.swift b/iosApp/iosApp/RootView.swift index 06937cb..6d59acb 100644 --- a/iosApp/iosApp/RootView.swift +++ b/iosApp/iosApp/RootView.swift @@ -190,7 +190,7 @@ struct RootView: View { // Show main app ZStack(alignment: .topLeading) { MainTabView(refreshID: refreshID) - .onChange(of: themeManager.currentTheme) { _ in + .onChange(of: themeManager.currentTheme) { _, _ in refreshID = UUID() } Color.clear diff --git a/iosApp/iosApp/Shared/Extensions/ColorExtensions.swift b/iosApp/iosApp/Shared/Extensions/ColorExtensions.swift new file mode 100644 index 0000000..862a461 --- /dev/null +++ b/iosApp/iosApp/Shared/Extensions/ColorExtensions.swift @@ -0,0 +1,20 @@ +import SwiftUI + +// MARK: - Task Category Colors + +extension Color { + /// Returns the semantic color for a given task category name. + /// Shared across all views that display category-colored elements. + static func taskCategoryColor(for categoryName: String) -> Color { + switch categoryName.lowercased() { + case "plumbing": return Color.appSecondary + case "safety", "electrical": return Color.appError + case "hvac": return Color.appPrimary + case "appliances": return Color.appAccent + case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green + case "interior": return Color(hex: "#AF52DE") ?? .purple + case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange + default: return Color.appPrimary + } + } +} diff --git a/iosApp/iosApp/Shared/Extensions/DateExtensions.swift b/iosApp/iosApp/Shared/Extensions/DateExtensions.swift index ab50a99..b971fee 100644 --- a/iosApp/iosApp/Shared/Extensions/DateExtensions.swift +++ b/iosApp/iosApp/Shared/Extensions/DateExtensions.swift @@ -4,7 +4,7 @@ import Foundation extension Date { /// Formats date as "MMM d, yyyy" (e.g., "Jan 15, 2024") - func formatted() -> String { + func formattedMedium() -> String { DateFormatters.shared.mediumDate.string(from: self) } @@ -75,7 +75,7 @@ extension String { /// Converts API date string to formatted display string (e.g., "Jan 2, 2025") func toFormattedDate() -> String { guard let date = self.toDate() else { return self } - return date.formatted() + return date.formattedMedium() } /// Checks if date string represents an overdue date diff --git a/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift b/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift index 35d73be..6cab652 100644 --- a/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift +++ b/iosApp/iosApp/Shared/Extensions/DoubleExtensions.swift @@ -15,29 +15,52 @@ extension KotlinDouble { } } +// MARK: - Cached NumberFormatters + +/// Static cached NumberFormatter instances to avoid per-call allocation overhead. +/// These formatters are mutated per-call for variable parameters (fractionDigits, currencyCode) +/// and are safe because all callers run on the main thread (@MainActor ViewModels / SwiftUI views). +private enum CachedFormatters { + static let currency: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .currency + f.currencyCode = "USD" + return f + }() + + static let decimal: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + return f + }() + + static let percent: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .percent + return f + }() +} + // MARK: - Double Extensions for Currency and Number Formatting extension Double { /// Formats as currency (e.g., "$1,234.56") func toCurrency() -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency + let formatter = CachedFormatters.currency formatter.currencyCode = "USD" return formatter.string(from: NSNumber(value: self)) ?? "$\(self)" } /// Formats as currency with currency symbol (e.g., "$1,234.56") func toCurrencyString(currencyCode: String = "USD") -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .currency + let formatter = CachedFormatters.currency formatter.currencyCode = currencyCode return formatter.string(from: NSNumber(value: self)) ?? "$\(self)" } /// Formats with comma separators (e.g., "1,234.56") func toDecimalString(fractionDigits: Int = 2) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal + let formatter = CachedFormatters.decimal formatter.minimumFractionDigits = fractionDigits formatter.maximumFractionDigits = fractionDigits return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self) @@ -45,8 +68,7 @@ extension Double { /// Formats as percentage (e.g., "45.5%") func toPercentage(fractionDigits: Int = 1) -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .percent + let formatter = CachedFormatters.percent formatter.minimumFractionDigits = fractionDigits formatter.maximumFractionDigits = fractionDigits return formatter.string(from: NSNumber(value: self / 100)) ?? "\(self)%" @@ -78,8 +100,9 @@ extension Double { extension Int { /// Formats with comma separators (e.g., "1,234") func toFormattedString() -> String { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal + let formatter = CachedFormatters.decimal + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 0 return formatter.string(from: NSNumber(value: self)) ?? "\(self)" } diff --git a/iosApp/iosApp/Shared/Extensions/StringExtensions.swift b/iosApp/iosApp/Shared/Extensions/StringExtensions.swift index 8d2fd75..4c07e2a 100644 --- a/iosApp/iosApp/Shared/Extensions/StringExtensions.swift +++ b/iosApp/iosApp/Shared/Extensions/StringExtensions.swift @@ -36,7 +36,7 @@ extension String { /// Validates phone number (basic check) var isValidPhone: Bool { - let phoneRegex = "^[0-9+\\-\\(\\)\\s]{10,}$" + let phoneRegex = "^(?=.*[0-9])[0-9+\\-\\(\\)\\s]{10,}$" let phonePredicate = NSPredicate(format: "SELF MATCHES %@", phoneRegex) return phonePredicate.evaluate(with: self) } diff --git a/iosApp/iosApp/Shared/Modifiers/CardModifiers.swift b/iosApp/iosApp/Shared/Modifiers/CardModifiers.swift index f1969b9..cd2041e 100644 --- a/iosApp/iosApp/Shared/Modifiers/CardModifiers.swift +++ b/iosApp/iosApp/Shared/Modifiers/CardModifiers.swift @@ -135,6 +135,17 @@ extension View { } } +// MARK: - Organic Card Button Style + +struct OrganicCardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.98 : 1.0) + .opacity(configuration.isPressed ? 0.9 : 1.0) + .animation(.easeOut(duration: 0.15), value: configuration.isPressed) + } +} + // MARK: - Metadata Pill Styles struct MetadataPillStyle: ViewModifier { diff --git a/iosApp/iosApp/Subscription/FeatureComparisonView.swift b/iosApp/iosApp/Subscription/FeatureComparisonView.swift index 2421bf7..a1b3c65 100644 --- a/iosApp/iosApp/Subscription/FeatureComparisonView.swift +++ b/iosApp/iosApp/Subscription/FeatureComparisonView.swift @@ -6,11 +6,8 @@ struct FeatureComparisonView: View { @Binding var isPresented: Bool @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var storeKit = StoreKitManager.shared + @StateObject private var purchaseHelper = SubscriptionPurchaseHelper() @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 /// Whether the user is already subscribed from a non-iOS platform private var isSubscribedOnOtherPlatform: Bool { @@ -124,11 +121,10 @@ struct FeatureComparisonView: View { ForEach(storeKit.products, id: \.id) { product in SubscriptionButton( product: product, - isSelected: selectedProduct?.id == product.id, - isProcessing: isProcessing, + isSelected: purchaseHelper.selectedProduct?.id == product.id, + isProcessing: purchaseHelper.isProcessing, onSelect: { - selectedProduct = product - handlePurchase(product) + purchaseHelper.handlePurchase(product) } ) } @@ -151,7 +147,7 @@ struct FeatureComparisonView: View { } // Error Message - if let error = errorMessage { + if let error = purchaseHelper.errorMessage { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(Color.appError) @@ -168,7 +164,7 @@ struct FeatureComparisonView: View { // Restore Purchases if !isSubscribedOnOtherPlatform { Button(action: { - handleRestore() + purchaseHelper.handleRestore() }) { Text("Restore Purchases") .font(.caption) @@ -187,7 +183,7 @@ struct FeatureComparisonView: View { } } } - .alert("Subscription Active", isPresented: $showSuccessAlert) { + .alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) { Button("Done") { isPresented = false } @@ -200,50 +196,6 @@ struct FeatureComparisonView: View { } } - // 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 diff --git a/iosApp/iosApp/Subscription/StoreKitManager.swift b/iosApp/iosApp/Subscription/StoreKitManager.swift index a1a2836..84f63df 100644 --- a/iosApp/iosApp/Subscription/StoreKitManager.swift +++ b/iosApp/iosApp/Subscription/StoreKitManager.swift @@ -94,6 +94,7 @@ class StoreKitManager: ObservableObject { print("βœ… StoreKit: Purchase successful for \(product.id)") } catch { print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)") + self.purchaseError = "Purchase successful but verification is pending. It will complete automatically." } return transaction diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index f48bc58..d6b340c 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -32,7 +32,18 @@ class SubscriptionCacheWrapper: ObservableObject { if let subscription = currentSubscription, let expiresAt = subscription.expiresAt, !expiresAt.isEmpty { - return "pro" + // Parse the date and check if subscription is still active + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let expiryDate = formatter.date(from: expiresAt) ?? ISO8601DateFormatter().date(from: expiresAt) { + if expiryDate > Date() { + return "pro" + } + // Expired β€” fall through to StoreKit check + } else { + // Can't parse date but backend says there's a subscription + return "pro" + } } // Fallback to local StoreKit entitlements. diff --git a/iosApp/iosApp/Subscription/SubscriptionPurchaseHelper.swift b/iosApp/iosApp/Subscription/SubscriptionPurchaseHelper.swift new file mode 100644 index 0000000..8d2b27d --- /dev/null +++ b/iosApp/iosApp/Subscription/SubscriptionPurchaseHelper.swift @@ -0,0 +1,63 @@ +import Foundation +import StoreKit + +/// Shared purchase/restore logic used by FeatureComparisonView, UpgradeFeatureView, and UpgradePromptView. +/// Each view owns an instance via @StateObject and binds its published properties to the UI. +@MainActor +final class SubscriptionPurchaseHelper: ObservableObject { + @Published var isProcessing = false + @Published var errorMessage: String? + @Published var showSuccessAlert = false + @Published var selectedProduct: Product? + + private var storeKit: StoreKitManager { StoreKitManager.shared } + + func handlePurchase(_ product: Product) { + selectedProduct = product + isProcessing = true + errorMessage = nil + + Task { + do { + let transaction = try await storeKit.purchase(product) + + await MainActor.run { + isProcessing = false + + if transaction != nil { + // Check if backend verification failed (purchase is valid but pending server confirmation) + if let backendError = storeKit.purchaseError { + errorMessage = backendError + } else { + showSuccessAlert = true + } + } + } + } catch { + await MainActor.run { + isProcessing = false + errorMessage = "Purchase failed: \(error.localizedDescription)" + } + } + } + } + + 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" + } + } + } + } +} diff --git a/iosApp/iosApp/Subscription/UpgradeFeatureView.swift b/iosApp/iosApp/Subscription/UpgradeFeatureView.swift index 8d253a8..63d9c22 100644 --- a/iosApp/iosApp/Subscription/UpgradeFeatureView.swift +++ b/iosApp/iosApp/Subscription/UpgradeFeatureView.swift @@ -7,13 +7,10 @@ struct UpgradeFeatureView: View { let icon: String @State private var showFeatureComparison = false - @State private var isProcessing = false - @State private var selectedProduct: Product? - @State private var errorMessage: String? - @State private var showSuccessAlert = false @State private var isAnimating = false @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var storeKit = StoreKitManager.shared + @StateObject private var purchaseHelper = SubscriptionPurchaseHelper() private var triggerData: UpgradeTriggerData? { subscriptionCache.upgradeTriggers[triggerKey] @@ -155,11 +152,10 @@ struct UpgradeFeatureView: View { ForEach(storeKit.products, id: \.id) { product in SubscriptionProductButton( product: product, - isSelected: selectedProduct?.id == product.id, - isProcessing: isProcessing, + isSelected: purchaseHelper.selectedProduct?.id == product.id, + isProcessing: purchaseHelper.isProcessing, onSelect: { - selectedProduct = product - handlePurchase(product) + purchaseHelper.handlePurchase(product) } ) } @@ -184,7 +180,7 @@ struct UpgradeFeatureView: View { } // Error Message - if let error = errorMessage { + if let error = purchaseHelper.errorMessage { HStack(spacing: 10) { Image(systemName: "exclamationmark.circle.fill") .foregroundColor(Color.appError) @@ -211,7 +207,7 @@ struct UpgradeFeatureView: View { if !isSubscribedOnOtherPlatform { Button(action: { - handleRestore() + purchaseHelper.handleRestore() }) { Text("Restore Purchases") .font(.system(size: 13, weight: .medium)) @@ -227,7 +223,7 @@ struct UpgradeFeatureView: View { .sheet(isPresented: $showFeatureComparison) { FeatureComparisonView(isPresented: $showFeatureComparison) } - .alert("Subscription Active", isPresented: $showSuccessAlert) { + .alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) { Button("Done") { } } message: { Text("You now have full access to all Pro features!") @@ -241,48 +237,6 @@ struct UpgradeFeatureView: View { } } - 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: - Organic Feature Row diff --git a/iosApp/iosApp/Subscription/UpgradePromptView.swift b/iosApp/iosApp/Subscription/UpgradePromptView.swift index 8ff984d..46aac0d 100644 --- a/iosApp/iosApp/Subscription/UpgradePromptView.swift +++ b/iosApp/iosApp/Subscription/UpgradePromptView.swift @@ -124,11 +124,8 @@ struct UpgradePromptView: View { @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @StateObject private var storeKit = StoreKitManager.shared + @StateObject private var purchaseHelper = SubscriptionPurchaseHelper() @State private var showFeatureComparison = false - @State private var isProcessing = false - @State private var selectedProduct: Product? - @State private var errorMessage: String? - @State private var showSuccessAlert = false @State private var isAnimating = false var triggerData: UpgradeTriggerData? { @@ -263,11 +260,10 @@ struct UpgradePromptView: View { ForEach(storeKit.products, id: \.id) { product in OrganicSubscriptionButton( product: product, - isSelected: selectedProduct?.id == product.id, - isProcessing: isProcessing, + isSelected: purchaseHelper.selectedProduct?.id == product.id, + isProcessing: purchaseHelper.isProcessing, onSelect: { - selectedProduct = product - handlePurchase(product) + purchaseHelper.handlePurchase(product) } ) } @@ -292,7 +288,7 @@ struct UpgradePromptView: View { } // Error Message - if let error = errorMessage { + if let error = purchaseHelper.errorMessage { HStack(spacing: 10) { Image(systemName: "exclamationmark.circle.fill") .foregroundColor(Color.appError) @@ -319,7 +315,7 @@ struct UpgradePromptView: View { if !isSubscribedOnOtherPlatform { Button(action: { - handleRestore() + purchaseHelper.handleRestore() }) { Text("Restore Purchases") .font(.system(size: 13, weight: .medium)) @@ -347,7 +343,7 @@ struct UpgradePromptView: View { .sheet(isPresented: $showFeatureComparison) { FeatureComparisonView(isPresented: $showFeatureComparison) } - .alert("Subscription Active", isPresented: $showSuccessAlert) { + .alert("Subscription Active", isPresented: $purchaseHelper.showSuccessAlert) { Button("Done") { isPresented = false } @@ -364,48 +360,6 @@ struct UpgradePromptView: View { } } - 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: - Organic Feature Row diff --git a/iosApp/iosApp/Subviews/Common/HomeNavigationCard.swift b/iosApp/iosApp/Subviews/Common/HomeNavigationCard.swift index 3cb977e..27fa479 100644 --- a/iosApp/iosApp/Subviews/Common/HomeNavigationCard.swift +++ b/iosApp/iosApp/Subviews/Common/HomeNavigationCard.swift @@ -23,7 +23,6 @@ struct HomeNavigationCard: View { VStack(alignment: .leading, spacing: AppSpacing.xxs) { Text(title) .font(.title3.weight(.semibold)) - .fontWeight(.semibold) .foregroundColor(Color.appTextPrimary) Text(subtitle) diff --git a/iosApp/iosApp/Subviews/Common/MyCribIconView.swift b/iosApp/iosApp/Subviews/Common/MyCribIconView.swift index 8e4b207..94af582 100644 --- a/iosApp/iosApp/Subviews/Common/MyCribIconView.swift +++ b/iosApp/iosApp/Subviews/Common/MyCribIconView.swift @@ -181,8 +181,8 @@ struct MyCribIconView: View { .fill( LinearGradient( colors: [ - Color(red: 1.0, green: 0.64, blue: 0.28), // #FFA347 - Color(red: 0.96, green: 0.51, blue: 0.20) // #F58233 + backgroundColor.opacity(1.0), + backgroundColor.opacity(0.85) ], startPoint: .top, endPoint: .bottom diff --git a/iosApp/iosApp/Subviews/Common/StatView.swift b/iosApp/iosApp/Subviews/Common/StatView.swift index 0a6acbf..dde2f21 100644 --- a/iosApp/iosApp/Subviews/Common/StatView.swift +++ b/iosApp/iosApp/Subviews/Common/StatView.swift @@ -1,11 +1,36 @@ import SwiftUI struct StatView: View { - let icon: String + enum IconType { + case system(String) + case asset(String) + } + + let icon: IconType let value: String let label: String var color: Color = Color.appPrimary + /// Convenience initializer that accepts a plain string for backward compatibility. + /// Asset names are detected automatically; everything else is treated as an SF Symbol. + init(icon: String, value: String, label: String, color: Color = Color.appPrimary) { + if icon == "house_outline" { + self.icon = .asset(icon) + } else { + self.icon = .system(icon) + } + self.value = value + self.label = label + self.color = color + } + + init(icon: IconType, value: String, label: String, color: Color = Color.appPrimary) { + self.icon = icon + self.value = value + self.label = label + self.color = color + } + var body: some View { VStack(spacing: OrganicSpacing.compact) { ZStack { @@ -13,15 +38,16 @@ struct StatView: View { .fill(color.opacity(0.1)) .frame(width: 52, height: 52) - if icon == "house_outline" { - Image("house_outline") + switch icon { + case .asset(let name): + Image(name) .renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 24, height: 24) .foregroundColor(color) - } else { - Image(systemName: icon) + case .system(let name): + Image(systemName: name) .font(.system(size: 22, weight: .semibold)) .foregroundColor(color) } diff --git a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift index c0f7896..f2c74b8 100644 --- a/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ResidenceCard.swift @@ -183,38 +183,6 @@ private struct PropertyIconView: View { } } -// MARK: - Pulse Ring Animation - -private struct PulseRing: View { - @State private var isAnimating = false - @State private var isPulsing = false - @Environment(\.accessibilityReduceMotion) private var reduceMotion - - var body: some View { - Circle() - .stroke(Color.appError.opacity(0.6), lineWidth: 2) - .frame(width: 60, height: 60) - .scaleEffect(isPulsing ? 1.15 : 1.0) - .opacity(isPulsing ? 0 : 1) - .animation( - reduceMotion - ? .easeOut(duration: 1.5) - : isAnimating - ? Animation.easeOut(duration: 1.5).repeatForever(autoreverses: false) - : .default, - value: isPulsing - ) - .onAppear { - isAnimating = true - isPulsing = true - } - .onDisappear { - isAnimating = false - isPulsing = false - } - } -} - // MARK: - Primary Badge private struct PrimaryBadgeView: View { diff --git a/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift b/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift index a58ae0c..70b01b1 100644 --- a/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift +++ b/iosApp/iosApp/Subviews/Residence/ShareCodeCard.swift @@ -4,7 +4,6 @@ import ComposeApp // MARK: - Share Code Card struct ShareCodeCard: View { let shareCode: ShareCodeResponse? - let residenceName: String let isGeneratingCode: Bool let isGeneratingPackage: Bool let onGenerateCode: () -> Void @@ -131,7 +130,6 @@ struct ShareCodeCard: View { #Preview { ShareCodeCard( shareCode: nil, - residenceName: "My Home", isGeneratingCode: false, isGeneratingPackage: false, onGenerateCode: {}, diff --git a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift index f6f9f07..6b12b0f 100644 --- a/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/DynamicTaskCard.swift @@ -182,49 +182,63 @@ struct DynamicTaskCard: View { switch buttonType { case "mark_in_progress": Button { - print("πŸ”΅ Mark In Progress tapped for task: \(task.id)") + #if DEBUG + print("Mark In Progress tapped for task: \(task.id)") + #endif onMarkInProgress() } label: { Label("Mark Task In Progress", systemImage: "play.circle") } case "complete": Button { - print("βœ… Complete tapped for task: \(task.id)") + #if DEBUG + print("Complete tapped for task: \(task.id)") + #endif onComplete() } label: { Label("Complete Task", systemImage: "checkmark.circle") } case "edit": Button { - print("✏️ Edit tapped for task: \(task.id)") + #if DEBUG + print("Edit tapped for task: \(task.id)") + #endif onEdit() } label: { Label("Edit Task", systemImage: "pencil") } case "cancel": Button(role: .destructive) { - print("❌ Cancel tapped for task: \(task.id)") + #if DEBUG + print("Cancel tapped for task: \(task.id)") + #endif onCancel() } label: { Label("Cancel Task", systemImage: "xmark.circle") } case "uncancel": Button { - print("πŸ”„ Restore tapped for task: \(task.id)") + #if DEBUG + print("Restore tapped for task: \(task.id)") + #endif onUncancel() } label: { Label("Restore Task", systemImage: "arrow.uturn.backward.circle") } case "archive": Button { - print("πŸ“¦ Archive tapped for task: \(task.id)") + #if DEBUG + print("Archive tapped for task: \(task.id)") + #endif onArchive() } label: { Label("Archive Task", systemImage: "archivebox") } case "unarchive": Button { - print("πŸ“€ Unarchive tapped for task: \(task.id)") + #if DEBUG + print("Unarchive tapped for task: \(task.id)") + #endif onUnarchive() } label: { Label("Unarchive Task", systemImage: "arrow.up.bin") diff --git a/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift b/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift index 7259ffe..a41cd83 100644 --- a/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift +++ b/iosApp/iosApp/Subviews/Task/TaskActionButtons.swift @@ -1,8 +1,7 @@ import SwiftUI import ComposeApp -// TODO: (P5) Each action button that performs an API call creates its own @StateObject TaskViewModel instance. -// This is potentially wasteful β€” consider accepting a shared TaskViewModel from the parent view instead. +// Action buttons accept a shared TaskViewModel from the parent view to avoid redundant instances. // MARK: - Edit Task Button struct EditTaskButton: View { @@ -29,7 +28,7 @@ struct CancelTaskButton: View { let onCompletion: () -> Void let onError: (String) -> Void - @StateObject private var viewModel = TaskViewModel() + let viewModel: TaskViewModel @State private var showConfirmation = false var body: some View { @@ -54,7 +53,7 @@ struct CancelTaskButton: View { } } } message: { - Text("Are you sure you want to cancel this task? This action cannot be undone.") + Text("Are you sure you want to cancel this task? You can undo this later.") } } } @@ -65,7 +64,7 @@ struct UncancelTaskButton: View { let onCompletion: () -> Void let onError: (String) -> Void - @StateObject private var viewModel = TaskViewModel() + let viewModel: TaskViewModel var body: some View { Button(action: { @@ -92,7 +91,7 @@ struct MarkInProgressButton: View { let onCompletion: () -> Void let onError: (String) -> Void - @StateObject private var viewModel = TaskViewModel() + let viewModel: TaskViewModel var body: some View { Button(action: { @@ -148,7 +147,7 @@ struct ArchiveTaskButton: View { let onCompletion: () -> Void let onError: (String) -> Void - @StateObject private var viewModel = TaskViewModel() + let viewModel: TaskViewModel @State private var showConfirmation = false var body: some View { @@ -184,7 +183,7 @@ struct UnarchiveTaskButton: View { let onCompletion: () -> Void let onError: (String) -> Void - @StateObject private var viewModel = TaskViewModel() + let viewModel: TaskViewModel var body: some View { Button(action: { diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index 6840e0c..1f031ed 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -82,7 +82,7 @@ struct TasksSection: View { } .scrollTargetBehavior(.viewAligned) } - .frame(height: 500) + .frame(height: min(500, UIScreen.main.bounds.height * 0.55)) } } } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 1011a4b..3fadfa3 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -2,12 +2,10 @@ import SwiftUI import ComposeApp struct AllTasksView: View { - @Environment(\.scenePhase) private var scenePhase @StateObject private var taskViewModel = TaskViewModel() @StateObject private var residenceViewModel = ResidenceViewModel() @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @State private var showAddTask = false - @State private var showEditTask = false @State private var showingUpgradePrompt = false @State private var selectedTaskForEdit: TaskResponse? @State private var selectedTaskForComplete: TaskResponse? @@ -50,10 +48,11 @@ struct AllTasksView: View { residences: residenceViewModel.myResidences?.residences ?? [] ) } - .sheet(isPresented: $showEditTask) { - if let task = selectedTaskForEdit { - EditTaskView(task: task, isPresented: $showEditTask) - } + .sheet(item: $selectedTaskForEdit) { task in + EditTaskView(task: task, isPresented: Binding( + get: { selectedTaskForEdit != nil }, + set: { if !$0 { selectedTaskForEdit = nil } } + )) } .sheet(item: $selectedTaskForComplete, onDismiss: { if let task = pendingCompletedTask { @@ -138,19 +137,15 @@ struct AllTasksView: View { } } } - .onChange(of: tasksResponse) { response in + .onChange(of: tasksResponse) { _, response in if let taskId = pendingTaskId, let response = response { navigateToTaskInKanban(taskId: taskId, response: response) } } - .onChange(of: scenePhase) { newPhase in - if newPhase == .active { - if WidgetDataManager.shared.areTasksDirty() { - WidgetDataManager.shared.clearDirtyFlag() - loadAllTasks(forceRefresh: true) - } - } - } + // P-5: Removed redundant .onChange(of: scenePhase) handler. + // iOSApp.swift already handles foreground refresh and widget dirty-flag + // processing globally, so per-view scenePhase handlers fire duplicate + // network requests. } @ViewBuilder @@ -185,7 +180,6 @@ struct AllTasksView: View { column: column, onEditTask: { task in selectedTaskForEdit = task - showEditTask = true }, onCancelTask: { task in selectedTaskForCancel = task @@ -240,7 +234,7 @@ struct AllTasksView: View { .padding(16) } .scrollTargetBehavior(.viewAligned) - .onChange(of: scrollToColumnIndex) { columnIndex in + .onChange(of: scrollToColumnIndex) { _, columnIndex in if let columnIndex = columnIndex { withAnimation(.easeInOut(duration: 0.3)) { proxy.scrollTo(columnIndex, anchor: .leading) @@ -328,12 +322,16 @@ struct AllTasksView: View { private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) { for (index, column) in response.columns.enumerated() { - if column.tasks.contains(where: { $0.id == taskId }) { + if let task = column.tasks.first(where: { $0.id == taskId }) { pendingTaskId = nil PushNotificationManager.shared.clearPendingNavigation() DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.scrollToColumnIndex = index } + // Open the edit sheet for this task so the user sees its details + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { + self.selectedTaskForEdit = task + } return } } diff --git a/iosApp/iosApp/Task/CompleteTaskView.swift b/iosApp/iosApp/Task/CompleteTaskView.swift index d8ffa05..dc91a55 100644 --- a/iosApp/iosApp/Task/CompleteTaskView.swift +++ b/iosApp/iosApp/Task/CompleteTaskView.swift @@ -2,6 +2,11 @@ import SwiftUI import PhotosUI import ComposeApp +/// Wrapper to retain the Kotlin ViewModel via @StateObject +private class CompletionViewModelHolder: ObservableObject { + let vm = ComposeApp.TaskCompletionViewModel() +} + struct CompleteTaskView: View { let task: TaskResponse let onComplete: (TaskResponse?) -> Void // Pass back updated task @@ -9,7 +14,8 @@ struct CompleteTaskView: View { @Environment(\.dismiss) private var dismiss @StateObject private var taskViewModel = TaskViewModel() @StateObject private var contractorViewModel = ContractorViewModel() - private let completionViewModel = ComposeApp.TaskCompletionViewModel() + @StateObject private var completionHolder = CompletionViewModelHolder() + private var completionViewModel: ComposeApp.TaskCompletionViewModel { completionHolder.vm } @State private var completedByName: String = "" @State private var actualCost: String = "" @State private var notes: String = "" @@ -200,7 +206,7 @@ struct CompleteTaskView: View { } .buttonStyle(.bordered) } - .onChange(of: selectedItems) { newItems in + .onChange(of: selectedItems) { _, newItems in Task { selectedImages = [] for item in newItems { diff --git a/iosApp/iosApp/Task/CompletionHistorySheet.swift b/iosApp/iosApp/Task/CompletionHistorySheet.swift index ea3183b..cddcee8 100644 --- a/iosApp/iosApp/Task/CompletionHistorySheet.swift +++ b/iosApp/iosApp/Task/CompletionHistorySheet.swift @@ -262,7 +262,7 @@ struct CompletionHistoryCard: View { .foregroundColor(Color.appPrimary) } - Text("$\(cost)") + Text(cost.toCurrency()) .font(.system(size: 15, weight: .bold, design: .rounded)) .foregroundColor(Color.appPrimary) } diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index f966cab..9c0480f 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -100,7 +100,6 @@ struct TaskFormView: View { if needsResidenceSelection, let residences = residences { Section { Picker(L10n.Tasks.property, selection: $selectedResidence) { - Text(L10n.Tasks.selectProperty).tag(nil as ResidenceResponse?) ForEach(residences, id: \.id) { residence in Text(residence.name).tag(residence as ResidenceResponse?) } @@ -111,10 +110,6 @@ struct TaskFormView: View { } } header: { Text(L10n.Tasks.property) - } footer: { - Text(L10n.Tasks.required) - .font(.caption) - .foregroundColor(Color.appError) } .sectionBackground() } @@ -168,7 +163,7 @@ struct TaskFormView: View { VStack(alignment: .leading, spacing: 8) { TextField(L10n.Tasks.titleLabel, text: $title) .focused($focusedField, equals: .title) - .onChange(of: title) { newValue in + .onChange(of: title) { _, newValue in updateSuggestions(query: newValue) } @@ -190,7 +185,6 @@ struct TaskFormView: View { TextField(L10n.Tasks.descriptionOptional, text: $description, axis: .vertical) .lineLimit(3...6) .focused($focusedField, equals: .description) - .keyboardDismissToolbar() } header: { Text(L10n.Tasks.taskDetails) } footer: { @@ -219,7 +213,7 @@ struct TaskFormView: View { Text(frequency.displayName).tag(frequency as TaskFrequency?) } } - .onChange(of: selectedFrequency) { newFrequency in + .onChange(of: selectedFrequency) { _, newFrequency in // Clear interval days if not Custom frequency if newFrequency?.name.lowercased() != "custom" { intervalDays = "" @@ -231,7 +225,6 @@ struct TaskFormView: View { TextField(L10n.Tasks.customInterval, text: $intervalDays) .keyboardType(.numberPad) .focused($focusedField, equals: .intervalDays) - .keyboardDismissToolbar() } DatePicker(L10n.Tasks.dueDate, selection: $dueDate, displayedComponents: .date) @@ -266,7 +259,6 @@ struct TaskFormView: View { .focused($focusedField, equals: .estimatedCost) } .sectionBackground() - .keyboardDismissToolbar() if let errorMessage = viewModel.errorMessage { Section { @@ -290,11 +282,20 @@ struct TaskFormView: View { } ToolbarItem(placement: .navigationBarTrailing) { - Button(L10n.Common.save) { + Button(isEditMode ? L10n.Common.save : L10n.Common.add) { submitForm() } .disabled(!canSave || viewModel.isLoading || isLoadingLookups) } + + ToolbarItemGroup(placement: .keyboard) { + Spacer() + Button("Done") { + focusedField = nil + } + .foregroundColor(Color.appPrimary) + .fontWeight(.medium) + } } .onAppear { // Track screen view for new tasks @@ -306,17 +307,17 @@ struct TaskFormView: View { setDefaults() } } - .onChange(of: dataManager.lookupsInitialized) { initialized in + .onChange(of: dataManager.lookupsInitialized) { _, initialized in if initialized { setDefaults() } } - .onChange(of: viewModel.taskCreated) { created in + .onChange(of: viewModel.taskCreated) { _, created in if created { isPresented = false } } - .onChange(of: viewModel.errorMessage) { errorMessage in + .onChange(of: viewModel.errorMessage) { _, errorMessage in if let errorMessage = errorMessage, !errorMessage.isEmpty { errorAlert = ErrorAlertInfo(message: errorMessage) } diff --git a/iosApp/iosApp/Task/TaskSuggestionsView.swift b/iosApp/iosApp/Task/TaskSuggestionsView.swift index 613e616..ac2f0ba 100644 --- a/iosApp/iosApp/Task/TaskSuggestionsView.swift +++ b/iosApp/iosApp/Task/TaskSuggestionsView.swift @@ -27,7 +27,7 @@ struct TaskSuggestionsView: View { // Category-colored icon Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos) .font(.system(size: 18)) - .foregroundColor(categoryColor(for: template.categoryName)) + .foregroundColor(Color.taskCategoryColor(for: template.categoryName)) .frame(width: 28, height: 28) // Task info @@ -78,18 +78,6 @@ struct TaskSuggestionsView: View { .naturalShadow(.medium) } - private func categoryColor(for categoryName: String) -> Color { - switch categoryName.lowercased() { - case "plumbing": return Color.appSecondary - case "safety", "electrical": return Color.appError - case "hvac": return Color.appPrimary - case "appliances": return Color.appAccent - case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green - case "interior": return Color(hex: "#AF52DE") ?? .purple - case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange - default: return Color.appPrimary - } - } } #Preview { diff --git a/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift b/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift index bbe695f..3f377f9 100644 --- a/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift +++ b/iosApp/iosApp/Task/TaskTemplatesBrowserView.swift @@ -106,7 +106,7 @@ struct TaskTemplatesBrowserView: View { // Category icon Image(systemName: categoryIcon(for: categoryGroup.categoryName)) .font(.system(size: 18)) - .foregroundColor(categoryColor(for: categoryGroup.categoryName)) + .foregroundColor(Color.taskCategoryColor(for: categoryGroup.categoryName)) .frame(width: 28, height: 28) // Category name @@ -180,7 +180,7 @@ struct TaskTemplatesBrowserView: View { // Icon Image(systemName: template.iconIos.isEmpty ? "checklist" : template.iconIos) .font(.system(size: 16)) - .foregroundColor(categoryColor(for: template.categoryName)) + .foregroundColor(Color.taskCategoryColor(for: template.categoryName)) .frame(width: 24, height: 24) // Task info @@ -225,18 +225,6 @@ struct TaskTemplatesBrowserView: View { } } - private func categoryColor(for categoryName: String) -> Color { - switch categoryName.lowercased() { - case "plumbing": return Color.appSecondary - case "safety", "electrical": return Color.appError - case "hvac": return Color.appPrimary - case "appliances": return Color.appAccent - case "exterior", "lawn & garden": return Color(hex: "#34C759") ?? .green - case "interior": return Color(hex: "#AF52DE") ?? .purple - case "general", "seasonal": return Color(hex: "#FF9500") ?? .orange - default: return Color.appPrimary - } - } } #Preview { diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index 6859ceb..38085e8 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -10,6 +10,8 @@ struct iOSApp: App { @StateObject private var residenceSharingManager = ResidenceSharingManager.shared @Environment(\.scenePhase) private var scenePhase @State private var deepLinkResetToken: String? + /// Tracks foreground refresh tasks so they can be cancelled on subsequent transitions + @State private var foregroundTask: Task? @State private var pendingImportURL: URL? @State private var pendingImportType: CaseraPackageType = .contractor @State private var showImportConfirmation: Bool = false @@ -59,7 +61,7 @@ struct iOSApp: App { .onOpenURL { url in handleIncomingURL(url: url) } - .onChange(of: scenePhase) { newPhase in + .onChange(of: scenePhase) { _, newPhase in guard !UITestRuntime.isEnabled else { return } if newPhase == .active { @@ -73,27 +75,22 @@ struct iOSApp: App { // Check and register device token when app becomes active PushNotificationManager.shared.checkAndRegisterDeviceIfNeeded() - // Refresh lookups/static data when app becomes active - Task { + // Cancel any previous foreground refresh task before starting a new one + foregroundTask?.cancel() + foregroundTask = Task { @MainActor in + // Refresh lookups/static data _ = try? await APILayer.shared.initializeLookups() - } - // Process any pending widget actions (task completions, mark in-progress) - Task { @MainActor in + // Process any pending widget actions (task completions, mark in-progress) WidgetActionProcessor.shared.processPendingActions() - } - // Check if widget completed a task - refresh data globally - if WidgetDataManager.shared.areTasksDirty() { - WidgetDataManager.shared.clearDirtyFlag() - Task { - // Refresh tasks - summary is calculated client-side from kanban data + // Check if widget completed a task - refresh data globally + if WidgetDataManager.shared.areTasksDirty() { + WidgetDataManager.shared.clearDirtyFlag() let result = try? await APILayer.shared.getTasks(forceRefresh: true) if let success = result as? ApiResultSuccess, let data = success.data { - // Update widget cache WidgetDataManager.shared.saveTasks(from: data) - // Summary is calculated by DataManager.setAllTasks() -> refreshSummaryFromKanban() } } } @@ -237,11 +234,21 @@ struct iOSApp: App { .appendingPathExtension("casera") try data.write(to: tempURL) - if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let typeString = json["type"] as? String { - pendingImportType = typeString == "residence" ? .residence : .contractor - } else { - pendingImportType = .contractor + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let typeString = json["type"] as? String { + pendingImportType = typeString == "residence" ? .residence : .contractor + } else { + print("iOSApp: Casera file is valid JSON but missing 'type' field, defaulting to contractor") + pendingImportType = .contractor + } + } catch { + print("iOSApp: Failed to parse casera file JSON: \(error)") + if accessing { + url.stopAccessingSecurityScopedResource() + } + contractorSharingManager.importError = "The file appears to be corrupted and could not be read." + return } pendingImportURL = tempURL @@ -262,25 +269,35 @@ struct iOSApp: App { /// Handles casera:// deep links private func handleDeepLink(url: URL) { - // Handle casera://reset-password?token=xxx - guard url.host == "reset-password" else { - #if DEBUG - print("Unrecognized deep link host: \(url.host ?? "nil")") - #endif - return - } + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true) else { return } + let host = url.host - // Parse token from query parameters - if let components = URLComponents(url: url, resolvingAgainstBaseURL: true), - let queryItems = components.queryItems, - let token = queryItems.first(where: { $0.name == "token" })?.value { + switch host { + case "reset-password": + if let token = components.queryItems?.first(where: { $0.name == "token" })?.value { + #if DEBUG + print("Reset token extracted: \(token)") + #endif + deepLinkResetToken = token + } + case "task": + if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value, + let id = Int(idString) { + NotificationCenter.default.post(name: .navigateToTask, object: nil, userInfo: ["taskId": id]) + } + case "residence": + if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value, + let id = Int(idString) { + NotificationCenter.default.post(name: .navigateToResidence, object: nil, userInfo: ["residenceId": id]) + } + case "document": + if let idString = components.queryItems?.first(where: { $0.name == "id" })?.value, + let id = Int(idString) { + NotificationCenter.default.post(name: .navigateToDocument, object: nil, userInfo: ["documentId": id]) + } + default: #if DEBUG - print("Reset token extracted: \(token)") - #endif - deepLinkResetToken = token - } else { - #if DEBUG - print("No token found in deep link") + print("Unrecognized deep link host: \(host ?? "nil")") #endif } }