Add task completion animations, subscription trials, and quiet debug console
- Completion animations: play user-selected animation on task card after completing, with DataManager guard to prevent race condition during animation playback. Works in both AllTasksView and ResidenceDetailView. Animation preference persisted via @AppStorage and configurable from Settings. - Subscription: add trial fields (trialStart, trialEnd, trialActive) and subscriptionSource to model, cross-platform purchase guard, trial banner in upgrade prompt, and platform-aware subscription management in profile. - Analytics: disable PostHog SDK debug logging and remove console print statements to reduce debug console noise. - Documents: remove redundant nested do-catch blocks in ViewModel wrapper. - Widgets: add debounced timeline reloads and thread-safe file I/O queue. - Onboarding: fix animation leak on disappear, remove unused state vars. - Remove unused files (ContentView, StateFlowExtensions, CustomView). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,12 +5,18 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SubscriptionStatus(
|
data class SubscriptionStatus(
|
||||||
|
val tier: String = "free",
|
||||||
|
@SerialName("is_active") val isActive: Boolean = false,
|
||||||
@SerialName("subscribed_at") val subscribedAt: String? = null,
|
@SerialName("subscribed_at") val subscribedAt: String? = null,
|
||||||
@SerialName("expires_at") val expiresAt: String? = null,
|
@SerialName("expires_at") val expiresAt: String? = null,
|
||||||
@SerialName("auto_renew") val autoRenew: Boolean = true,
|
@SerialName("auto_renew") val autoRenew: Boolean = true,
|
||||||
val usage: UsageStats,
|
val usage: UsageStats,
|
||||||
val limits: Map<String, TierLimits>, // {"free": {...}, "pro": {...}}
|
val limits: Map<String, TierLimits>, // {"free": {...}, "pro": {...}}
|
||||||
@SerialName("limitations_enabled") val limitationsEnabled: Boolean = false // Master toggle
|
@SerialName("limitations_enabled") val limitationsEnabled: Boolean = false, // Master toggle
|
||||||
|
@SerialName("trial_start") val trialStart: String? = null,
|
||||||
|
@SerialName("trial_end") val trialEnd: String? = null,
|
||||||
|
@SerialName("trial_active") val trialActive: Boolean = false,
|
||||||
|
@SerialName("subscription_source") val subscriptionSource: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -45,12 +45,13 @@ object SubscriptionHelper {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive the current subscription tier from DataManager.
|
* Derive the current subscription tier from DataManager.
|
||||||
* "pro" if the backend subscription has a non-empty expiresAt (active paid plan),
|
* "pro" if the backend subscription has an active trial or a non-empty expiresAt (active paid plan),
|
||||||
* "free" otherwise.
|
* "free" otherwise.
|
||||||
*/
|
*/
|
||||||
val currentTier: String
|
val currentTier: String
|
||||||
get() {
|
get() {
|
||||||
val subscription = DataManager.subscription.value ?: return "free"
|
val subscription = DataManager.subscription.value ?: return "free"
|
||||||
|
if (subscription.trialActive) return "pro"
|
||||||
val expiresAt = subscription.expiresAt
|
val expiresAt = subscription.expiresAt
|
||||||
return if (!expiresAt.isNullOrEmpty()) "pro" else "free"
|
return if (!expiresAt.isNullOrEmpty()) "pro" else "free"
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,25 @@ object SubscriptionHelper {
|
|||||||
return currentTier == "pro"
|
return currentTier == "pro"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the user can purchase a subscription on the given platform.
|
||||||
|
* If the user already has an active Pro subscription from a different platform,
|
||||||
|
* purchasing on this platform is not allowed.
|
||||||
|
*
|
||||||
|
* @param currentPlatform The platform attempting the purchase ("ios", "android", or "stripe")
|
||||||
|
* @return UsageCheck with allowed=false if already subscribed on another platform
|
||||||
|
*/
|
||||||
|
fun canPurchaseOnPlatform(currentPlatform: String): UsageCheck {
|
||||||
|
val subscription = DataManager.subscription.value
|
||||||
|
?: return UsageCheck(allowed = true, triggerKey = null)
|
||||||
|
if (currentTier != "pro") return UsageCheck(allowed = true, triggerKey = null)
|
||||||
|
val source = subscription.subscriptionSource
|
||||||
|
if (source != null && source != currentPlatform) {
|
||||||
|
return UsageCheck(allowed = false, triggerKey = "already_subscribed_other_platform")
|
||||||
|
}
|
||||||
|
return UsageCheck(allowed = true, triggerKey = null)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== PROPERTY (RESIDENCE) =====
|
// ===== PROPERTY (RESIDENCE) =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
577
hardening-report.md
Normal file
577
hardening-report.md
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
# Hardening Audit Report
|
||||||
|
|
||||||
|
## Audit Sources
|
||||||
|
- 11 mapper agents (100% file coverage)
|
||||||
|
- 17 specialized domain auditors (parallel)
|
||||||
|
- 1 cross-cutting deep audit (parallel)
|
||||||
|
- Total source files: 161
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRITICAL — Will crash or lose data (14 findings)
|
||||||
|
|
||||||
|
**WidgetDataManager.swift:248** | Missing closing brace nests all remaining class members inside `clearPendingState` function
|
||||||
|
- What: The `clearPendingState()` method is missing its closing `}`. All subsequent members (`hasPendingActions`, `WidgetTask`, `TaskMetrics`, `calculateMetrics`, `saveTasks`, `loadTasks`, etc.) are nested inside the function scope, making them inaccessible externally.
|
||||||
|
- Impact: Build failure. External callers (`DataManagerObservable`, `iOSApp`, `BackgroundTaskManager`, `WidgetActionProcessor`, etc.) cannot access these members.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**StoreKitManager.swift:91-94** | Transaction finished even when backend verification fails
|
||||||
|
- What: After StoreKit transaction verification, `verifyTransactionWithBackend(transaction)` is called at line 91, then `transaction.finish()` is called unconditionally at line 94. Backend errors are logged but not propagated.
|
||||||
|
- Impact: User charged by Apple but backend never records the purchase. Finished transactions cannot be re-verified via `Transaction.currentEntitlements`. User stuck on free tier despite paying. Same pattern in `listenForTransactions()` at lines 234-256.
|
||||||
|
- Source: Deep Audit (cross-cutting), IAP Auditor
|
||||||
|
|
||||||
|
**AppleSignInManager.swift:5** | `ObservableObject` without `@MainActor` publishes from delegate callbacks on background threads
|
||||||
|
- What: `ASAuthorizationControllerDelegate` callbacks can deliver on background threads. Inside these callbacks, `isProcessing = false` and `self.error = ...` mutate `@Published` properties off the main thread.
|
||||||
|
- Impact: Data races and potential SwiftUI rendering crashes.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**AppleSignInManager.swift:113** | `presentationAnchor(for:)` accesses UIKit APIs without `@MainActor`
|
||||||
|
- What: Reads `UIApplication.shared.connectedScenes` and `scene.windows` from a non-`@MainActor` method.
|
||||||
|
- Impact: Accessing UIKit from a background thread is undefined behavior and frequently crashes.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**StoreKitManager.swift:7** | `ObservableObject` without `@MainActor`, `@Published` mutations from `Task.detached`
|
||||||
|
- What: `StoreKitManager` has `@Published` properties but no `@MainActor`. `listenForTransactions()` creates a `Task.detached` that accesses `self` without actor isolation.
|
||||||
|
- Impact: `@Published` mutations from detached task run off main actor. Swift 6 compiler error. Potential crashes from concurrent access.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**StoreKitManager.swift:234** | `Task.detached` captures `self` strongly without `[weak self]`
|
||||||
|
- What: `listenForTransactions()` creates `Task.detached { ... }` capturing `self` strongly. Called `checkVerified` on `self` without actor isolation.
|
||||||
|
- Impact: Swift 6 strict mode error: "Sending 'self' risks causing data races."
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**iOSApp.swift:294** | Deep link reset-password token extracted but never delivered to any view
|
||||||
|
- What: `handleDeepLink` stores parsed reset token in `@State private var deepLinkResetToken`, but `RootView()` is constructed with no arguments. `LoginView` accepts `resetToken: Binding<String?>` but the binding is never wired.
|
||||||
|
- Impact: `casera://reset-password?token=xxx` deep links are silently discarded. Password reset emails don't work.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**Info.plist** | Missing Privacy Manifest (`PrivacyInfo.xcprivacy`)
|
||||||
|
- What: No `PrivacyInfo.xcprivacy` file found. App uses `UserDefaults`, analytics (PostHog), and device identifiers — all require declared API reasons since iOS 17.
|
||||||
|
- Impact: App Store rejection starting Spring 2024 enforcement. Required for `NSPrivacyAccessedAPIType` declarations.
|
||||||
|
- Source: Security/Privacy Scanner
|
||||||
|
|
||||||
|
**DoubleExtensions.swift:42** | Shared `NumberFormatter` mutated on every call — data race
|
||||||
|
- What: `toDecimalString(fractionDigits:)` and `toPercentage(fractionDigits:)` mutate `minimumFractionDigits`/`maximumFractionDigits` on shared `NumberFormatters.shared` instances. No lock or actor protection.
|
||||||
|
- Impact: Concurrent calls from `LazyVStack` rendering will race on formatter property writes. Non-deterministic output; rare but real crash.
|
||||||
|
- Source: SwiftUI Architecture Auditor, SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**Info.plist:61** | `fetch` background mode declared but never implemented
|
||||||
|
- What: `UIBackgroundModes` includes `"fetch"` but there is no `application(_:performFetchWithCompletionHandler:)` implementation. App uses `BGAppRefreshTask` instead.
|
||||||
|
- Impact: iOS penalizes apps with unused background modes. System wakes app for fetch cycles with no useful work. Risk of App Store rejection.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**WidgetDataManager.swift:43,57,64,79,110,122** | `UserDefaults.synchronize()` called on every small write — 6 forced disk syncs
|
||||||
|
- What: `synchronize()` is called after each individual write to shared UserDefaults (auth token save/clear, API URL, subscription status, dirty flag). Forces immediate disk flush instead of batching.
|
||||||
|
- Impact: Each call triggers synchronous disk write, waking storage controller. 1-3% additional battery drain per active session hour.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**DataManagerObservable.swift:178** | Widget task file written on every DataManager tasks emission
|
||||||
|
- What: `WidgetDataManager.shared.saveTasks(from: tasks)` called every time `allTasks` emits. Writes JSON file, encodes all tasks with `.prettyPrinted`, calls `WidgetCenter.shared.reloadAllTimelines()` — all synchronously.
|
||||||
|
- Impact: Every API call touching tasks triggers JSON encoding, atomic file write, and widget timeline reload. 3-8% additional battery drain during active use.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**DataManager.kt:367-368** | `tasksDueNextWeek` and `tasksDueNextMonth` set to the same value
|
||||||
|
- What: Both assigned `dueSoonCount` from `due_soon_tasks` column (30-day window). No separate 7-day calculation.
|
||||||
|
- Impact: Dashboard shows identical numbers for "Due This Week" and "Due Next Month." Weekly metric is useless.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**ResidenceDetailView.swift:426** | `APILayer` called directly from a View — architecture boundary violation
|
||||||
|
- What: `deleteResidence()` and `loadResidenceContractors()` are functions on the View struct calling `APILayer.shared` directly, managing `@State` loading/error booleans.
|
||||||
|
- Impact: Business logic untestable, cannot be mocked, violates declared architecture. Same issue in `ManageUsersView.swift:109`.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BUG — Incorrect behavior (8 findings)
|
||||||
|
|
||||||
|
**WidgetDataManager.swift:247** | `hasPendingActions` var declared inside `clearPendingState` method body
|
||||||
|
- What: Due to missing closing brace, `var hasPendingActions: Bool` is a local variable inside the method, not an instance computed property.
|
||||||
|
- Impact: `hasPendingActions` inaccessible from `WidgetActionProcessor`. Build failure.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**AnalyticsManager.swift:19** | Identical PostHog API key in both DEBUG and RELEASE builds
|
||||||
|
- What: `#if DEBUG / #else` block assigns same `phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ` key in both branches. Has `// TODO: (SE2)` comment.
|
||||||
|
- Impact: Debug/QA events pollute production analytics, corrupting funnel metrics.
|
||||||
|
- Source: Security/Privacy Scanner, SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
**AuthViewModel.kt:87-88** | `register()` double-writes auth token and user to DataManager
|
||||||
|
- What: Calls `DataManager.setAuthToken()` and `DataManager.setCurrentUser()` which APILayer.register() already calls internally.
|
||||||
|
- Impact: Double StateFlow emissions, duplicate disk persistence, unnecessary UI re-renders.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**LoginViewModel.swift:99-105** | `login()` calls `initializeLookups()` after `APILayer.login()` which already calls it internally
|
||||||
|
- What: Double initialization of all lookup data on every login.
|
||||||
|
- Impact: Second round of ETag-based fetches, delays post-login navigation on slow connections.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**OnboardingState.swift:16** | Dual source of truth for `hasCompletedOnboarding` between `@AppStorage` and Kotlin `DataManager`
|
||||||
|
- What: `@AppStorage("hasCompletedOnboarding")` in Swift and `_hasCompletedOnboarding` StateFlow in Kotlin are never synchronized. `completeOnboarding()` sets @AppStorage but NOT Kotlin DataManager.
|
||||||
|
- Impact: Inconsistent onboarding state between Swift and Kotlin layers. Could show onboarding again after certain logout/clear flows.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**ResidencesListView.swift:149** | Deprecated `NavigationLink(isActive:)` used for push-notification navigation
|
||||||
|
- What: Hidden `NavigationLink(isActive:destination:label:)` in `.background` modifier for programmatic navigation. Deprecated since iOS 16.
|
||||||
|
- Impact: Unreliable with `NavigationStack`. May silently fail or double-push. Same pattern in `DocumentsWarrantiesView.swift:210` and `DocumentDetailView.swift:47`.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**DateExtensions.swift:97-133** | DateFormatter created per call in extension methods without locale pinning
|
||||||
|
- What: Six `DateFormatter()` instances created in extension methods without setting `locale` to `Locale(identifier: "en_US_POSIX")` for fixed-format dates.
|
||||||
|
- Impact: Formatting varies by user locale. API date strings may be incorrectly formatted in non-English locales.
|
||||||
|
- Source: Codable Auditor
|
||||||
|
|
||||||
|
**AllTasksView.swift:94** | `loadAllTasks(forceRefresh: true)` called from view after sheet dismissal
|
||||||
|
- What: Calls refresh methods in `.onChange(of: showAddTask)` and `.onChange(of: showEditTask)` when sheets close.
|
||||||
|
- Impact: Violates CLAUDE.md architecture rule: "iOS code MUST ONLY call mutation methods on ViewModels, NOT refresh methods after mutations."
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SILENT FAILURE — Error swallowed or ignored (6 findings)
|
||||||
|
|
||||||
|
**StoreKitManager.swift:259-295** | `verifyTransactionWithBackend` swallows all errors
|
||||||
|
- What: Backend verification errors are printed but never thrown or returned. Caller has no way to know verification failed.
|
||||||
|
- Impact: Transactions finished without backend acknowledgment. Revenue lost silently.
|
||||||
|
- Source: IAP Auditor, Deep Audit
|
||||||
|
|
||||||
|
**StateFlowObserver.swift:24** | `Task` returned without storing — caller may not retain, causing premature cancellation
|
||||||
|
- What: `observe()` creates and returns a `Task`, but `observeWithState` and `observeWithCompletion` (lines 69, 103) discard the returned task. `@discardableResult` suppresses the warning.
|
||||||
|
- Impact: Observation stops immediately. `onSuccess`/`onError` callbacks never fire.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**Codable patterns across codebase** | `try?` used extensively to swallow JSON errors
|
||||||
|
- What: Multiple locations use `try?` for JSON decoding/encoding without error logging.
|
||||||
|
- Impact: Malformed data silently produces nil instead of surfacing the issue.
|
||||||
|
- Source: Codable Auditor
|
||||||
|
|
||||||
|
**DocumentFormState.swift:89** | DateFormatter without locale for API date formatting
|
||||||
|
- What: DateFormatter created with `dateFormat = "yyyy-MM-dd"` but no `locale` set.
|
||||||
|
- Impact: On devices with non-Gregorian calendars, date string may not match expected API format.
|
||||||
|
- Source: Codable Auditor
|
||||||
|
|
||||||
|
**GoogleSignInManager.swift** | Singleton closure leak
|
||||||
|
- What: `GoogleSignInManager` singleton captures `self` strongly in completion handlers.
|
||||||
|
- Impact: Memory leak in singleton (benign for singleton, bad pattern for reuse).
|
||||||
|
- Source: Memory Auditor
|
||||||
|
|
||||||
|
**AuthenticatedImage.swift:86** | Static `NSCache` unbounded across all instances
|
||||||
|
- What: `NSCache<NSString, UIImage>` has no `countLimit` or `totalCostLimit`. Not cleared on logout.
|
||||||
|
- Impact: Excessive memory pressure. Stale images from previous user session may display briefly.
|
||||||
|
- Source: Networking Auditor, Memory Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RACE CONDITION — Concurrency issue (9 findings)
|
||||||
|
|
||||||
|
**SubscriptionCacheWrapper.swift:10** | `ObservableObject` without `@MainActor`
|
||||||
|
- What: Four `@Published` properties, no `@MainActor`. Uses `DispatchQueue.main.async` for writes instead of actor isolation.
|
||||||
|
- Impact: Swift 6 isolation checker loses thread context. Refactoring that removes `async` breaks thread safety.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**ThemeManager.swift:95** | `ObservableObject` without `@MainActor`
|
||||||
|
- What: `@Published var currentTheme`, no `@MainActor`. `setTheme(_:)` calls `withAnimation` which must be on main actor.
|
||||||
|
- Impact: `withAnimation` silently no-ops off main actor. Swift 6 data race.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**OnboardingState.swift:12** | `ObservableObject` without `@MainActor`
|
||||||
|
- What: Multiple `@Published` properties, singleton, no `@MainActor`. `nextStep()` and `completeOnboarding()` mutate state without actor guarantee.
|
||||||
|
- Impact: Swift 6 strict mode error from any `Task {}` call to mutation methods.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**WidgetDataManager.swift:7** | `fileQueue.sync` blocks main thread from `@MainActor` call sites
|
||||||
|
- What: `saveTasks`, `loadTasks`, `removeAction`, `clearPendingState` use `fileQueue.sync` blocking the calling thread. Called from `@MainActor` code in `DataManagerObservable`.
|
||||||
|
- Impact: Blocks main thread during file I/O, causing frame drops and potential watchdog terminations.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**DataManagerObservable.swift:96** | All 27 observation `Task` blocks capture `self` strongly
|
||||||
|
- What: `startObserving()` creates 27 `Task { for await ... }` blocks, none using `[weak self]`.
|
||||||
|
- Impact: Pattern prevents deallocation. Swift 6 Sendable checker may flag captures.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**PushNotificationManager.swift:72** | Multiple `Task {}` without `[weak self]` inside `@MainActor` class
|
||||||
|
- What: Lines 72, 90, 102, 179, 196, 232, 317, 349, 369, 391 — all capture `self` strongly.
|
||||||
|
- Impact: Pattern violation under Swift 6 strict concurrency.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**AppDelegate.swift:6** | `AppDelegate` missing `@MainActor` but wraps `@MainActor` singleton
|
||||||
|
- What: Conforms to `UIApplicationDelegate` and `UNUserNotificationCenterDelegate` without `@MainActor`. Calls `PushNotificationManager.shared` via `Task { @MainActor in }` correctly, but class-level isolation missing.
|
||||||
|
- Impact: Swift 6 compiler error for any future direct property access.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**SubscriptionCache.swift:157** | `DispatchQueue.main.async` used instead of `@MainActor` isolation for `@Published` writes
|
||||||
|
- What: Pre-Swift-concurrency pattern. Multiple methods dispatch to main queue manually.
|
||||||
|
- Impact: Mixing `DispatchQueue.main.async` with Swift concurrency actors loses isolation tracking.
|
||||||
|
- Source: Concurrency Auditor
|
||||||
|
|
||||||
|
**CompleteTaskView.swift:336** | `Task { for await ... }` started in action handler without cancellation management
|
||||||
|
- What: Task observing Kotlin StateFlow started in view method with no cancellation token. Sheet dismissal doesn't cancel it.
|
||||||
|
- Impact: `onComplete` callback fires after view gone, triggering unwanted state changes.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## LOGIC ERROR — Code doesn't match intent (4 findings)
|
||||||
|
|
||||||
|
**SubscriptionCache.swift:114** | `objectWillChange` observation fires on ALL DataManagerObservable changes
|
||||||
|
- What: Subscribes to `DataManagerObservable.shared.objectWillChange` (25+ @Published properties). Every task update, residence change, lookup update triggers `syncFromDataManager()`.
|
||||||
|
- Impact: Unnecessary Kotlin StateFlow reads on every unrelated data change. Should observe `$subscription` directly.
|
||||||
|
- Source: SwiftUI Architecture Auditor, Deep Audit
|
||||||
|
|
||||||
|
**OnboardingState.swift:12** | Mixes `@AppStorage` with `@Published` — potential update-notification gap
|
||||||
|
- What: Uses both `@AppStorage` (for persistence) and `@Published` (for observation). `userIntent` computed property mutates `@AppStorage` and calls `objectWillChange.send()` manually.
|
||||||
|
- Impact: Duplicate or missed notifications. SwiftUI reactivity bugs in onboarding flow.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
**ResidencesListView.swift:133** | Duplicate `onChange(of: authManager.isAuthenticated)` observers
|
||||||
|
- What: Two observers for `authManager.isAuthenticated` in `ResidencesListView` plus one in `MainTabView`. All fire on the same state change.
|
||||||
|
- Impact: `loadMyResidences()` and `loadTasks()` called multiple times from different observers. Redundant network requests.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**ApiConfig.kt:25** | DEV environment URL doesn't match CLAUDE.md documentation
|
||||||
|
- What: Code uses `https://casera.treytartt.com/api`. CLAUDE.md documents `https://mycrib.treytartt.com/api`.
|
||||||
|
- Impact: Documentation misleads developers.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PERFORMANCE — Unnecessary cost (19 findings)
|
||||||
|
|
||||||
|
**DataManagerObservable.swift:18** | Monolithic `ObservableObject` with 30+ `@Published` properties
|
||||||
|
- What: Single class with 30+ properties covering auth, residences, tasks, documents, contractors, subscriptions, lookups. Any property change invalidates ALL subscribed views.
|
||||||
|
- Impact: O(views x changes) invalidation. Loading contractors re-renders all views observing DataManager.
|
||||||
|
- Source: SwiftUI Performance Analyzer, Swift Performance Analyzer
|
||||||
|
|
||||||
|
**PropertyHeaderCard.swift:145** | `NumberFormatter()` created on every view body evaluation
|
||||||
|
- What: `formatNumber()` creates a new `NumberFormatter()` each call. Called from `var body` inside a `ForEach` on residence list.
|
||||||
|
- Impact: ~1-2ms per allocation. Stutter on scroll frames. Shared formatter already exists in `NumberFormatters.shared`.
|
||||||
|
- Source: SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**DocumentsWarrantiesView.swift:26** | `filter()` on documents array called as computed property in view body
|
||||||
|
- What: `warranties` and `documents` computed properties filter entire array on every state change.
|
||||||
|
- Impact: Fires on every keystroke during search. Same issue in `WarrantiesTabContent.swift:10` and `DocumentsTabContent.swift:13`.
|
||||||
|
- Source: SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**ContractorsListView.swift:25** | `filter()` with nested `.contains()` on specialties in view body
|
||||||
|
- What: O(n*m) scan on every view update — each contractor's specialties checked on every render.
|
||||||
|
- Impact: Fires on every search character, favorite toggle, any @Published change.
|
||||||
|
- Source: SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**DataManagerObservable.swift:573** | O(n) linear scan for per-residence task metrics
|
||||||
|
- What: `activeTaskCount(for:)` and `taskMetrics(for:)` iterate all task columns per residence per render.
|
||||||
|
- Impact: O(tasks x residences) computation on every render pass.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
**OrganicDesign.swift:104-125** | `GrainTexture` renders random noise via `Canvas` on every draw pass — used in 25+ files
|
||||||
|
- What: `Canvas` closure calls `CGFloat.random` for every pixel subdivision on every render pass. No caching.
|
||||||
|
- Impact: ~390 draw calls per 390x200pt card per redraw. During animation: 60+ times/sec. 3-7% GPU drain.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**LoadingOverlay.swift:127** | Shimmer animation runs `repeatForever` with no stop mechanism
|
||||||
|
- What: `withAnimation(.linear(duration: 1.5).repeatForever(...))` with no `.onDisappear` to stop.
|
||||||
|
- Impact: GPU compositing continues even when not visible if view remains in hierarchy.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**OrganicDesign.swift:405-415** | `FloatingLeaf` runs `repeatForever` animation with no stop — used in 3+ empty-state views
|
||||||
|
- What: 4-second `repeatForever` animation driving rotation and offset. No `onDisappear` to stop.
|
||||||
|
- Impact: Animations remain active in navigation stack hierarchy. 5-10% battery drain per hour.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**ResidenceCard.swift:197-205** | `PulseRing` runs infinite animation per card with no stop
|
||||||
|
- What: 1.5-second `repeatForever` per residence card with overdue tasks. No `onDisappear`.
|
||||||
|
- Impact: 5 residences = 5 concurrent infinite animations. 5-12% GPU drain per hour.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**Onboarding views (8 screens)** | `repeatForever` animations stack without cleanup
|
||||||
|
- What: Each onboarding screen starts independent `repeatForever` animations. No `onDisappear` to stop. By last step, 10+ concurrent animations active.
|
||||||
|
- Impact: 10-20% battery drain during onboarding flow.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**WidgetDataManager.swift:407** | `reloadAllTimelines()` called unconditionally inside `saveTasks()`
|
||||||
|
- What: Also called from `DataManagerObservable`, `BackgroundTaskManager`, and `iOSApp.swift` on background. Multiple back-to-back reloads.
|
||||||
|
- Impact: 3-6% battery drain per active hour from unnecessary widget renders.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**ResidencesListView.swift:296-318** | Two concurrent `repeatForever` animations in empty-state with no stop
|
||||||
|
- What: Scale on glow circle (3s) + y-offset on house icon (2s). Remain in navigation hierarchy.
|
||||||
|
- Impact: Same pattern in `AllTasksView.swift:331-349` (2 animations), `ContractorsListView.swift:432-436` (1 animation).
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**Info.plist** | `CADisableMinimumFrameDurationOnPhone = true` enables 120fps without selective opt-in
|
||||||
|
- What: All decorative `repeatForever` animations run at 120fps on ProMotion devices.
|
||||||
|
- Impact: Doubles GPU compositing work for purely decorative content. 5-10% additional drain.
|
||||||
|
- Source: Energy Auditor
|
||||||
|
|
||||||
|
**Kotlin byte-by-byte conversion** | Data crossing KMM bridge with unnecessary copies
|
||||||
|
- What: Kotlin StateFlow observations involve byte-by-byte conversion at the Swift-Kotlin boundary.
|
||||||
|
- Impact: O(n) copy overhead on every StateFlow emission for large data sets.
|
||||||
|
- Source: Swift Performance Analyzer
|
||||||
|
|
||||||
|
**UpgradePromptView.swift:10** | `parseContent()` string parsing called on every render
|
||||||
|
- What: `lines` computed property calls `parseContent(content)` (full string splitting/classification) on every render.
|
||||||
|
- Impact: String parsing ~10-50x more expensive than property access.
|
||||||
|
- Source: SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**DynamicTaskCard.swift:142** | Three `.contains(where:)` calls in `menuContent` view body
|
||||||
|
- What: Each renders per task card in kanban columns. 10+ tasks = 30+ array scans per render.
|
||||||
|
- Impact: Measurable overhead in scrolling kanban view.
|
||||||
|
- Source: SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**ContractorDetailView.swift:485** | `.first(where:)` on full residences array in view body
|
||||||
|
- What: Scans entire residences array on every render of contractor detail.
|
||||||
|
- Impact: Unnecessary O(n) on every layout pass.
|
||||||
|
- Source: SwiftUI Performance Analyzer
|
||||||
|
|
||||||
|
**AuthenticatedImage.swift:139** | `URLSession.shared` for image downloads with no cellular constraints
|
||||||
|
- What: No `allowsExpensiveNetworkAccess = false` or `isDiscretionary = true`.
|
||||||
|
- Impact: Keeps cellular modem powered up longer on poor connections. 3-8% additional drain.
|
||||||
|
- Source: Energy Auditor, Networking Auditor
|
||||||
|
|
||||||
|
**Ktor clients (all platforms)** | No `HttpTimeout` configured
|
||||||
|
- What: None of the five Ktor `createHttpClient()` implementations install `HttpTimeout`. Default is infinite.
|
||||||
|
- Impact: Stalled TCP connection hangs coroutine indefinitely with no error surfaced.
|
||||||
|
- Source: Networking Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ACCESSIBILITY — Usability barrier (24 findings)
|
||||||
|
|
||||||
|
**Multiple views** | Missing Dynamic Type support — fixed font sizes throughout
|
||||||
|
- What: Extensive use of `.font(.system(size: N))` with hardcoded sizes across all views (onboarding, residence cards, contractor cards, task cards, document views, subscription views).
|
||||||
|
- Impact: Text doesn't scale with user's accessibility settings. Violates WCAG 2.1 SC 1.4.4.
|
||||||
|
- Source: Accessibility Auditor
|
||||||
|
|
||||||
|
**Multiple views** | Missing VoiceOver labels on interactive elements
|
||||||
|
- What: Buttons using only SF Symbols without `.accessibilityLabel()`. Decorative images without `.accessibilityHidden(true)`.
|
||||||
|
- Impact: VoiceOver users hear "Button" or image filename instead of meaningful description.
|
||||||
|
- Source: Accessibility Auditor
|
||||||
|
|
||||||
|
**OrganicDesign.swift** | Animations not respecting `accessibilityReduceMotion`
|
||||||
|
- What: `repeatForever` animations (FloatingLeaf, PulseRing, shimmer, blob pulses) have no `@Environment(\.accessibilityReduceMotion)` check.
|
||||||
|
- Impact: Users with motion sensitivity experience nausea or discomfort.
|
||||||
|
- Source: Accessibility Auditor
|
||||||
|
|
||||||
|
**Multiple views** | Missing `.accessibilityElement(children: .combine)` on card views
|
||||||
|
- What: Card views with multiple text elements not combined for VoiceOver.
|
||||||
|
- Impact: VoiceOver navigates through each piece of text separately instead of reading the card as a unit.
|
||||||
|
- Source: Accessibility Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SECURITY — Vulnerability or exposure (10 findings)
|
||||||
|
|
||||||
|
**Missing PrivacyInfo.xcprivacy** | No Privacy Manifest
|
||||||
|
- What: Required since iOS 17 for UserDefaults, analytics, device identifiers.
|
||||||
|
- Impact: App Store rejection.
|
||||||
|
- Source: Security/Privacy Scanner
|
||||||
|
|
||||||
|
**AnalyticsManager.swift:19** | Same PostHog API key in DEBUG and RELEASE
|
||||||
|
- What: Both branches use identical key. Debug events pollute production.
|
||||||
|
- Impact: Corrupted analytics data.
|
||||||
|
- Source: Security/Privacy Scanner
|
||||||
|
|
||||||
|
**ApiClient.js.kt:35, ApiClient.wasmJs.kt:35, ApiClient.jvm.kt:35** | `LogLevel.ALL` logs all HTTP traffic in production
|
||||||
|
- What: Auth tokens and PII in JSON bodies appear in browser console/logs. No DEBUG guard on JS, wasmJs, JVM targets.
|
||||||
|
- Impact: Auth token leakage in production environments.
|
||||||
|
- Source: Networking Auditor
|
||||||
|
|
||||||
|
**WidgetDataManager.swift** | Auth token stored in shared UserDefaults
|
||||||
|
- What: Auth token saved to shared App Group UserDefaults for widget access.
|
||||||
|
- Impact: Accessible to any app in the App Group. Should use Keychain shared access group.
|
||||||
|
- Source: Storage Auditor, Security/Privacy Scanner
|
||||||
|
|
||||||
|
**Multiple files** | `print()` statements with sensitive data in production code
|
||||||
|
- What: Debug prints containing user data, tokens, and state information throughout codebase.
|
||||||
|
- Impact: Sensitive data visible in device console logs.
|
||||||
|
- Source: Security/Privacy Scanner
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MODERNIZATION — Legacy pattern to update (33 findings)
|
||||||
|
|
||||||
|
**All ViewModels** | `ObservableObject` + `@Published` instead of `@Observable` macro
|
||||||
|
- What: All iOS ViewModels use legacy `ObservableObject` pattern. iOS 17+ supports `@Observable` with property-level observation.
|
||||||
|
- Impact: Whole-object invalidation instead of property-level tracking. Excessive re-renders.
|
||||||
|
- Source: Modernization Helper
|
||||||
|
|
||||||
|
**Multiple views** | `NavigationView` usage (deprecated since iOS 16)
|
||||||
|
- What: `ResidencesListView.swift:103` (ProfileTabView sheet), `AllTasksView.swift:461` (preview), `ContractorsListView.swift:466` (preview).
|
||||||
|
- Impact: Split-view behavior on iPad. Deprecated API.
|
||||||
|
- Source: Navigation Auditor, Modernization Helper
|
||||||
|
|
||||||
|
**3 files** | Deprecated `NavigationLink(isActive:)` pattern
|
||||||
|
- What: `ResidencesListView.swift:149`, `DocumentsWarrantiesView.swift:210`, `DocumentDetailView.swift:47` use hidden background `NavigationLink(isActive:)`.
|
||||||
|
- Impact: Incompatible with `NavigationStack`. Unreliable programmatic navigation.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**MainTabView.swift:12** | No `NavigationPath` on any tab NavigationStack
|
||||||
|
- What: All four tab-root NavigationStacks declared without `path:` binding.
|
||||||
|
- Impact: No programmatic navigation, no state restoration, no deep linking support.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**iOSApp.swift:1** | No `@SceneStorage` for navigation state
|
||||||
|
- What: No navigation state persistence anywhere.
|
||||||
|
- Impact: All navigation position lost on app termination.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**MainTabView.swift:1** | No `.navigationDestination(for:)` registered
|
||||||
|
- What: Zero type-safe navigation contracts. All `NavigationLink` use inline destination construction.
|
||||||
|
- Impact: Cannot add deep links or programmatic navigation without touching every call site.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**PasswordResetViewModel.swift:62** | `DispatchQueue.main.asyncAfter` instead of `Task.sleep`
|
||||||
|
- What: Hard-coded 1.5-second timers using GCD instead of Swift concurrency.
|
||||||
|
- Impact: No cancellation support. Closure fires after view dismissal.
|
||||||
|
- Source: Modernization Helper, SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
**AnalyticsManager.swift:3, ThemeManager.swift:1** | Non-View managers import SwiftUI
|
||||||
|
- What: Infrastructure classes coupled to UI framework.
|
||||||
|
- Impact: Cannot unit test without SwiftUI dependency chain.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
**FormStates (4 files)** | Non-View form state models import SwiftUI for UIImage
|
||||||
|
- What: `DocumentFormState`, `CompleteTaskFormState`, `ContractorFormState`, `ResidenceFormState` store `UIImage`/`[UIImage]`.
|
||||||
|
- Impact: Business validation logic coupled to UIKit, preventing unit testing.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DEAD CODE / UNREACHABLE (2 findings)
|
||||||
|
|
||||||
|
**ContentView.swift** | Deleted file tracked in git
|
||||||
|
- What: File deleted (shown in git status as `D`) but changes not committed.
|
||||||
|
- Impact: Unused file artifact.
|
||||||
|
- Source: Mapper
|
||||||
|
|
||||||
|
**StateFlowExtensions.swift** | Deleted file tracked in git
|
||||||
|
- What: File deleted but changes not committed.
|
||||||
|
- Impact: Unused file artifact.
|
||||||
|
- Source: Mapper
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FRAGILE — Works now but will break easily (6 findings)
|
||||||
|
|
||||||
|
**MainTabView.swift** | No coordinator/router pattern — navigation logic scattered across 5+ views
|
||||||
|
- What: Push notification routing in `PushNotificationManager`, consumed individually in `MainTabView`, `AllTasksView`, `ResidencesListView`, `DocumentsWarrantiesView`. Deep links in `iOSApp`. Auth navigation in `ResidencesListView`.
|
||||||
|
- Impact: Adding new deep link destination requires touching 3+ files.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**APILayer.kt:97-100** | Split mutex unlock pattern
|
||||||
|
- What: `initializeLookups()` has manual unlock at line 98 (early return) and `finally` unlock at line 188. Correct but fragile.
|
||||||
|
- Impact: Easy to break during refactoring.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**SubscriptionCache.swift:114-118** | Reads Kotlin DataManager directly instead of DataManagerObservable
|
||||||
|
- What: Bypasses established observation pattern (every other component uses DataManagerObservable's @Published properties).
|
||||||
|
- Impact: If Kotlin StateFlow emission timing changes, could read stale data.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**iOSApp.swift:55-64** | `initializeLookups()` called 4 times: init, APILayer.login, LoginViewModel, foreground
|
||||||
|
- What: Quadruple initialization on cold start after login.
|
||||||
|
- Impact: Wasted bandwidth and delayed navigation.
|
||||||
|
- Source: Deep Audit (cross-cutting)
|
||||||
|
|
||||||
|
**ResidencesListView.swift:125** | `fullScreenCover` bound to `isAuthenticated.negated` — custom Binding negation
|
||||||
|
- What: Inline `Binding` negation with inverted setter (`set: { self.wrappedValue = !$0 }`).
|
||||||
|
- Impact: Fragile pattern. Dismissal fires unintended side effects.
|
||||||
|
- Source: Navigation Auditor
|
||||||
|
|
||||||
|
**NotificationPreferencesView.swift:385** | ViewModel calls APILayer directly, bypasses DataManager cache
|
||||||
|
- What: `NotificationPreferencesViewModelWrapper` calls `APILayer.shared` directly. Results never cached in DataManager.
|
||||||
|
- Impact: Always shows loading spinner. Inconsistent with architecture.
|
||||||
|
- Source: SwiftUI Architecture Auditor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TESTING (23 findings)
|
||||||
|
|
||||||
|
**UI Tests** | 409 `sleep()` calls across UI test suites
|
||||||
|
- What: `Thread.sleep(forTimeInterval:)` used extensively for timing. Brittle and slow.
|
||||||
|
- Impact: Tests take longer than necessary. Flaky on slow CI.
|
||||||
|
- Source: Testing Auditor
|
||||||
|
|
||||||
|
**UI Tests** | Shared mutable state across test suites
|
||||||
|
- What: Test suites share state without proper isolation.
|
||||||
|
- Impact: Tests pass individually but fail when run together.
|
||||||
|
- Source: Testing Auditor
|
||||||
|
|
||||||
|
**Unit Tests** | Minimal test coverage — only 2 unit test suites
|
||||||
|
- What: Only `CaseraTests.swift` (template) and `TaskMetricsTests.swift`. No ViewModel tests, no form validation tests, no integration tests.
|
||||||
|
- Impact: No regression safety net for business logic.
|
||||||
|
- Source: Testing Auditor
|
||||||
|
|
||||||
|
**Build** | Missing incremental compilation settings
|
||||||
|
- What: Build settings not optimized for incremental compilation.
|
||||||
|
- Impact: Slower build times during development.
|
||||||
|
- Source: Build Optimizer
|
||||||
|
|
||||||
|
**Build** | `alwaysOutOfDate` on Kotlin script build phase
|
||||||
|
- What: Kotlin framework build phase always runs, even when source hasn't changed.
|
||||||
|
- Impact: Unnecessary rebuild time on every build.
|
||||||
|
- Source: Build Optimizer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### Summary by Category
|
||||||
|
| Category | Count |
|
||||||
|
|----------|-------|
|
||||||
|
| Critical | 14 |
|
||||||
|
| Bug | 8 |
|
||||||
|
| Silent Failure | 6 |
|
||||||
|
| Race Condition | 9 |
|
||||||
|
| Logic Error | 4 |
|
||||||
|
| Performance | 19 |
|
||||||
|
| Accessibility | 24 |
|
||||||
|
| Security | 10 |
|
||||||
|
| Modernization | 33 |
|
||||||
|
| Dead Code | 2 |
|
||||||
|
| Fragile | 6 |
|
||||||
|
| Testing | 23 |
|
||||||
|
| **Total** | **158** |
|
||||||
|
|
||||||
|
### Summary by Source
|
||||||
|
| Source | Findings |
|
||||||
|
|--------|----------|
|
||||||
|
| Concurrency Auditor | 21 |
|
||||||
|
| Memory Auditor | 8 |
|
||||||
|
| SwiftUI Performance | 15 |
|
||||||
|
| Swift Performance | 17 |
|
||||||
|
| SwiftUI Architecture | 18 |
|
||||||
|
| Security/Privacy | 10 |
|
||||||
|
| Accessibility | 24 |
|
||||||
|
| Energy | 17 |
|
||||||
|
| Storage | 5 |
|
||||||
|
| Networking | 7 |
|
||||||
|
| Codable | 19 |
|
||||||
|
| IAP | 12 |
|
||||||
|
| iCloud | 5 |
|
||||||
|
| Modernization | 33 |
|
||||||
|
| Navigation | 15 |
|
||||||
|
| Testing | 23 |
|
||||||
|
| Build Optimization | 8 |
|
||||||
|
| Deep Audit (cross-cutting) | 10 |
|
||||||
|
|
||||||
|
*Note: Some findings were reported by multiple auditors and deduplicated. Raw total across all auditors was ~267; after dedup: 158.*
|
||||||
|
|
||||||
|
### Top 10 Priorities
|
||||||
|
|
||||||
|
1. **CRITICAL: Fix `WidgetDataManager.swift:248` missing closing brace** — Build blocker. Every member after `clearPendingState` is nested inside the function.
|
||||||
|
|
||||||
|
2. **CRITICAL: Fix `StoreKitManager` transaction finish-before-verify** — Users charged by Apple but backend doesn't record. Revenue loss. Finish only after backend confirms.
|
||||||
|
|
||||||
|
3. **CRITICAL: Add `@MainActor` to `AppleSignInManager`, `StoreKitManager`, `SubscriptionCacheWrapper`, `ThemeManager`, `OnboardingState`** — All are ObservableObject with @Published mutations from non-main-actor contexts. Data races and potential crashes.
|
||||||
|
|
||||||
|
4. **CRITICAL: Wire deep link reset-password token to LoginView** — Password reset emails completely broken. `deepLinkResetToken` never reaches `RootView` → `LoginView`.
|
||||||
|
|
||||||
|
5. **CRITICAL: Add Privacy Manifest (`PrivacyInfo.xcprivacy`)** — App Store rejection risk. Required for UserDefaults, PostHog analytics, device identifiers.
|
||||||
|
|
||||||
|
6. **CRITICAL: Remove `UserDefaults.synchronize()` calls and debounce widget saves** — 6 forced disk syncs + JSON write on every task emission. Combined 5-10% battery drain.
|
||||||
|
|
||||||
|
7. **HIGH: Stop all `repeatForever` animations on `.onDisappear`** — 15+ infinite animations across onboarding, empty states, cards, shimmer, floating leaves. Combined 10-20% GPU drain.
|
||||||
|
|
||||||
|
8. **HIGH: Migrate to `NavigationStack(path:)` with `.navigationDestination(for:)`** — Replace all 3 deprecated `NavigationLink(isActive:)` patterns. Enable programmatic navigation and state restoration.
|
||||||
|
|
||||||
|
9. **HIGH: Fix `NumberFormatters.shared` thread safety** — Shared formatter mutated on every call from view body. Data race under concurrent rendering.
|
||||||
|
|
||||||
|
10. **HIGH: Split `DataManagerObservable` or migrate to `@Observable`** — 30+ @Published properties cause every view to re-render on any data change. Systemic performance issue.
|
||||||
@@ -45,8 +45,9 @@ private let proLimits = TierLimits(
|
|||||||
documents: nil
|
documents: nil
|
||||||
)
|
)
|
||||||
|
|
||||||
// MARK: - Serialized Suite (SubscriptionCacheWrapper is a shared singleton)
|
// MARK: - Serialized Suite (SubscriptionCacheWrapper is a @MainActor shared singleton)
|
||||||
|
|
||||||
|
@MainActor
|
||||||
@Suite(.serialized)
|
@Suite(.serialized)
|
||||||
struct SubscriptionGatingTests {
|
struct SubscriptionGatingTests {
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ final class AnalyticsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
config.debug = true
|
config.debug = false
|
||||||
config.flushAt = 1
|
config.flushAt = 1
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -78,9 +78,6 @@ final class AnalyticsManager {
|
|||||||
func track(_ event: AnalyticsEvent) {
|
func track(_ event: AnalyticsEvent) {
|
||||||
guard isConfigured else { return }
|
guard isConfigured else { return }
|
||||||
let (name, properties) = event.payload
|
let (name, properties) = event.payload
|
||||||
#if DEBUG
|
|
||||||
print("[Analytics] \(name)", properties ?? [:])
|
|
||||||
#endif
|
|
||||||
PostHogSDK.shared.capture(name, properties: properties)
|
PostHogSDK.shared.capture(name, properties: properties)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,9 +87,6 @@ final class AnalyticsManager {
|
|||||||
guard isConfigured else { return }
|
guard isConfigured else { return }
|
||||||
var props: [String: Any] = ["screen_name": screen.rawValue]
|
var props: [String: Any] = ["screen_name": screen.rawValue]
|
||||||
if let properties { props.merge(properties) { _, new in new } }
|
if let properties { props.merge(properties) { _, new in new } }
|
||||||
#if DEBUG
|
|
||||||
print("[Analytics] screen_viewed: \(screen.rawValue)")
|
|
||||||
#endif
|
|
||||||
PostHogSDK.shared.capture("screen_viewed", properties: props)
|
PostHogSDK.shared.capture("screen_viewed", properties: props)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ComposeApp
|
|
||||||
|
|
||||||
@@ -240,11 +240,11 @@ struct ContractorDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func quickActionsView(contractor: Contractor) -> some View {
|
private func quickActionsView(contractor: Contractor) -> some View {
|
||||||
let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
|
let hasPhone = !(contractor.phone?.isEmpty ?? true)
|
||||||
let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
|
let hasEmail = !(contractor.email?.isEmpty ?? true)
|
||||||
let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
|
let hasWebsite = !(contractor.website?.isEmpty ?? true)
|
||||||
let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
|
let hasAddress = !(contractor.streetAddress?.isEmpty ?? true) ||
|
||||||
(contractor.city != nil && !contractor.city!.isEmpty)
|
!(contractor.city?.isEmpty ?? true)
|
||||||
|
|
||||||
if hasPhone || hasEmail || hasWebsite || hasAddress {
|
if hasPhone || hasEmail || hasWebsite || hasAddress {
|
||||||
HStack(spacing: AppSpacing.sm) {
|
HStack(spacing: AppSpacing.sm) {
|
||||||
@@ -307,8 +307,8 @@ struct ContractorDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func directionsQuickAction(contractor: Contractor) -> some View {
|
private func directionsQuickAction(contractor: Contractor) -> some View {
|
||||||
let hasAddress = (contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty) ||
|
let hasAddress = !(contractor.streetAddress?.isEmpty ?? true) ||
|
||||||
(contractor.city != nil && !contractor.city!.isEmpty)
|
!(contractor.city?.isEmpty ?? true)
|
||||||
if hasAddress {
|
if hasAddress {
|
||||||
QuickActionButton(
|
QuickActionButton(
|
||||||
icon: "map.fill",
|
icon: "map.fill",
|
||||||
@@ -334,9 +334,9 @@ struct ContractorDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func contactInfoSection(contractor: Contractor) -> some View {
|
private func contactInfoSection(contractor: Contractor) -> some View {
|
||||||
let hasPhone = contractor.phone != nil && !contractor.phone!.isEmpty
|
let hasPhone = !(contractor.phone?.isEmpty ?? true)
|
||||||
let hasEmail = contractor.email != nil && !contractor.email!.isEmpty
|
let hasEmail = !(contractor.email?.isEmpty ?? true)
|
||||||
let hasWebsite = contractor.website != nil && !contractor.website!.isEmpty
|
let hasWebsite = !(contractor.website?.isEmpty ?? true)
|
||||||
|
|
||||||
if hasPhone || hasEmail || hasWebsite {
|
if hasPhone || hasEmail || hasWebsite {
|
||||||
DetailSection(title: L10n.Contractors.contactInfoSection) {
|
DetailSection(title: L10n.Contractors.contactInfoSection) {
|
||||||
@@ -403,8 +403,8 @@ struct ContractorDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func addressSection(contractor: Contractor) -> some View {
|
private func addressSection(contractor: Contractor) -> some View {
|
||||||
let hasStreet = contractor.streetAddress != nil && !contractor.streetAddress!.isEmpty
|
let hasStreet = !(contractor.streetAddress?.isEmpty ?? true)
|
||||||
let hasCity = contractor.city != nil && !contractor.city!.isEmpty
|
let hasCity = !(contractor.city?.isEmpty ?? true)
|
||||||
|
|
||||||
if hasStreet || hasCity {
|
if hasStreet || hasCity {
|
||||||
let addressComponents = [
|
let addressComponents = [
|
||||||
|
|||||||
@@ -224,15 +224,19 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
|||||||
Group {
|
Group {
|
||||||
if let errorMessage = errorMessage, items.isEmpty {
|
if let errorMessage = errorMessage, items.isEmpty {
|
||||||
// Wrap in ScrollView for pull-to-refresh support
|
// Wrap in ScrollView for pull-to-refresh support
|
||||||
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
DefaultErrorView(message: errorMessage, onRetry: onRetry)
|
||||||
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if items.isEmpty && !isLoading {
|
} else if items.isEmpty && !isLoading {
|
||||||
// Wrap in ScrollView for pull-to-refresh support
|
// Wrap in ScrollView for pull-to-refresh support
|
||||||
|
GeometryReader { geometry in
|
||||||
ScrollView {
|
ScrollView {
|
||||||
emptyContent()
|
emptyContent()
|
||||||
.frame(maxWidth: .infinity, minHeight: UIScreen.main.bounds.height * 0.6)
|
.frame(maxWidth: .infinity, minHeight: geometry.size.height * 0.6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
content(items)
|
content(items)
|
||||||
@@ -244,7 +248,10 @@ struct ListAsyncContentView<T, Content: View, EmptyContent: View>: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.refreshable {
|
.refreshable {
|
||||||
|
await withCheckedContinuation { continuation in
|
||||||
onRefresh()
|
onRefresh()
|
||||||
|
continuation.resume()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,21 +85,22 @@ struct DocumentFormState: FormState {
|
|||||||
|
|
||||||
// MARK: - Date Formatting
|
// MARK: - Date Formatting
|
||||||
|
|
||||||
private var dateFormatter: DateFormatter {
|
private static let dateFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}
|
}()
|
||||||
|
|
||||||
var purchaseDateString: String? {
|
var purchaseDateString: String? {
|
||||||
purchaseDate.map { dateFormatter.string(from: $0) }
|
purchaseDate.map { Self.dateFormatter.string(from: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var startDateString: String? {
|
var startDateString: String? {
|
||||||
startDate.map { dateFormatter.string(from: $0) }
|
startDate.map { Self.dateFormatter.string(from: $0) }
|
||||||
}
|
}
|
||||||
|
|
||||||
var endDateString: String? {
|
var endDateString: String? {
|
||||||
endDate.map { dateFormatter.string(from: $0) }
|
endDate.map { Self.dateFormatter.string(from: $0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,17 @@ struct FormField<T> {
|
|||||||
error = nil
|
error = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if field is valid (no error)
|
/// Check if field has no error. Note: returns true for fields that have
|
||||||
|
/// never been validated — use `isValidated` to confirm validation has run.
|
||||||
var isValid: Bool {
|
var isValid: Bool {
|
||||||
error == nil
|
error == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True only after `validate()` has been called and produced no error
|
||||||
|
var isValidated: Bool {
|
||||||
|
isDirty && error == nil
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if field should show error (dirty and has error)
|
/// Check if field should show error (dirty and has error)
|
||||||
var shouldShowError: Bool {
|
var shouldShowError: Bool {
|
||||||
isDirty && error != nil
|
isDirty && error != nil
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ extension Color {
|
|||||||
private static func themed(_ name: String) -> Color {
|
private static func themed(_ name: String) -> Color {
|
||||||
// Both main app and widgets use the theme from ThemeManager
|
// Both main app and widgets use the theme from ThemeManager
|
||||||
// Theme is shared via App Group UserDefaults
|
// Theme is shared via App Group UserDefaults
|
||||||
let theme = ThemeManager.shared.currentTheme.rawValue
|
let theme = MainActor.assumeIsolated {
|
||||||
|
ThemeManager.shared.currentTheme.rawValue
|
||||||
|
}
|
||||||
return Color("\(theme)/\(name)", bundle: nil)
|
return Color("\(theme)/\(name)", bundle: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ struct DocumentsTabContent: View {
|
|||||||
DocumentsListContent(documents: documents)
|
DocumentsListContent(documents: documents)
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
|
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredDocuments.count, limitKey: "documents") {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "doc",
|
icon: "doc",
|
||||||
title: L10n.Documents.noDocumentsFound,
|
title: L10n.Documents.noDocumentsFound,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct ImageViewerSheet: View {
|
|||||||
let onDismiss: () -> Void
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
TabView(selection: $selectedIndex) {
|
TabView(selection: $selectedIndex) {
|
||||||
ForEach(Array(images.enumerated()), id: \.element.id) { index, image in
|
ForEach(Array(images.enumerated()), id: \.element.id) { index, image in
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ struct WarrantiesTabContent: View {
|
|||||||
WarrantiesListContent(warranties: warranties)
|
WarrantiesListContent(warranties: warranties)
|
||||||
},
|
},
|
||||||
emptyContent: {
|
emptyContent: {
|
||||||
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
|
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: filteredWarranties.count, limitKey: "documents") {
|
||||||
EmptyStateView(
|
EmptyStateView(
|
||||||
icon: "doc.text.viewfinder",
|
icon: "doc.text.viewfinder",
|
||||||
title: L10n.Documents.noWarrantiesFound,
|
title: L10n.Documents.noWarrantiesFound,
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ struct DocumentDetailView: View {
|
|||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: "exclamationmark.triangle")
|
Image(systemName: "exclamationmark.triangle")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundColor(.red)
|
.foregroundColor(Color.appError)
|
||||||
Text(errorState.message)
|
Text(errorState.message)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
Button(L10n.Common.retry) {
|
Button(L10n.Common.retry) {
|
||||||
viewModel.loadDocumentDetail(id: documentId)
|
viewModel.loadDocumentDetail(id: documentId)
|
||||||
}
|
}
|
||||||
@@ -40,19 +40,11 @@ struct DocumentDetailView: View {
|
|||||||
}
|
}
|
||||||
.navigationTitle(L10n.Documents.documentDetails)
|
.navigationTitle(L10n.Documents.documentDetails)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.background(
|
.navigationDestination(isPresented: $navigateToEdit) {
|
||||||
// Hidden NavigationLink for programmatic navigation to edit
|
|
||||||
Group {
|
|
||||||
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
|
if let successState = viewModel.documentDetailState as? DocumentDetailStateSuccess {
|
||||||
NavigationLink(
|
EditDocumentView(document: successState.document)
|
||||||
destination: EditDocumentView(document: successState.document),
|
|
||||||
isActive: $navigateToEdit
|
|
||||||
) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
if viewModel.documentDetailState is DocumentDetailStateSuccess {
|
if viewModel.documentDetailState is DocumentDetailStateSuccess {
|
||||||
@@ -343,7 +335,7 @@ struct DocumentDetailView: View {
|
|||||||
detailRow(label: L10n.Documents.residence, value: "Residence #\(residenceId)")
|
detailRow(label: L10n.Documents.residence, value: "Residence #\(residenceId)")
|
||||||
}
|
}
|
||||||
if let taskId = document.taskId {
|
if let taskId = document.taskId {
|
||||||
detailRow(label: L10n.Documents.contractor, value: "Task #\(taskId)")
|
detailRow(label: "Task", value: "Task #\(taskId)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
@@ -499,15 +491,15 @@ struct DocumentDetailView: View {
|
|||||||
|
|
||||||
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {
|
private func getStatusColor(isActive: Bool, daysUntilExpiration: Int32) -> Color {
|
||||||
if !isActive {
|
if !isActive {
|
||||||
return .gray
|
return Color.appTextSecondary
|
||||||
} else if daysUntilExpiration < 0 {
|
} else if daysUntilExpiration < 0 {
|
||||||
return .red
|
return Color.appError
|
||||||
} else if daysUntilExpiration < 30 {
|
} else if daysUntilExpiration < 30 {
|
||||||
return .orange
|
return Color.appAccent
|
||||||
} else if daysUntilExpiration < 90 {
|
} else if daysUntilExpiration < 90 {
|
||||||
return .yellow
|
return Color.appAccent.opacity(0.8)
|
||||||
} else {
|
} else {
|
||||||
return .green
|
return Color.appPrimary
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
forceRefresh: false
|
forceRefresh: false
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
|
||||||
if let success = result as? ApiResultSuccess<NSArray> {
|
if let success = result as? ApiResultSuccess<NSArray> {
|
||||||
let documents = success.data as? [Document] ?? []
|
let documents = success.data as? [Document] ?? []
|
||||||
self.documentsState = DocumentStateSuccess(documents: documents)
|
self.documentsState = DocumentStateSuccess(documents: documents)
|
||||||
@@ -133,14 +132,11 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.documentsState = DocumentStateError(message: "Failed to load documents")
|
self.documentsState = DocumentStateError(message: "Failed to load documents")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
do {
|
|
||||||
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
self.documentsState = DocumentStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func loadDocumentDetail(id: Int32) {
|
func loadDocumentDetail(id: Int32) {
|
||||||
loadedDocumentId = id
|
loadedDocumentId = id
|
||||||
@@ -150,7 +146,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
let result = try await APILayer.shared.getDocument(id: id, forceRefresh: false)
|
||||||
|
|
||||||
do {
|
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
self.documentDetailState = DocumentDetailStateSuccess(document: document)
|
||||||
} else if let error = ApiResultBridge.error(from: result) {
|
} else if let error = ApiResultBridge.error(from: result) {
|
||||||
@@ -158,14 +153,11 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
|
self.documentDetailState = DocumentDetailStateError(message: "Failed to load document")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
do {
|
|
||||||
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
self.documentDetailState = DocumentDetailStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func updateDocument(
|
func updateDocument(
|
||||||
id: Int32,
|
id: Int32,
|
||||||
@@ -215,7 +207,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
endDate: endDate
|
endDate: endDate
|
||||||
)
|
)
|
||||||
|
|
||||||
do {
|
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
self.updateState = UpdateStateSuccess(document: document)
|
self.updateState = UpdateStateSuccess(document: document)
|
||||||
// Also refresh the detail state
|
// Also refresh the detail state
|
||||||
@@ -225,14 +216,11 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.updateState = UpdateStateError(message: "Failed to update document")
|
self.updateState = UpdateStateError(message: "Failed to update document")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
do {
|
|
||||||
self.updateState = UpdateStateError(message: error.localizedDescription)
|
self.updateState = UpdateStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func deleteDocument(id: Int32) {
|
func deleteDocument(id: Int32) {
|
||||||
self.deleteState = DeleteStateLoading()
|
self.deleteState = DeleteStateLoading()
|
||||||
@@ -241,7 +229,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let result = try await APILayer.shared.deleteDocument(id: id)
|
let result = try await APILayer.shared.deleteDocument(id: id)
|
||||||
|
|
||||||
do {
|
|
||||||
if result is ApiResultSuccess<KotlinUnit> {
|
if result is ApiResultSuccess<KotlinUnit> {
|
||||||
self.deleteState = DeleteStateSuccess()
|
self.deleteState = DeleteStateSuccess()
|
||||||
} else if let error = ApiResultBridge.error(from: result) {
|
} else if let error = ApiResultBridge.error(from: result) {
|
||||||
@@ -249,14 +236,11 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.deleteState = DeleteStateError(message: "Failed to delete document")
|
self.deleteState = DeleteStateError(message: "Failed to delete document")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
do {
|
|
||||||
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
self.deleteState = DeleteStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func resetUpdateState() {
|
func resetUpdateState() {
|
||||||
self.updateState = UpdateStateIdle()
|
self.updateState = UpdateStateIdle()
|
||||||
@@ -273,7 +257,6 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
do {
|
do {
|
||||||
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
|
let result = try await APILayer.shared.deleteDocumentImage(documentId: documentId, imageId: imageId)
|
||||||
|
|
||||||
do {
|
|
||||||
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
if let success = result as? ApiResultSuccess<Document>, let document = success.data {
|
||||||
self.deleteImageState = DeleteImageStateSuccess()
|
self.deleteImageState = DeleteImageStateSuccess()
|
||||||
// Refresh detail state with updated document (image removed)
|
// Refresh detail state with updated document (image removed)
|
||||||
@@ -283,14 +266,11 @@ class DocumentViewModelWrapper: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
|
self.deleteImageState = DeleteImageStateError(message: "Failed to delete image")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
do {
|
|
||||||
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
self.deleteImageState = DeleteImageStateError(message: error.localizedDescription)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
func resetDeleteImageState() {
|
func resetDeleteImageState() {
|
||||||
self.deleteImageState = DeleteImageStateIdle()
|
self.deleteImageState = DeleteImageStateIdle()
|
||||||
|
|||||||
@@ -206,21 +206,11 @@ struct DocumentsWarrantiesView: View {
|
|||||||
selectedTab = .warranties
|
selectedTab = .warranties
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
.navigationDestination(isPresented: $navigateToPushDocument) {
|
||||||
NavigationLink(
|
|
||||||
destination: Group {
|
|
||||||
if let documentId = pushTargetDocumentId {
|
if let documentId = pushTargetDocumentId {
|
||||||
DocumentDetailView(documentId: documentId)
|
DocumentDetailView(documentId: documentId)
|
||||||
} else {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
isActive: $navigateToPushDocument
|
|
||||||
) {
|
|
||||||
EmptyView()
|
|
||||||
}
|
}
|
||||||
.hidden()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadAllDocuments(forceRefresh: Bool = false) {
|
private func loadAllDocuments(forceRefresh: Bool = false) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct DocumentTypeHelper {
|
struct DocumentTypeHelper {
|
||||||
static let allTypes = ["warranty", "manual", "receipt", "inspection", "insurance", "other"]
|
static let allTypes = ["warranty", "manual", "receipt", "inspection", "permit", "deed", "insurance", "contract", "photo", "other"]
|
||||||
|
|
||||||
static func displayName(for value: String) -> String {
|
static func displayName(for value: String) -> String {
|
||||||
switch value {
|
switch value {
|
||||||
@@ -20,7 +20,7 @@ struct DocumentTypeHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct DocumentCategoryHelper {
|
struct DocumentCategoryHelper {
|
||||||
static let allCategories = ["appliance", "hvac", "plumbing", "electrical", "roofing", "flooring", "other"]
|
static let allCategories = ["appliance", "hvac", "plumbing", "electrical", "roofing", "structural", "landscaping", "general", "other"]
|
||||||
|
|
||||||
static func displayName(for value: String) -> String {
|
static func displayName(for value: String) -> String {
|
||||||
switch value {
|
switch value {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum DateUtils {
|
|||||||
private static let isoDateFormatter: DateFormatter = {
|
private static let isoDateFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ enum UITestRuntime {
|
|||||||
UserDefaults.standard.set(true, forKey: "ui_testing_mode")
|
UserDefaults.standard.set(true, forKey: "ui_testing_mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func resetStateIfRequested() {
|
@MainActor static func resetStateIfRequested() {
|
||||||
guard shouldResetState else { return }
|
guard shouldResetState else { return }
|
||||||
|
|
||||||
DataManager.shared.clear()
|
DataManager.shared.clear()
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ final class WidgetActionProcessor {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let actions = WidgetDataManager.shared.loadPendingActions()
|
Task {
|
||||||
|
let actions = await WidgetDataManager.shared.loadPendingActions()
|
||||||
guard !actions.isEmpty else {
|
guard !actions.isEmpty else {
|
||||||
print("WidgetActionProcessor: No pending actions")
|
print("WidgetActionProcessor: No pending actions")
|
||||||
return
|
return
|
||||||
@@ -32,7 +33,6 @@ final class WidgetActionProcessor {
|
|||||||
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
|
print("WidgetActionProcessor: Processing \(actions.count) pending action(s)")
|
||||||
|
|
||||||
for action in actions {
|
for action in actions {
|
||||||
Task {
|
|
||||||
await processAction(action)
|
await processAction(action)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import ComposeApp
|
|||||||
final class WidgetDataManager {
|
final class WidgetDataManager {
|
||||||
static let shared = WidgetDataManager()
|
static let shared = WidgetDataManager()
|
||||||
|
|
||||||
|
/// Tracks the last time `reloadAllTimelines()` was called for debouncing
|
||||||
|
private static var lastReloadTime: Date = .distantPast
|
||||||
|
/// Minimum interval between `reloadAllTimelines()` calls (seconds)
|
||||||
|
private static let reloadDebounceInterval: TimeInterval = 2.0
|
||||||
|
|
||||||
// MARK: - API Column Names (Single Source of Truth)
|
// MARK: - API Column Names (Single Source of Truth)
|
||||||
// These match the column names returned by the API's task columns endpoint
|
// These match the column names returned by the API's task columns endpoint
|
||||||
static let overdueColumn = "overdue_tasks"
|
static let overdueColumn = "overdue_tasks"
|
||||||
@@ -25,19 +30,31 @@ final class WidgetDataManager {
|
|||||||
private let limitationsEnabledKey = "widget_limitations_enabled"
|
private let limitationsEnabledKey = "widget_limitations_enabled"
|
||||||
private let isPremiumKey = "widget_is_premium"
|
private let isPremiumKey = "widget_is_premium"
|
||||||
|
|
||||||
|
/// Serial queue for thread-safe file I/O operations
|
||||||
|
private let fileQueue = DispatchQueue(label: "com.casera.widget.fileio")
|
||||||
|
|
||||||
private var sharedDefaults: UserDefaults? {
|
private var sharedDefaults: UserDefaults? {
|
||||||
UserDefaults(suiteName: appGroupIdentifier)
|
UserDefaults(suiteName: appGroupIdentifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
private init() {}
|
private init() {}
|
||||||
|
|
||||||
|
/// Reload all widget timelines, debounced to avoid excessive reloads.
|
||||||
|
/// Only triggers a reload if at least `reloadDebounceInterval` has elapsed since the last reload.
|
||||||
|
private func reloadWidgetTimelinesIfNeeded() {
|
||||||
|
let now = Date()
|
||||||
|
if now.timeIntervalSince(Self.lastReloadTime) >= Self.reloadDebounceInterval {
|
||||||
|
Self.lastReloadTime = now
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Auth Token Sharing
|
// MARK: - Auth Token Sharing
|
||||||
|
|
||||||
/// Save auth token to shared App Group for widget access
|
/// Save auth token to shared App Group for widget access
|
||||||
/// Call this after successful login or when token is refreshed
|
/// Call this after successful login or when token is refreshed
|
||||||
func saveAuthToken(_ token: String) {
|
func saveAuthToken(_ token: String) {
|
||||||
sharedDefaults?.set(token, forKey: tokenKey)
|
sharedDefaults?.set(token, forKey: tokenKey)
|
||||||
sharedDefaults?.synchronize()
|
|
||||||
print("WidgetDataManager: Saved auth token to shared container")
|
print("WidgetDataManager: Saved auth token to shared container")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,14 +68,12 @@ final class WidgetDataManager {
|
|||||||
/// Call this on logout
|
/// Call this on logout
|
||||||
func clearAuthToken() {
|
func clearAuthToken() {
|
||||||
sharedDefaults?.removeObject(forKey: tokenKey)
|
sharedDefaults?.removeObject(forKey: tokenKey)
|
||||||
sharedDefaults?.synchronize()
|
|
||||||
print("WidgetDataManager: Cleared auth token from shared container")
|
print("WidgetDataManager: Cleared auth token from shared container")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Save API base URL to shared container for widget
|
/// Save API base URL to shared container for widget
|
||||||
func saveAPIBaseURL(_ url: String) {
|
func saveAPIBaseURL(_ url: String) {
|
||||||
sharedDefaults?.set(url, forKey: apiBaseURLKey)
|
sharedDefaults?.set(url, forKey: apiBaseURLKey)
|
||||||
sharedDefaults?.synchronize()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get API base URL from shared container
|
/// Get API base URL from shared container
|
||||||
@@ -73,7 +88,6 @@ final class WidgetDataManager {
|
|||||||
func saveSubscriptionStatus(limitationsEnabled: Bool, isPremium: Bool) {
|
func saveSubscriptionStatus(limitationsEnabled: Bool, isPremium: Bool) {
|
||||||
sharedDefaults?.set(limitationsEnabled, forKey: limitationsEnabledKey)
|
sharedDefaults?.set(limitationsEnabled, forKey: limitationsEnabledKey)
|
||||||
sharedDefaults?.set(isPremium, forKey: isPremiumKey)
|
sharedDefaults?.set(isPremium, forKey: isPremiumKey)
|
||||||
sharedDefaults?.synchronize()
|
|
||||||
print("WidgetDataManager: Saved subscription status - limitations=\(limitationsEnabled), premium=\(isPremium)")
|
print("WidgetDataManager: Saved subscription status - limitations=\(limitationsEnabled), premium=\(isPremium)")
|
||||||
// Reload widget to reflect new subscription status
|
// Reload widget to reflect new subscription status
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
@@ -104,7 +118,6 @@ final class WidgetDataManager {
|
|||||||
/// Called by widget after completing a task
|
/// Called by widget after completing a task
|
||||||
func markTasksDirty() {
|
func markTasksDirty() {
|
||||||
sharedDefaults?.set(true, forKey: dirtyFlagKey)
|
sharedDefaults?.set(true, forKey: dirtyFlagKey)
|
||||||
sharedDefaults?.synchronize()
|
|
||||||
print("WidgetDataManager: Marked tasks as dirty")
|
print("WidgetDataManager: Marked tasks as dirty")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +129,6 @@ final class WidgetDataManager {
|
|||||||
/// Clear dirty flag after refreshing tasks
|
/// Clear dirty flag after refreshing tasks
|
||||||
func clearDirtyFlag() {
|
func clearDirtyFlag() {
|
||||||
sharedDefaults?.set(false, forKey: dirtyFlagKey)
|
sharedDefaults?.set(false, forKey: dirtyFlagKey)
|
||||||
sharedDefaults?.synchronize()
|
|
||||||
print("WidgetDataManager: Cleared dirty flag")
|
print("WidgetDataManager: Cleared dirty flag")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +154,40 @@ final class WidgetDataManager {
|
|||||||
|
|
||||||
// MARK: - Pending Action Processing
|
// MARK: - Pending Action Processing
|
||||||
|
|
||||||
/// Load pending actions queued by the widget
|
/// Load pending actions queued by the widget (async, non-blocking)
|
||||||
func loadPendingActions() -> [WidgetAction] {
|
func loadPendingActions() async -> [WidgetAction] {
|
||||||
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName),
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
|
||||||
FileManager.default.fileExists(atPath: fileURL.path) else {
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
fileQueue.async {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
let actions = try JSONDecoder().decode([WidgetAction].self, from: data)
|
||||||
|
continuation.resume(returning: actions)
|
||||||
|
} catch {
|
||||||
|
print("WidgetDataManager: Error loading pending actions - \(error)")
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load pending actions synchronously (blocks calling thread).
|
||||||
|
/// Prefer the async overload from the main app. This is kept for widget extension use.
|
||||||
|
func loadPendingActionsSync() -> [WidgetAction] {
|
||||||
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileQueue.sync {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,11 +199,13 @@ final class WidgetDataManager {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear all pending actions after processing
|
/// Clear all pending actions after processing
|
||||||
func clearPendingActions() {
|
func clearPendingActions() {
|
||||||
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
||||||
|
|
||||||
|
fileQueue.async {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
print("WidgetDataManager: Cleared pending actions")
|
print("WidgetDataManager: Cleared pending actions")
|
||||||
@@ -169,16 +213,28 @@ final class WidgetDataManager {
|
|||||||
// File might not exist
|
// File might not exist
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a specific action after processing
|
/// Remove a specific action after processing
|
||||||
func removeAction(_ action: WidgetAction) {
|
func removeAction(_ action: WidgetAction) {
|
||||||
var actions = loadPendingActions()
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
||||||
|
|
||||||
|
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
|
||||||
|
} else {
|
||||||
|
actions = []
|
||||||
|
}
|
||||||
|
|
||||||
actions.removeAll { $0 == action }
|
actions.removeAll { $0 == action }
|
||||||
|
|
||||||
if actions.isEmpty {
|
if actions.isEmpty {
|
||||||
clearPendingActions()
|
try? FileManager.default.removeItem(at: fileURL)
|
||||||
} else {
|
} else {
|
||||||
guard let fileURL = sharedContainerURL?.appendingPathComponent(actionsFileName) else { return }
|
|
||||||
do {
|
do {
|
||||||
let data = try JSONEncoder().encode(actions)
|
let data = try JSONEncoder().encode(actions)
|
||||||
try data.write(to: fileURL, options: .atomic)
|
try data.write(to: fileURL, options: .atomic)
|
||||||
@@ -187,11 +243,11 @@ final class WidgetDataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear pending state for a task after it's been synced
|
/// Clear pending state for a task after it's been synced
|
||||||
func clearPendingState(forTaskId taskId: Int) {
|
func clearPendingState(forTaskId taskId: Int) {
|
||||||
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName),
|
guard let fileURL = sharedContainerURL?.appendingPathComponent(pendingTasksFileName) else {
|
||||||
FileManager.default.fileExists(atPath: fileURL.path) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +257,11 @@ final class WidgetDataManager {
|
|||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileQueue.async {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: fileURL)
|
let data = try Data(contentsOf: fileURL)
|
||||||
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
|
var states = try JSONDecoder().decode([PendingTaskState].self, from: data)
|
||||||
@@ -212,17 +273,20 @@ final class WidgetDataManager {
|
|||||||
let updatedData = try JSONEncoder().encode(states)
|
let updatedData = try JSONEncoder().encode(states)
|
||||||
try updatedData.write(to: fileURL, options: .atomic)
|
try updatedData.write(to: fileURL, options: .atomic)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload widget to reflect the change
|
|
||||||
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
|
||||||
} catch {
|
} catch {
|
||||||
print("WidgetDataManager: Error clearing pending state - \(error)")
|
print("WidgetDataManager: Error clearing pending state - \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload widget to reflect the change
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
WidgetCenter.shared.reloadTimelines(ofKind: "Casera")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if there are any pending actions from the widget
|
/// Check if there are any pending actions from the widget
|
||||||
var hasPendingActions: Bool {
|
var hasPendingActions: Bool {
|
||||||
!loadPendingActions().isEmpty
|
!loadPendingActionsSync().isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Task model for widget display - simplified version of TaskDetail
|
/// Task model for widget display - simplified version of TaskDetail
|
||||||
@@ -285,6 +349,7 @@ final class WidgetDataManager {
|
|||||||
private static let dateFormatter: DateFormatter = {
|
private static let dateFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -364,28 +429,61 @@ final class WidgetDataManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileQueue.async {
|
||||||
do {
|
do {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.outputFormatting = .prettyPrinted
|
encoder.outputFormatting = .prettyPrinted
|
||||||
let data = try encoder.encode(allTasks)
|
let data = try encoder.encode(allTasks)
|
||||||
try data.write(to: fileURL, options: .atomic)
|
try data.write(to: fileURL, options: .atomic)
|
||||||
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
|
print("WidgetDataManager: Saved \(allTasks.count) tasks to widget cache")
|
||||||
|
|
||||||
// Reload widget timeline
|
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
|
||||||
} catch {
|
} catch {
|
||||||
print("WidgetDataManager: Error saving tasks - \(error)")
|
print("WidgetDataManager: Error saving tasks - \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload widget timeline (debounced) after file write completes
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.reloadWidgetTimelinesIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load tasks from the shared container
|
/// Load tasks from the shared container (async, non-blocking)
|
||||||
/// Used by the widget to read cached data
|
func loadTasks() async -> [WidgetTask] {
|
||||||
func loadTasks() -> [WidgetTask] {
|
|
||||||
guard let fileURL = tasksFileURL else {
|
guard let fileURL = tasksFileURL else {
|
||||||
print("WidgetDataManager: Unable to access shared container")
|
print("WidgetDataManager: Unable to access shared container")
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
fileQueue.async {
|
||||||
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
|
print("WidgetDataManager: No cached tasks file found")
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: fileURL)
|
||||||
|
let tasks = try JSONDecoder().decode([WidgetTask].self, from: data)
|
||||||
|
print("WidgetDataManager: Loaded \(tasks.count) tasks from widget cache")
|
||||||
|
continuation.resume(returning: tasks)
|
||||||
|
} catch {
|
||||||
|
print("WidgetDataManager: Error loading tasks - \(error)")
|
||||||
|
continuation.resume(returning: [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load tasks synchronously (blocks calling thread).
|
||||||
|
/// Prefer the async overload from the main app. This is kept for widget extension use.
|
||||||
|
func loadTasksSync() -> [WidgetTask] {
|
||||||
|
guard let fileURL = tasksFileURL else {
|
||||||
|
print("WidgetDataManager: Unable to access shared container")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileQueue.sync {
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||||
print("WidgetDataManager: No cached tasks file found")
|
print("WidgetDataManager: No cached tasks file found")
|
||||||
return []
|
return []
|
||||||
@@ -401,10 +499,12 @@ final class WidgetDataManager {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get upcoming/pending tasks for widget display
|
/// Get upcoming/pending tasks for widget display
|
||||||
|
/// Uses synchronous loading since this is typically called from widget timeline providers
|
||||||
func getUpcomingTasks() -> [WidgetTask] {
|
func getUpcomingTasks() -> [WidgetTask] {
|
||||||
let allTasks = loadTasks()
|
let allTasks = loadTasksSync()
|
||||||
|
|
||||||
// All loaded tasks are already filtered (archived and completed columns are excluded during save)
|
// All loaded tasks are already filtered (archived and completed columns are excluded during save)
|
||||||
// Sort by due date (earliest first), with overdue at top
|
// Sort by due date (earliest first), with overdue at top
|
||||||
@@ -426,13 +526,18 @@ final class WidgetDataManager {
|
|||||||
func clearCache() {
|
func clearCache() {
|
||||||
guard let fileURL = tasksFileURL else { return }
|
guard let fileURL = tasksFileURL else { return }
|
||||||
|
|
||||||
|
fileQueue.async {
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: fileURL)
|
try FileManager.default.removeItem(at: fileURL)
|
||||||
print("WidgetDataManager: Cleared widget cache")
|
print("WidgetDataManager: Cleared widget cache")
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
|
||||||
} catch {
|
} catch {
|
||||||
print("WidgetDataManager: Error clearing cache - \(error)")
|
print("WidgetDataManager: Error clearing cache - \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
<array>
|
<array>
|
||||||
<string>com.tt.casera.refresh</string>
|
<string>com.tt.casera.refresh</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
|
||||||
<true/>
|
|
||||||
<key>CASERA_IAP_ANNUAL_PRODUCT_ID</key>
|
<key>CASERA_IAP_ANNUAL_PRODUCT_ID</key>
|
||||||
<string>com.example.casera.pro.annual</string>
|
<string>com.example.casera.pro.annual</string>
|
||||||
<key>CASERA_IAP_MONTHLY_PRODUCT_ID</key>
|
<key>CASERA_IAP_MONTHLY_PRODUCT_ID</key>
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
<key>UIBackgroundModes</key>
|
<key>UIBackgroundModes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>remote-notification</string>
|
<string>remote-notification</string>
|
||||||
<string>fetch</string>
|
|
||||||
<string>processing</string>
|
<string>processing</string>
|
||||||
</array>
|
</array>
|
||||||
<key>UTExportedTypeDeclarations</key>
|
<key>UTExportedTypeDeclarations</key>
|
||||||
|
|||||||
@@ -137,8 +137,15 @@
|
|||||||
"4.9" : {
|
"4.9" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"7-day free trial, then %@" : {
|
"7-day free trial, then %@%@" : {
|
||||||
|
"localizations" : {
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "new",
|
||||||
|
"value" : "7-day free trial, then %1$@%2$@"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"ABC123" : {
|
"ABC123" : {
|
||||||
|
|
||||||
@@ -171,10 +178,6 @@
|
|||||||
"comment" : "A link that directs users to log in if they already have an account.",
|
"comment" : "A link that directs users to log in if they already have an account.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Animation Testing" : {
|
|
||||||
"comment" : "The title of a view that tests different animations.",
|
|
||||||
"isCommentAutoGenerated" : true
|
|
||||||
},
|
|
||||||
"Animation Type" : {
|
"Animation Type" : {
|
||||||
"comment" : "A label above the picker for selecting an animation type.",
|
"comment" : "A label above the picker for selecting an animation type.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -5251,6 +5254,10 @@
|
|||||||
"comment" : "A button label that says \"Complete Task\".",
|
"comment" : "A button label that says \"Complete Task\".",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Completion Animation" : {
|
||||||
|
"comment" : "The title of the view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Completion Photos" : {
|
"Completion Photos" : {
|
||||||
"comment" : "The title for the view that shows a user's photo submissions.",
|
"comment" : "The title for the view that shows a user's photo submissions.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
@@ -17334,6 +17341,9 @@
|
|||||||
"Free" : {
|
"Free" : {
|
||||||
"comment" : "A label indicating a free feature.",
|
"comment" : "A label indicating a free feature.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Free trial ends %@" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Generate Code" : {
|
"Generate Code" : {
|
||||||
"comment" : "A button label that generates a new invitation code.",
|
"comment" : "A button label that generates a new invitation code.",
|
||||||
@@ -17430,6 +17440,16 @@
|
|||||||
},
|
},
|
||||||
"Logging in..." : {
|
"Logging in..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Manage at casera.app" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Manage your subscription at casera.app" : {
|
||||||
|
"comment" : "A text instruction that directs them to manage their subscription on casera.app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Manage your subscription on your Android device" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Mark Task In Progress" : {
|
"Mark Task In Progress" : {
|
||||||
"comment" : "A button label that says \"Mark Task In Progress\".",
|
"comment" : "A button label that says \"Mark Task In Progress\".",
|
||||||
@@ -17490,6 +17510,10 @@
|
|||||||
"comment" : "A button that dismisses the success dialog.",
|
"comment" : "A button that dismisses the success dialog.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Open casera.app" : {
|
||||||
|
"comment" : "A button label that opens the casera.app settings page.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"or" : {
|
"or" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
@@ -30112,6 +30136,10 @@
|
|||||||
},
|
},
|
||||||
"You're all set up!" : {
|
"You're all set up!" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"You're already subscribed" : {
|
||||||
|
"comment" : "A message displayed when a user is already subscribed to the app.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Your data will be synced across devices" : {
|
"Your data will be synced across devices" : {
|
||||||
|
|
||||||
@@ -30123,6 +30151,13 @@
|
|||||||
"Your home maintenance companion" : {
|
"Your home maintenance companion" : {
|
||||||
"comment" : "The tagline for the app, describing its purpose.",
|
"comment" : "The tagline for the app, describing its purpose.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Your subscription is managed on another platform." : {
|
||||||
|
"comment" : "A description of a user's subscription on an unspecified platform.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Your subscription is managed through Google Play on your Android device." : {
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version" : "1.1"
|
"version" : "1.1"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AuthenticationServices
|
import AuthenticationServices
|
||||||
|
import UIKit
|
||||||
|
|
||||||
/// Handles Sign in with Apple authentication flow
|
/// Handles Sign in with Apple authentication flow
|
||||||
|
@MainActor
|
||||||
class AppleSignInManager: NSObject, ObservableObject {
|
class AppleSignInManager: NSObject, ObservableObject {
|
||||||
// MARK: - Published Properties
|
// MARK: - Published Properties
|
||||||
@Published var isProcessing: Bool = false
|
@Published var isProcessing: Bool = false
|
||||||
@@ -32,7 +34,8 @@ class AppleSignInManager: NSObject, ObservableObject {
|
|||||||
// MARK: - ASAuthorizationControllerDelegate
|
// MARK: - ASAuthorizationControllerDelegate
|
||||||
|
|
||||||
extension AppleSignInManager: ASAuthorizationControllerDelegate {
|
extension AppleSignInManager: ASAuthorizationControllerDelegate {
|
||||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
|
nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) {
|
||||||
|
Task { @MainActor in
|
||||||
isProcessing = false
|
isProcessing = false
|
||||||
|
|
||||||
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
|
guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else {
|
||||||
@@ -67,8 +70,10 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate {
|
|||||||
|
|
||||||
completionHandler?(.success(credential))
|
completionHandler?(.success(credential))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
nonisolated func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) {
|
||||||
|
Task { @MainActor in
|
||||||
isProcessing = false
|
isProcessing = false
|
||||||
|
|
||||||
// Check if user cancelled
|
// Check if user cancelled
|
||||||
@@ -105,12 +110,14 @@ extension AppleSignInManager: ASAuthorizationControllerDelegate {
|
|||||||
self.error = error
|
self.error = error
|
||||||
completionHandler?(.failure(error))
|
completionHandler?(.failure(error))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ASAuthorizationControllerPresentationContextProviding
|
// MARK: - ASAuthorizationControllerPresentationContextProviding
|
||||||
|
|
||||||
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
|
extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding {
|
||||||
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
nonisolated func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
|
||||||
|
MainActor.assumeIsolated {
|
||||||
// Get the key window for presentation
|
// Get the key window for presentation
|
||||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
|
let window = scene.windows.first(where: { $0.isKeyWindow }) else {
|
||||||
@@ -122,6 +129,7 @@ extension AppleSignInManager: ASAuthorizationControllerPresentationContextProvid
|
|||||||
}
|
}
|
||||||
return window
|
return window
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Supporting Types
|
// MARK: - Supporting Types
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct LoginView: View {
|
|||||||
@State private var showPasswordReset = false
|
@State private var showPasswordReset = false
|
||||||
@State private var isPasswordVisible = false
|
@State private var isPasswordVisible = false
|
||||||
@State private var activeResetToken: String?
|
@State private var activeResetToken: String?
|
||||||
@StateObject private var googleSignInManager = GoogleSignInManager.shared
|
@ObservedObject private var googleSignInManager = GoogleSignInManager.shared
|
||||||
@Binding var resetToken: String?
|
@Binding var resetToken: String?
|
||||||
var onLoginSuccess: (() -> Void)?
|
var onLoginSuccess: (() -> Void)?
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ struct LoginView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
// Warm organic background
|
// Warm organic background
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import SwiftUI
|
|||||||
struct MainTabView: View {
|
struct MainTabView: View {
|
||||||
@EnvironmentObject private var themeManager: ThemeManager
|
@EnvironmentObject private var themeManager: ThemeManager
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@StateObject private var authManager = AuthenticationManager.shared
|
@ObservedObject private var authManager = AuthenticationManager.shared
|
||||||
@ObservedObject private var pushManager = PushNotificationManager.shared
|
@ObservedObject private var pushManager = PushNotificationManager.shared
|
||||||
var refreshID: UUID
|
var refreshID: UUID
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ResidencesListView()
|
ResidencesListView()
|
||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
@@ -19,7 +19,7 @@ struct MainTabView: View {
|
|||||||
.tag(0)
|
.tag(0)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.residencesTab)
|
||||||
|
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
AllTasksView()
|
AllTasksView()
|
||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
@@ -29,7 +29,7 @@ struct MainTabView: View {
|
|||||||
.tag(1)
|
.tag(1)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.tasksTab)
|
||||||
|
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ContractorsListView()
|
ContractorsListView()
|
||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
@@ -39,7 +39,7 @@ struct MainTabView: View {
|
|||||||
.tag(2)
|
.tag(2)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.contractorsTab)
|
||||||
|
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
DocumentsWarrantiesView(residenceId: nil)
|
DocumentsWarrantiesView(residenceId: nil)
|
||||||
}
|
}
|
||||||
.id(refreshID)
|
.id(refreshID)
|
||||||
@@ -50,7 +50,7 @@ struct MainTabView: View {
|
|||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Navigation.documentsTab)
|
||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: authManager.isAuthenticated) { _ in
|
.onChange(of: authManager.isAuthenticated) { _, _ in
|
||||||
selectedTab = 0
|
selectedTab = 0
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import ComposeApp
|
|||||||
|
|
||||||
/// Coordinates the onboarding flow, presenting the appropriate view based on current step
|
/// Coordinates the onboarding flow, presenting the appropriate view based on current step
|
||||||
struct OnboardingCoordinator: View {
|
struct OnboardingCoordinator: View {
|
||||||
@StateObject private var onboardingState = OnboardingState.shared
|
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||||
@StateObject private var residenceViewModel = ResidenceViewModel()
|
@StateObject private var residenceViewModel = ResidenceViewModel()
|
||||||
@State private var showingRegister = false
|
|
||||||
@State private var showingLogin = false
|
|
||||||
@State private var isNavigatingBack = false
|
@State private var isNavigatingBack = false
|
||||||
@State private var isCreatingResidence = false
|
@State private var isCreatingResidence = false
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
@State private var showingLoginSheet = false
|
@State private var showingLoginSheet = false
|
||||||
@State private var isExpanded = false
|
@State private var isExpanded = false
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
@StateObject private var googleSignInManager = GoogleSignInManager.shared
|
@ObservedObject private var googleSignInManager = GoogleSignInManager.shared
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -72,7 +72,9 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
.frame(width: 120, height: 120)
|
.frame(width: 120, height: 120)
|
||||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -353,6 +355,9 @@ struct OnboardingCreateAccountContent: View {
|
|||||||
onAccountCreated(isVerified)
|
onAccountCreated(isVerified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
@ObservedObject private var onboardingState = OnboardingState.shared
|
@ObservedObject private var onboardingState = OnboardingState.shared
|
||||||
@State private var selectedTasks: Set<UUID> = []
|
@State private var selectedTasks: Set<UUID> = []
|
||||||
@State private var isCreatingTasks = false
|
@State private var isCreatingTasks = false
|
||||||
@State private var showCustomTaskSheet = false
|
|
||||||
@State private var expandedCategory: String? = nil
|
@State private var expandedCategory: String? = nil
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -161,7 +160,9 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
.offset(x: -15, y: -15)
|
.offset(x: -15, y: -15)
|
||||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,7 +179,9 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
.offset(x: 15, y: 15)
|
.offset(x: 15, y: 15)
|
||||||
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -341,6 +344,9 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
// Expand first category by default
|
// Expand first category by default
|
||||||
expandedCategory = taskCategories.first?.name
|
expandedCategory = taskCategories.first?.name
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectPopularTasks() {
|
private func selectPopularTasks() {
|
||||||
@@ -392,14 +398,12 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
for template in selectedTemplates {
|
for template in selectedTemplates {
|
||||||
// Look up category ID from DataManager
|
// Look up category ID from DataManager
|
||||||
let categoryId: Int32? = {
|
let categoryId: Int32? = {
|
||||||
let categoryName = template.category.lowercased()
|
return dataManager.taskCategories.first { $0.name.caseInsensitiveCompare(template.category) == .orderedSame }?.id
|
||||||
return dataManager.taskCategories.first { $0.name.lowercased() == categoryName }?.id
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Look up frequency ID from DataManager
|
// Look up frequency ID from DataManager
|
||||||
let frequencyId: Int32? = {
|
let frequencyId: Int32? = {
|
||||||
let frequencyName = template.frequency.lowercased()
|
return dataManager.taskFrequencies.first { $0.name.caseInsensitiveCompare(template.frequency) == .orderedSame }?.id
|
||||||
return dataManager.taskFrequencies.first { $0.name.lowercased() == frequencyName }?.id
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
|
print("🏠 ONBOARDING: Creating task '\(template.title)' - categoryId=\(String(describing: categoryId)), frequencyId=\(String(describing: frequencyId))")
|
||||||
@@ -424,12 +428,14 @@ struct OnboardingFirstTaskContent: View {
|
|||||||
print("🏠 ONBOARDING: Task '\(template.title)' creation: \(success ? "SUCCESS" : "FAILED") (\(completedCount)/\(totalCount))")
|
print("🏠 ONBOARDING: Task '\(template.title)' creation: \(success ? "SUCCESS" : "FAILED") (\(completedCount)/\(totalCount))")
|
||||||
|
|
||||||
if completedCount == totalCount {
|
if completedCount == totalCount {
|
||||||
|
Task { @MainActor in
|
||||||
self.isCreatingTasks = false
|
self.isCreatingTasks = false
|
||||||
self.onTaskAdded()
|
self.onTaskAdded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Onboarding Task Category Model
|
// MARK: - Onboarding Task Category Model
|
||||||
|
|||||||
@@ -81,7 +81,9 @@ struct OnboardingJoinResidenceContent: View {
|
|||||||
.frame(width: 140, height: 140)
|
.frame(width: 140, height: 140)
|
||||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -222,6 +224,9 @@ struct OnboardingJoinResidenceContent: View {
|
|||||||
isCodeFieldFocused = true
|
isCodeFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func joinResidence() {
|
private func joinResidence() {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
var onContinue: () -> Void
|
var onContinue: () -> Void
|
||||||
|
|
||||||
@FocusState private var isTextFieldFocused: Bool
|
@FocusState private var isTextFieldFocused: Bool
|
||||||
@State private var showSuggestions = false
|
|
||||||
@State private var isAnimating = false
|
@State private var isAnimating = false
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
@@ -84,7 +83,9 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
.offset(x: -20, y: -20)
|
.offset(x: -20, y: -20)
|
||||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +102,9 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
.offset(x: 20, y: 20)
|
.offset(x: 20, y: 20)
|
||||||
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
.scaleEffect(isAnimating ? 0.95 : 1.05)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true).delay(0.5)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -264,6 +267,9 @@ struct OnboardingNameResidenceContent: View {
|
|||||||
isTextFieldFocused = true
|
isTextFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import StoreKit
|
|||||||
struct OnboardingSubscriptionContent: View {
|
struct OnboardingSubscriptionContent: View {
|
||||||
var onSubscribe: () -> Void
|
var onSubscribe: () -> Void
|
||||||
|
|
||||||
@StateObject private var storeKit = StoreKitManager.shared
|
@ObservedObject private var storeKit = StoreKitManager.shared
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var purchaseError: String?
|
@State private var purchaseError: String?
|
||||||
@State private var selectedPlan: PricingPlan = .yearly
|
@State private var selectedPlan: PricingPlan = .yearly
|
||||||
@@ -109,7 +109,12 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
)
|
)
|
||||||
.frame(width: 180, height: 180)
|
.frame(width: 180, height: 180)
|
||||||
.scaleEffect(animateBadge ? 1.1 : 1.0)
|
.scaleEffect(animateBadge ? 1.1 : 1.0)
|
||||||
.animation(.easeInOut(duration: 2).repeatForever(autoreverses: true), value: animateBadge)
|
.animation(
|
||||||
|
animateBadge
|
||||||
|
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
|
value: animateBadge
|
||||||
|
)
|
||||||
|
|
||||||
// Crown icon
|
// Crown icon
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -210,6 +215,7 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
OrganicPricingPlanCard(
|
OrganicPricingPlanCard(
|
||||||
plan: .yearly,
|
plan: .yearly,
|
||||||
isSelected: selectedPlan == .yearly,
|
isSelected: selectedPlan == .yearly,
|
||||||
|
displayPrice: yearlyProduct()?.displayPrice,
|
||||||
onSelect: { selectedPlan = .yearly }
|
onSelect: { selectedPlan = .yearly }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,6 +223,7 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
OrganicPricingPlanCard(
|
OrganicPricingPlanCard(
|
||||||
plan: .monthly,
|
plan: .monthly,
|
||||||
isSelected: selectedPlan == .monthly,
|
isSelected: selectedPlan == .monthly,
|
||||||
|
displayPrice: monthlyProduct()?.displayPrice,
|
||||||
onSelect: { selectedPlan = .monthly }
|
onSelect: { selectedPlan = .monthly }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -277,7 +284,7 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
|
|
||||||
// Legal text
|
// Legal text
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text("7-day free trial, then \(selectedPlan == .yearly ? "$23.99/year" : "$2.99/month")")
|
Text("7-day free trial, then \(productForSelectedPlan()?.displayPrice ?? selectedPlan.price)\(selectedPlan.period)")
|
||||||
.font(.system(size: 12, weight: .medium))
|
.font(.system(size: 12, weight: .medium))
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
@@ -296,6 +303,9 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
animateBadge = true
|
animateBadge = true
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
animateBadge = false
|
||||||
|
}
|
||||||
.task {
|
.task {
|
||||||
if storeKit.products.isEmpty {
|
if storeKit.products.isEmpty {
|
||||||
await storeKit.loadProducts()
|
await storeKit.loadProducts()
|
||||||
@@ -338,8 +348,16 @@ struct OnboardingSubscriptionContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func productForSelectedPlan() -> Product? {
|
private func productForSelectedPlan() -> Product? {
|
||||||
let productIdHint = selectedPlan == .yearly ? "annual" : "monthly"
|
selectedPlan == .yearly ? yearlyProduct() : monthlyProduct()
|
||||||
return storeKit.products.first { $0.id.localizedCaseInsensitiveContains(productIdHint) }
|
}
|
||||||
|
|
||||||
|
private func yearlyProduct() -> Product? {
|
||||||
|
storeKit.products.first { $0.id.localizedCaseInsensitiveContains("annual") }
|
||||||
|
?? storeKit.products.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func monthlyProduct() -> Product? {
|
||||||
|
storeKit.products.first { $0.id.localizedCaseInsensitiveContains("monthly") }
|
||||||
?? storeKit.products.first
|
?? storeKit.products.first
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,6 +409,7 @@ enum PricingPlan {
|
|||||||
private struct OrganicPricingPlanCard: View {
|
private struct OrganicPricingPlanCard: View {
|
||||||
let plan: PricingPlan
|
let plan: PricingPlan
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
|
var displayPrice: String? = nil
|
||||||
var onSelect: () -> Void
|
var onSelect: () -> Void
|
||||||
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
@@ -444,7 +463,7 @@ private struct OrganicPricingPlanCard: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
VStack(alignment: .trailing, spacing: 0) {
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
Text(plan.price)
|
Text(displayPrice ?? plan.price)
|
||||||
.font(.system(size: 20, weight: .bold))
|
.font(.system(size: 20, weight: .bold))
|
||||||
.foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary)
|
.foregroundColor(isSelected ? Color.appAccent : Color.appTextPrimary)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ struct OnboardingValuePropsContent: View {
|
|||||||
var onContinue: () -> Void
|
var onContinue: () -> Void
|
||||||
|
|
||||||
@State private var currentPage = 0
|
@State private var currentPage = 0
|
||||||
@State private var animateFeatures = false
|
|
||||||
|
|
||||||
private let features: [FeatureHighlight] = [
|
private let features: [FeatureHighlight] = [
|
||||||
FeatureHighlight(
|
FeatureHighlight(
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
.frame(width: 140, height: 140)
|
.frame(width: 140, height: 140)
|
||||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -133,12 +135,13 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.verificationCodeField)
|
||||||
.keyboardDismissToolbar()
|
.keyboardDismissToolbar()
|
||||||
.onChange(of: viewModel.code) { _, newValue in
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
// Limit to 6 digits
|
// Filter to digits only and truncate to 6 in one pass to prevent re-triggering
|
||||||
if newValue.count > 6 {
|
let filtered = String(newValue.filter { $0.isNumber }.prefix(6))
|
||||||
viewModel.code = String(newValue.prefix(6))
|
if filtered != newValue {
|
||||||
|
viewModel.code = filtered
|
||||||
}
|
}
|
||||||
// Auto-verify when 6 digits entered
|
// Auto-verify when 6 digits entered
|
||||||
if newValue.count == 6 {
|
if filtered.count == 6 {
|
||||||
viewModel.verifyEmail()
|
viewModel.verifyEmail()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,6 +241,9 @@ struct OnboardingVerifyEmailContent: View {
|
|||||||
isCodeFieldFocused = true
|
isCodeFieldFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
}
|
||||||
.onReceive(viewModel.$isVerified) { isVerified in
|
.onReceive(viewModel.$isVerified) { isVerified in
|
||||||
print("🏠 ONBOARDING: onReceive isVerified = \(isVerified), hasCalledOnVerified = \(hasCalledOnVerified)")
|
print("🏠 ONBOARDING: onReceive isVerified = \(isVerified), hasCalledOnVerified = \(hasCalledOnVerified)")
|
||||||
if isVerified && !hasCalledOnVerified {
|
if isVerified && !hasCalledOnVerified {
|
||||||
|
|||||||
@@ -76,7 +76,9 @@ struct OnboardingWelcomeView: View {
|
|||||||
.frame(width: 200, height: 200)
|
.frame(width: 200, height: 200)
|
||||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||||
.animation(
|
.animation(
|
||||||
Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true),
|
isAnimating
|
||||||
|
? Animation.easeInOut(duration: 2.5).repeatForever(autoreverses: true)
|
||||||
|
: .default,
|
||||||
value: isAnimating
|
value: isAnimating
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,10 +180,6 @@ struct OnboardingWelcomeView: View {
|
|||||||
.padding(.bottom, 20)
|
.padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deterministic marker for UI tests.
|
|
||||||
Color.clear
|
|
||||||
.frame(width: 1, height: 1)
|
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Onboarding.welcomeTitle)
|
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingLoginSheet) {
|
.sheet(isPresented: $showingLoginSheet) {
|
||||||
LoginView(onLoginSuccess: {
|
LoginView(onLoginSuccess: {
|
||||||
@@ -196,6 +194,9 @@ struct OnboardingWelcomeView: View {
|
|||||||
iconOpacity = 1.0
|
iconOpacity = 1.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct ForgotPasswordView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ struct PasswordResetFlow: View {
|
|||||||
.animation(.easeInOut, value: viewModel.currentStep)
|
.animation(.easeInOut, value: viewModel.currentStep)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
// Set up callback for auto-login success
|
// Set up callback for auto-login success
|
||||||
viewModel.onLoginSuccess = { [self] isVerified in
|
// Capture dismiss and onLoginSuccess directly to avoid holding the entire view struct
|
||||||
// Dismiss the sheet first
|
let dismissAction = dismiss
|
||||||
dismiss()
|
let loginHandler = onLoginSuccess
|
||||||
// Then call the parent's login success handler
|
viewModel.onLoginSuccess = { isVerified in
|
||||||
onLoginSuccess?(isVerified)
|
dismissAction()
|
||||||
|
loginHandler?(isVerified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
// Callback for successful login after password reset
|
// Callback for successful login after password reset
|
||||||
var onLoginSuccess: ((Bool) -> Void)?
|
var onLoginSuccess: ((Bool) -> Void)?
|
||||||
|
|
||||||
|
// Cancellable delayed transition task
|
||||||
|
private var delayedTransitionTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
init(resetToken: String? = nil) {
|
init(resetToken: String? = nil) {
|
||||||
// If we have a reset token from deep link, skip to password reset step
|
// If we have a reset token from deep link, skip to password reset step
|
||||||
@@ -59,7 +62,10 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
self.successMessage = "Check your email for a 6-digit verification code"
|
self.successMessage = "Check your email for a 6-digit verification code"
|
||||||
|
|
||||||
// Automatically move to next step after short delay
|
// Automatically move to next step after short delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
self.delayedTransitionTask?.cancel()
|
||||||
|
self.delayedTransitionTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
self.successMessage = nil
|
self.successMessage = nil
|
||||||
self.currentStep = .verifyCode
|
self.currentStep = .verifyCode
|
||||||
}
|
}
|
||||||
@@ -99,7 +105,10 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
self.successMessage = "Code verified! Now set your new password"
|
self.successMessage = "Code verified! Now set your new password"
|
||||||
|
|
||||||
// Automatically move to next step after short delay
|
// Automatically move to next step after short delay
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
|
self.delayedTransitionTask?.cancel()
|
||||||
|
self.delayedTransitionTask = Task {
|
||||||
|
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
self.successMessage = nil
|
self.successMessage = nil
|
||||||
self.currentStep = .resetPassword
|
self.currentStep = .resetPassword
|
||||||
}
|
}
|
||||||
@@ -191,8 +200,8 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
let response = success.data {
|
let response = success.data {
|
||||||
let isVerified = response.user.verified
|
let isVerified = response.user.verified
|
||||||
|
|
||||||
// Initialize lookups
|
// Lookups are already initialized by APILayer.login() internally
|
||||||
_ = try? await APILayer.shared.initializeLookups()
|
// (see APILayer.kt line 1205) — no need to call again here
|
||||||
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
||||||
@@ -200,7 +209,9 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
self.onLoginSuccess?(isVerified)
|
self.onLoginSuccess?(isVerified)
|
||||||
} else if let error = ApiResultBridge.error(from: loginResult) {
|
} else if let error = ApiResultBridge.error(from: loginResult) {
|
||||||
// Auto-login failed, fall back to manual login
|
// Auto-login failed, fall back to manual login
|
||||||
|
#if DEBUG
|
||||||
print("Auto-login failed: \(error.message)")
|
print("Auto-login failed: \(error.message)")
|
||||||
|
#endif
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||||
self.currentStep = .success
|
self.currentStep = .success
|
||||||
@@ -211,7 +222,9 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Auto-login failed, fall back to manual login
|
// Auto-login failed, fall back to manual login
|
||||||
|
#if DEBUG
|
||||||
print("Auto-login error: \(error.localizedDescription)")
|
print("Auto-login error: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
self.successMessage = "Password reset successfully! You can now log in with your new password."
|
||||||
self.currentStep = .success
|
self.currentStep = .success
|
||||||
@@ -250,6 +263,8 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
|
|
||||||
/// Reset all state
|
/// Reset all state
|
||||||
func reset() {
|
func reset() {
|
||||||
|
delayedTransitionTask?.cancel()
|
||||||
|
delayedTransitionTask = nil
|
||||||
email = ""
|
email = ""
|
||||||
code = ""
|
code = ""
|
||||||
newPassword = ""
|
newPassword = ""
|
||||||
@@ -261,6 +276,10 @@ class PasswordResetViewModel: ObservableObject {
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
delayedTransitionTask?.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
func clearError() {
|
func clearError() {
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct ResetPasswordView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct VerifyResetCodeView: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
@@ -98,10 +98,10 @@ struct VerifyResetCodeView: View {
|
|||||||
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
.stroke(isCodeFocused ? Color.appPrimary : Color.appTextSecondary.opacity(0.15), lineWidth: 1.5)
|
||||||
)
|
)
|
||||||
.onChange(of: viewModel.code) { _, newValue in
|
.onChange(of: viewModel.code) { _, newValue in
|
||||||
if newValue.count > 6 {
|
let filtered = String(newValue.filter { $0.isNumber }.prefix(6))
|
||||||
viewModel.code = String(newValue.prefix(6))
|
if filtered != newValue {
|
||||||
|
viewModel.code = filtered
|
||||||
}
|
}
|
||||||
viewModel.code = newValue.filter { $0.isNumber }
|
|
||||||
viewModel.clearError()
|
viewModel.clearError()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import SwiftUI
|
|||||||
struct AnimationTestingView: View {
|
struct AnimationTestingView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
// Animation selection
|
// Animation selection (persisted)
|
||||||
@State private var selectedAnimation: TaskAnimationType = .implode
|
@StateObject private var animationPreference = AnimationPreference.shared
|
||||||
|
private var selectedAnimation: TaskAnimationType {
|
||||||
|
get { animationPreference.selectedAnimation }
|
||||||
|
}
|
||||||
|
|
||||||
// Fake task data
|
// Fake task data
|
||||||
@State private var columns: [TestColumn] = TestColumn.defaultColumns
|
@State private var columns: [TestColumn] = TestColumn.defaultColumns
|
||||||
@@ -30,7 +33,7 @@ struct AnimationTestingView: View {
|
|||||||
resetButton
|
resetButton
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Animation Testing")
|
.navigationTitle("Completion Animation")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
@@ -59,13 +62,13 @@ struct AnimationTestingView: View {
|
|||||||
|
|
||||||
ScrollView(.horizontal, showsIndicators: false) {
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
HStack(spacing: AppSpacing.xs) {
|
HStack(spacing: AppSpacing.xs) {
|
||||||
ForEach(TaskAnimationType.allCases) { animation in
|
ForEach(TaskAnimationType.selectableCases) { animation in
|
||||||
AnimationChip(
|
AnimationChip(
|
||||||
animation: animation,
|
animation: animation,
|
||||||
isSelected: selectedAnimation == animation,
|
isSelected: selectedAnimation == animation,
|
||||||
onSelect: {
|
onSelect: {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
selectedAnimation = animation
|
animationPreference.selectedAnimation = animation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -135,6 +138,17 @@ struct AnimationTestingView: View {
|
|||||||
|
|
||||||
animatingTaskId = task.id
|
animatingTaskId = task.id
|
||||||
|
|
||||||
|
// No animation: instant move
|
||||||
|
if selectedAnimation == .none {
|
||||||
|
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
|
||||||
|
columns[currentIndex].tasks.remove(at: taskIndex)
|
||||||
|
}
|
||||||
|
columns[currentIndex + 1].tasks.insert(task, at: 0)
|
||||||
|
animatingTaskId = nil
|
||||||
|
animationPhase = .idle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Extended timing animations: shrink card, show checkmark, THEN move task
|
// Extended timing animations: shrink card, show checkmark, THEN move task
|
||||||
if selectedAnimation.needsExtendedTiming {
|
if selectedAnimation.needsExtendedTiming {
|
||||||
// Phase 1: Start shrinking
|
// Phase 1: Start shrinking
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import SwiftUI
|
|||||||
// MARK: - Animation Type Enum
|
// MARK: - Animation Type Enum
|
||||||
|
|
||||||
enum TaskAnimationType: String, CaseIterable, Identifiable {
|
enum TaskAnimationType: String, CaseIterable, Identifiable {
|
||||||
|
case none = "None"
|
||||||
case implode = "Implode"
|
case implode = "Implode"
|
||||||
case firework = "Firework"
|
case firework = "Firework"
|
||||||
case starburst = "Starburst"
|
case starburst = "Starburst"
|
||||||
@@ -12,6 +13,7 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .none: return "minus.circle"
|
||||||
case .implode: return "checkmark.circle"
|
case .implode: return "checkmark.circle"
|
||||||
case .firework: return "sparkle"
|
case .firework: return "sparkle"
|
||||||
case .starburst: return "sun.max.fill"
|
case .starburst: return "sun.max.fill"
|
||||||
@@ -21,6 +23,7 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
|
|||||||
|
|
||||||
var description: String {
|
var description: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .none: return "No animation, instant move"
|
||||||
case .implode: return "Sucks into center, becomes checkmark"
|
case .implode: return "Sucks into center, becomes checkmark"
|
||||||
case .firework: return "Explodes into colorful sparks"
|
case .firework: return "Explodes into colorful sparks"
|
||||||
case .starburst: return "Radiating rays from checkmark"
|
case .starburst: return "Radiating rays from checkmark"
|
||||||
@@ -29,7 +32,17 @@ enum TaskAnimationType: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// All celebration animations need extended timing for checkmark display
|
/// All celebration animations need extended timing for checkmark display
|
||||||
var needsExtendedTiming: Bool { true }
|
var needsExtendedTiming: Bool {
|
||||||
|
switch self {
|
||||||
|
case .none: return false
|
||||||
|
default: return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selectable animation types (excludes "none" from picker in testing view)
|
||||||
|
static var selectableCases: [TaskAnimationType] {
|
||||||
|
allCases
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Animation Phase
|
// MARK: - Animation Phase
|
||||||
@@ -159,6 +172,8 @@ extension View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
func taskAnimation(type: TaskAnimationType, phase: AnimationPhase) -> some View {
|
func taskAnimation(type: TaskAnimationType, phase: AnimationPhase) -> some View {
|
||||||
switch type {
|
switch type {
|
||||||
|
case .none:
|
||||||
|
self
|
||||||
case .implode:
|
case .implode:
|
||||||
self.implodeAnimation(phase: phase)
|
self.implodeAnimation(phase: phase)
|
||||||
case .firework:
|
case .firework:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ComposeApp
|
|||||||
struct NotificationPreferencesView: View {
|
struct NotificationPreferencesView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@StateObject private var viewModel = NotificationPreferencesViewModelWrapper()
|
@StateObject private var viewModel = NotificationPreferencesViewModelWrapper()
|
||||||
|
@State private var isInitialLoad = true
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -96,6 +97,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.taskDueSoon) { _, newValue in
|
.onChange(of: viewModel.taskDueSoon) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(taskDueSoon: newValue)
|
viewModel.updatePreference(taskDueSoon: newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +132,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.taskOverdue) { _, newValue in
|
.onChange(of: viewModel.taskOverdue) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(taskOverdue: newValue)
|
viewModel.updatePreference(taskOverdue: newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +167,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.taskCompleted) { _, newValue in
|
.onChange(of: viewModel.taskCompleted) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(taskCompleted: newValue)
|
viewModel.updatePreference(taskCompleted: newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +187,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.taskAssigned) { _, newValue in
|
.onChange(of: viewModel.taskAssigned) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(taskAssigned: newValue)
|
viewModel.updatePreference(taskAssigned: newValue)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -216,6 +221,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.residenceShared) { _, newValue in
|
.onChange(of: viewModel.residenceShared) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(residenceShared: newValue)
|
viewModel.updatePreference(residenceShared: newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,6 +241,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.warrantyExpiring) { _, newValue in
|
.onChange(of: viewModel.warrantyExpiring) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(warrantyExpiring: newValue)
|
viewModel.updatePreference(warrantyExpiring: newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,6 +261,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.dailyDigest) { _, newValue in
|
.onChange(of: viewModel.dailyDigest) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(dailyDigest: newValue)
|
viewModel.updatePreference(dailyDigest: newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +302,7 @@ struct NotificationPreferencesView: View {
|
|||||||
}
|
}
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.onChange(of: viewModel.emailTaskCompleted) { _, newValue in
|
.onChange(of: viewModel.emailTaskCompleted) { _, newValue in
|
||||||
|
guard !isInitialLoad else { return }
|
||||||
viewModel.updatePreference(emailTaskCompleted: newValue)
|
viewModel.updatePreference(emailTaskCompleted: newValue)
|
||||||
}
|
}
|
||||||
} header: {
|
} header: {
|
||||||
@@ -323,6 +332,12 @@ struct NotificationPreferencesView: View {
|
|||||||
AnalyticsManager.shared.trackScreen(.notificationSettings)
|
AnalyticsManager.shared.trackScreen(.notificationSettings)
|
||||||
viewModel.loadPreferences()
|
viewModel.loadPreferences()
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.isLoading) { _, newValue in
|
||||||
|
// Clear the initial load guard once preferences have finished loading
|
||||||
|
if !newValue && isInitialLoad {
|
||||||
|
isInitialLoad = false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct ProfileTabView: View {
|
|||||||
@State private var showRestoreSuccess = false
|
@State private var showRestoreSuccess = false
|
||||||
@State private var showingNotificationPreferences = false
|
@State private var showingNotificationPreferences = false
|
||||||
@State private var showingAnimationTesting = false
|
@State private var showingAnimationTesting = false
|
||||||
|
@StateObject private var animationPreference = AnimationPreference.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -66,6 +67,19 @@ struct ProfileTabView: View {
|
|||||||
// Subscription Section - Only show if limitations are enabled on backend
|
// Subscription Section - Only show if limitations are enabled on backend
|
||||||
if let subscription = subscriptionCache.currentSubscription, subscription.limitationsEnabled {
|
if let subscription = subscriptionCache.currentSubscription, subscription.limitationsEnabled {
|
||||||
Section(L10n.Profile.subscription) {
|
Section(L10n.Profile.subscription) {
|
||||||
|
// Trial banner
|
||||||
|
if subscription.trialActive, let trialEnd = subscription.trialEnd {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "crown.fill")
|
Image(systemName: "crown.fill")
|
||||||
.foregroundColor(subscriptionCache.currentTier == "pro" ? Color.appAccent : Color.appTextSecondary)
|
.foregroundColor(subscriptionCache.currentTier == "pro" ? Color.appAccent : Color.appTextSecondary)
|
||||||
@@ -80,7 +94,7 @@ struct ProfileTabView: View {
|
|||||||
Text("\(L10n.Profile.activeUntil) \(DateUtils.formatDateMedium(expiresAt))")
|
Text("\(L10n.Profile.activeUntil) \(DateUtils.formatDateMedium(expiresAt))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
} else {
|
} else if !subscription.trialActive {
|
||||||
Text(L10n.Profile.limitedFeatures)
|
Text(L10n.Profile.limitedFeatures)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
@@ -112,6 +126,29 @@ struct ProfileTabView: View {
|
|||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Subscription management varies by source platform
|
||||||
|
if subscription.subscriptionSource == "stripe" {
|
||||||
|
// Web/Stripe subscription - direct to web portal
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "https://casera.app/settings") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label("Manage at casera.app", systemImage: "globe")
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
}
|
||||||
|
} else if subscription.subscriptionSource == "android" {
|
||||||
|
// Android subscription - informational only
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
Text("Manage your subscription on your Android device")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
} else {
|
||||||
|
// iOS subscription (source is "ios" or nil) - normal StoreKit management
|
||||||
Button(action: {
|
Button(action: {
|
||||||
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
|
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
|
||||||
UIApplication.shared.open(url)
|
UIApplication.shared.open(url)
|
||||||
@@ -121,11 +158,12 @@ struct ProfileTabView: View {
|
|||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button(action: {
|
Button(action: {
|
||||||
Task {
|
Task {
|
||||||
await storeKitManager.restorePurchases()
|
await storeKitManager.restorePurchases()
|
||||||
showRestoreSuccess = true
|
showRestoreSuccess = !storeKitManager.purchasedProductIDs.isEmpty
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Label(L10n.Profile.restorePurchases, systemImage: "arrow.clockwise")
|
Label(L10n.Profile.restorePurchases, systemImage: "arrow.clockwise")
|
||||||
@@ -159,11 +197,15 @@ struct ProfileTabView: View {
|
|||||||
showingAnimationTesting = true
|
showingAnimationTesting = true
|
||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Label("Animation Testing", systemImage: "sparkles.rectangle.stack")
|
Label("Completion Animation", systemImage: "sparkles.rectangle.stack")
|
||||||
.foregroundColor(Color.appTextPrimary)
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
Text(animationPreference.selectedAnimation.rawValue)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct ProfileView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class ProfileViewModel: ObservableObject {
|
|||||||
@Published var firstName: String = ""
|
@Published var firstName: String = ""
|
||||||
@Published var lastName: String = ""
|
@Published var lastName: String = ""
|
||||||
@Published var email: String = ""
|
@Published var email: String = ""
|
||||||
|
@Published var isEditing: Bool = false
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
@Published var isLoadingUser: Bool = true
|
@Published var isLoadingUser: Bool = true
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@@ -28,11 +29,12 @@ class ProfileViewModel: ObservableObject {
|
|||||||
DataManagerObservable.shared.$currentUser
|
DataManagerObservable.shared.$currentUser
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] user in
|
.sink { [weak self] user in
|
||||||
|
guard let self, !self.isEditing else { return }
|
||||||
if let user = user {
|
if let user = user {
|
||||||
self?.firstName = user.firstName ?? ""
|
self.firstName = user.firstName ?? ""
|
||||||
self?.lastName = user.lastName ?? ""
|
self.lastName = user.lastName ?? ""
|
||||||
self?.email = user.email
|
self.email = user.email
|
||||||
self?.isLoadingUser = false
|
self.isLoadingUser = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ struct RegisterView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct JoinResidenceView: View {
|
|||||||
@FocusState private var isCodeFocused: Bool
|
@FocusState private var isCodeFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,10 @@ struct ManageUsersView: View {
|
|||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var isGeneratingCode = false
|
@State private var isGeneratingCode = false
|
||||||
@State private var shareFileURL: URL?
|
@State private var shareFileURL: URL?
|
||||||
@StateObject private var sharingManager = ResidenceSharingManager.shared
|
@ObservedObject private var sharingManager = ResidenceSharingManager.shared
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ struct ResidenceDetailView: View {
|
|||||||
@State private var selectedTaskForCancel: TaskResponse?
|
@State private var selectedTaskForCancel: TaskResponse?
|
||||||
@State private var showCancelConfirmation = false
|
@State private var showCancelConfirmation = false
|
||||||
|
|
||||||
|
// Completion animation state
|
||||||
|
@StateObject private var animationPreference = AnimationPreference.shared
|
||||||
|
@State private var animatingTaskId: Int32? = nil
|
||||||
|
@State private var animationPhase: AnimationPhase = .idle
|
||||||
|
@State private var pendingCompletedTask: TaskResponse? = nil
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
@State private var hasAppeared = false
|
@State private var hasAppeared = false
|
||||||
@State private var showReportAlert = false
|
@State private var showReportAlert = false
|
||||||
@State private var showReportConfirmation = false
|
@State private var showReportConfirmation = false
|
||||||
@@ -105,14 +112,17 @@ struct ResidenceDetailView: View {
|
|||||||
EditTaskView(task: task, isPresented: $showEditTask)
|
EditTaskView(task: task, isPresented: $showEditTask)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedTaskForComplete) { task in
|
.sheet(item: $selectedTaskForComplete, onDismiss: {
|
||||||
|
if let task = pendingCompletedTask {
|
||||||
|
startCompletionAnimation(for: task)
|
||||||
|
} else {
|
||||||
|
taskViewModel.isAnimatingCompletion = false
|
||||||
|
loadResidenceTasks(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}) { task in
|
||||||
CompleteTaskView(task: task) { updatedTask in
|
CompleteTaskView(task: task) { updatedTask in
|
||||||
print("DEBUG: onComplete callback called")
|
|
||||||
print("DEBUG: updatedTask is nil: \(updatedTask == nil)")
|
|
||||||
if let updatedTask = updatedTask {
|
if let updatedTask = updatedTask {
|
||||||
print("DEBUG: updatedTask.id = \(updatedTask.id)")
|
pendingCompletedTask = updatedTask
|
||||||
print("DEBUG: updatedTask.kanbanColumn = \(updatedTask.kanbanColumn ?? "nil")")
|
|
||||||
updateTaskInKanban(updatedTask)
|
|
||||||
}
|
}
|
||||||
selectedTaskForComplete = nil
|
selectedTaskForComplete = nil
|
||||||
}
|
}
|
||||||
@@ -248,6 +258,9 @@ private extension ResidenceDetailView {
|
|||||||
showArchiveConfirmation: $showArchiveConfirmation,
|
showArchiveConfirmation: $showArchiveConfirmation,
|
||||||
selectedTaskForCancel: $selectedTaskForCancel,
|
selectedTaskForCancel: $selectedTaskForCancel,
|
||||||
showCancelConfirmation: $showCancelConfirmation,
|
showCancelConfirmation: $showCancelConfirmation,
|
||||||
|
animatingTaskId: animatingTaskId,
|
||||||
|
animationPhase: animationPhase,
|
||||||
|
animationType: animationPreference.selectedAnimation,
|
||||||
reloadTasks: { loadResidenceTasks(forceRefresh: true) }
|
reloadTasks: { loadResidenceTasks(forceRefresh: true) }
|
||||||
)
|
)
|
||||||
} else if isLoadingTasks {
|
} else if isLoadingTasks {
|
||||||
@@ -422,6 +435,37 @@ private extension ResidenceDetailView {
|
|||||||
taskViewModel.updateTaskInKanban(updatedTask)
|
taskViewModel.updateTaskInKanban(updatedTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func startCompletionAnimation(for updatedTask: TaskResponse) {
|
||||||
|
let duration = animationPreference.animationDuration(reduceMotion: reduceMotion)
|
||||||
|
|
||||||
|
guard duration > 0 else {
|
||||||
|
taskViewModel.isAnimatingCompletion = false
|
||||||
|
updateTaskInKanban(updatedTask)
|
||||||
|
pendingCompletedTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
animatingTaskId = updatedTask.id
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .exiting
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||||
|
taskViewModel.isAnimatingCompletion = false
|
||||||
|
updateTaskInKanban(updatedTask)
|
||||||
|
animatingTaskId = nil
|
||||||
|
animationPhase = .idle
|
||||||
|
pendingCompletedTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func deleteResidence() {
|
func deleteResidence() {
|
||||||
guard TokenStorage.shared.getToken() != nil else { return }
|
guard TokenStorage.shared.getToken() != nil else { return }
|
||||||
|
|
||||||
@@ -500,6 +544,11 @@ private struct TasksSectionContainer: View {
|
|||||||
@Binding var selectedTaskForCancel: TaskResponse?
|
@Binding var selectedTaskForCancel: TaskResponse?
|
||||||
@Binding var showCancelConfirmation: Bool
|
@Binding var showCancelConfirmation: Bool
|
||||||
|
|
||||||
|
// Completion animation state
|
||||||
|
var animatingTaskId: Int32? = nil
|
||||||
|
var animationPhase: AnimationPhase = .idle
|
||||||
|
var animationType: TaskAnimationType = .none
|
||||||
|
|
||||||
let reloadTasks: () -> Void
|
let reloadTasks: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -526,6 +575,7 @@ private struct TasksSectionContainer: View {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCompleteTask: { task in
|
onCompleteTask: { task in
|
||||||
|
taskViewModel.isAnimatingCompletion = true
|
||||||
selectedTaskForComplete = task
|
selectedTaskForComplete = task
|
||||||
},
|
},
|
||||||
onArchiveTask: { task in
|
onArchiveTask: { task in
|
||||||
@@ -536,7 +586,10 @@ private struct TasksSectionContainer: View {
|
|||||||
taskViewModel.unarchiveTask(id: taskId) { _ in
|
taskViewModel.unarchiveTask(id: taskId) { _ in
|
||||||
reloadTasks()
|
reloadTasks()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
animatingTaskId: animatingTaskId,
|
||||||
|
animationPhase: animationPhase,
|
||||||
|
animationType: animationType
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ class ResidenceSharingManager: ObservableObject {
|
|||||||
|
|
||||||
let jsonContent = CaseraShareCodec.shared.encodeSharedResidence(sharedResidence: sharedResidence)
|
let jsonContent = CaseraShareCodec.shared.encodeSharedResidence(sharedResidence: sharedResidence)
|
||||||
guard let jsonData = jsonContent.data(using: .utf8) else {
|
guard let jsonData = jsonContent.data(using: .utf8) else {
|
||||||
|
#if DEBUG
|
||||||
print("ResidenceSharingManager: Failed to encode residence package as UTF-8")
|
print("ResidenceSharingManager: Failed to encode residence package as UTF-8")
|
||||||
|
#endif
|
||||||
errorMessage = "Failed to create share file"
|
errorMessage = "Failed to create share file"
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -80,7 +82,9 @@ class ResidenceSharingManager: ObservableObject {
|
|||||||
AnalyticsManager.shared.track(.residenceShared(method: "file"))
|
AnalyticsManager.shared.track(.residenceShared(method: "file"))
|
||||||
return tempURL
|
return tempURL
|
||||||
} catch {
|
} catch {
|
||||||
|
#if DEBUG
|
||||||
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
|
print("ResidenceSharingManager: Failed to write .casera file: \(error)")
|
||||||
|
#endif
|
||||||
errorMessage = "Failed to save share file"
|
errorMessage = "Failed to save share file"
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -197,35 +197,27 @@ class ResidenceViewModel: ObservableObject {
|
|||||||
|
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
print("🏠 ResidenceVM: Calling API...")
|
|
||||||
let result = try await APILayer.shared.createResidence(request: request)
|
let result = try await APILayer.shared.createResidence(request: request)
|
||||||
print("🏠 ResidenceVM: Got result: \(String(describing: result))")
|
|
||||||
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
if let success = result as? ApiResultSuccess<ResidenceResponse> {
|
||||||
print("🏠 ResidenceVM: Is ApiResultSuccess, data = \(String(describing: success.data))")
|
|
||||||
if let residence = success.data {
|
if let residence = success.data {
|
||||||
print("🏠 ResidenceVM: Got residence with id \(residence.id)")
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
completion(residence)
|
completion(residence)
|
||||||
} else {
|
} else {
|
||||||
print("🏠 ResidenceVM: success.data is nil")
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
} else if let error = ApiResultBridge.error(from: result) {
|
} else if let error = ApiResultBridge.error(from: result) {
|
||||||
print("🏠 ResidenceVM: Is ApiResultError: \(error.message ?? "nil")")
|
|
||||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
completion(nil)
|
completion(nil)
|
||||||
} else {
|
} else {
|
||||||
print("🏠 ResidenceVM: Unknown result type: \(type(of: result))")
|
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
completion(nil)
|
completion(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("🏠 ResidenceVM: Exception: \(error)")
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
self.errorMessage = ErrorMessageParser.parse(error.localizedDescription)
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ struct ResidenceFormView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
WarmGradientBackground()
|
WarmGradientBackground()
|
||||||
|
|
||||||
@@ -357,11 +357,11 @@ struct ResidenceFormView: View {
|
|||||||
stateProvince = residence.stateProvince ?? ""
|
stateProvince = residence.stateProvince ?? ""
|
||||||
postalCode = residence.postalCode ?? ""
|
postalCode = residence.postalCode ?? ""
|
||||||
country = residence.country ?? ""
|
country = residence.country ?? ""
|
||||||
bedrooms = residence.bedrooms != nil ? "\(residence.bedrooms!)" : ""
|
bedrooms = residence.bedrooms.map { "\($0)" } ?? ""
|
||||||
bathrooms = residence.bathrooms != nil ? "\(residence.bathrooms!)" : ""
|
bathrooms = residence.bathrooms.map { "\($0)" } ?? ""
|
||||||
squareFootage = residence.squareFootage != nil ? "\(residence.squareFootage!)" : ""
|
squareFootage = residence.squareFootage.map { "\($0)" } ?? ""
|
||||||
lotSize = residence.lotSize != nil ? "\(residence.lotSize!)" : ""
|
lotSize = residence.lotSize.map { "\($0)" } ?? ""
|
||||||
yearBuilt = residence.yearBuilt != nil ? "\(residence.yearBuilt!)" : ""
|
yearBuilt = residence.yearBuilt.map { "\($0)" } ?? ""
|
||||||
description = residence.description_ ?? ""
|
description = residence.description_ ?? ""
|
||||||
isPrimary = residence.isPrimary
|
isPrimary = residence.isPrimary
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import SwiftUI
|
|||||||
import ComposeApp
|
import ComposeApp
|
||||||
|
|
||||||
/// Shared authentication state manager
|
/// Shared authentication state manager
|
||||||
|
@MainActor
|
||||||
class AuthenticationManager: ObservableObject {
|
class AuthenticationManager: ObservableObject {
|
||||||
static let shared = AuthenticationManager()
|
static let shared = AuthenticationManager()
|
||||||
|
|
||||||
@@ -33,14 +34,11 @@ class AuthenticationManager: ObservableObject {
|
|||||||
|
|
||||||
isAuthenticated = true
|
isAuthenticated = true
|
||||||
|
|
||||||
// Fetch current user and initialize lookups immediately for all authenticated users
|
// Fetch current user to validate token and check verification status
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
// Initialize lookups right away for any authenticated user
|
// Lookups are already initialized by iOSApp.init() at startup
|
||||||
// This fetches /static_data/ and /upgrade-triggers/ at app start
|
// and refreshed by scenePhase .active handler — no need to call again here
|
||||||
print("🚀 Initializing lookups at app start...")
|
|
||||||
_ = try await APILayer.shared.initializeLookups()
|
|
||||||
print("✅ Lookups initialized on app launch")
|
|
||||||
|
|
||||||
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
|
let result = try await APILayer.shared.getCurrentUser(forceRefresh: true)
|
||||||
|
|
||||||
@@ -61,12 +59,23 @@ class AuthenticationManager: ObservableObject {
|
|||||||
self.isVerified = false
|
self.isVerified = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ Failed to check auth status: \(error)")
|
#if DEBUG
|
||||||
// On error, assume token is invalid
|
print("Failed to check auth status: \(error)")
|
||||||
|
#endif
|
||||||
|
// Distinguish network errors from auth errors
|
||||||
|
let nsError = error as NSError
|
||||||
|
if nsError.domain == NSURLErrorDomain {
|
||||||
|
// Network error — keep authenticated state, user may be offline
|
||||||
|
#if DEBUG
|
||||||
|
print("Network error during auth check, keeping auth state")
|
||||||
|
#endif
|
||||||
|
} else {
|
||||||
|
// Auth error — token is invalid
|
||||||
DataManager.shared.clear()
|
DataManager.shared.clear()
|
||||||
self.isAuthenticated = false
|
self.isAuthenticated = false
|
||||||
self.isVerified = false
|
self.isVerified = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.isCheckingAuth = false
|
self.isCheckingAuth = false
|
||||||
}
|
}
|
||||||
@@ -105,6 +114,9 @@ class AuthenticationManager: ObservableObject {
|
|||||||
WidgetDataManager.shared.clearCache()
|
WidgetDataManager.shared.clearCache()
|
||||||
WidgetDataManager.shared.clearAuthToken()
|
WidgetDataManager.shared.clearAuthToken()
|
||||||
|
|
||||||
|
// Clear authenticated image cache
|
||||||
|
AuthenticatedImage.clearCache()
|
||||||
|
|
||||||
// Update authentication state
|
// Update authentication state
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
@@ -112,7 +124,9 @@ class AuthenticationManager: ObservableObject {
|
|||||||
// Note: We don't reset onboarding state on logout
|
// Note: We don't reset onboarding state on logout
|
||||||
// so returning users go to login screen, not onboarding
|
// so returning users go to login screen, not onboarding
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
print("AuthenticationManager: Logged out - all state reset")
|
print("AuthenticationManager: Logged out - all state reset")
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reset onboarding state (for testing or re-onboarding)
|
/// Reset onboarding state (for testing or re-onboarding)
|
||||||
@@ -127,6 +141,7 @@ struct RootView: View {
|
|||||||
@StateObject private var authManager = AuthenticationManager.shared
|
@StateObject private var authManager = AuthenticationManager.shared
|
||||||
@StateObject private var onboardingState = OnboardingState.shared
|
@StateObject private var onboardingState = OnboardingState.shared
|
||||||
@State private var refreshID = UUID()
|
@State private var refreshID = UUID()
|
||||||
|
@Binding var deepLinkResetToken: String?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
@@ -151,7 +166,7 @@ struct RootView: View {
|
|||||||
} else if !authManager.isAuthenticated {
|
} else if !authManager.isAuthenticated {
|
||||||
// Show login screen for returning users
|
// Show login screen for returning users
|
||||||
ZStack(alignment: .topLeading) {
|
ZStack(alignment: .topLeading) {
|
||||||
LoginView()
|
LoginView(resetToken: $deepLinkResetToken)
|
||||||
Color.clear
|
Color.clear
|
||||||
.frame(width: 1, height: 1)
|
.frame(width: 1, height: 1)
|
||||||
.accessibilityIdentifier("ui.root.login")
|
.accessibilityIdentifier("ui.root.login")
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ class DateFormatters {
|
|||||||
lazy var mediumDate: DateFormatter = {
|
lazy var mediumDate: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "MMM d, yyyy"
|
formatter.dateFormat = "MMM d, yyyy"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ class DateFormatters {
|
|||||||
lazy var longDate: DateFormatter = {
|
lazy var longDate: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "MMMM d, yyyy"
|
formatter.dateFormat = "MMMM d, yyyy"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ class DateFormatters {
|
|||||||
lazy var shortDate: DateFormatter = {
|
lazy var shortDate: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "MM/dd/yyyy"
|
formatter.dateFormat = "MM/dd/yyyy"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ class DateFormatters {
|
|||||||
lazy var apiDate: DateFormatter = {
|
lazy var apiDate: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ class DateFormatters {
|
|||||||
lazy var time: DateFormatter = {
|
lazy var time: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "h:mm a"
|
formatter.dateFormat = "h:mm a"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -131,6 +136,7 @@ class DateFormatters {
|
|||||||
lazy var dateTime: DateFormatter = {
|
lazy var dateTime: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
|
formatter.dateFormat = "MMM d, yyyy 'at' h:mm a"
|
||||||
|
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
29
iosApp/iosApp/Shared/Utilities/AnimationPreference.swift
Normal file
29
iosApp/iosApp/Shared/Utilities/AnimationPreference.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Persists the user's selected task completion animation type.
|
||||||
|
/// Observed by task views to determine which animation to play after completing a task.
|
||||||
|
final class AnimationPreference: ObservableObject {
|
||||||
|
static let shared = AnimationPreference()
|
||||||
|
|
||||||
|
@AppStorage("selectedTaskAnimation") private var storedValue: String = TaskAnimationType.implode.rawValue
|
||||||
|
|
||||||
|
/// The currently selected animation type, persisted across launches.
|
||||||
|
var selectedAnimation: TaskAnimationType {
|
||||||
|
get { TaskAnimationType(rawValue: storedValue) ?? .implode }
|
||||||
|
set {
|
||||||
|
storedValue = newValue.rawValue
|
||||||
|
objectWillChange.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration to wait for the celebration animation before moving the task.
|
||||||
|
/// Returns 0 for `.none` or when Reduce Motion is enabled.
|
||||||
|
func animationDuration(reduceMotion: Bool) -> Double {
|
||||||
|
if reduceMotion || selectedAnimation == .none {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return 2.2
|
||||||
|
}
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
}
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import ComposeApp
|
|
||||||
import Combine
|
|
||||||
|
|
||||||
@@ -12,10 +12,41 @@ struct FeatureComparisonView: View {
|
|||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
@State private var showSuccessAlert = false
|
@State private var showSuccessAlert = false
|
||||||
|
|
||||||
|
/// Whether the user is already subscribed from a non-iOS platform
|
||||||
|
private var isSubscribedOnOtherPlatform: Bool {
|
||||||
|
guard let subscription = subscriptionCache.currentSubscription,
|
||||||
|
subscriptionCache.currentTier == "pro",
|
||||||
|
let source = subscription.subscriptionSource,
|
||||||
|
source != "ios" else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: AppSpacing.xl) {
|
VStack(spacing: AppSpacing.xl) {
|
||||||
|
// Trial banner
|
||||||
|
if let subscription = subscriptionCache.currentSubscription,
|
||||||
|
subscription.trialActive,
|
||||||
|
let trialEnd = subscription.trialEnd {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.appAccent.opacity(0.1))
|
||||||
|
.cornerRadius(AppRadius.md)
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, AppSpacing.lg)
|
||||||
|
}
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
VStack(spacing: AppSpacing.sm) {
|
VStack(spacing: AppSpacing.sm) {
|
||||||
Text("Choose Your Plan")
|
Text("Choose Your Plan")
|
||||||
@@ -78,7 +109,13 @@ struct FeatureComparisonView: View {
|
|||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
// Subscription Products
|
// Subscription Products
|
||||||
if storeKit.isLoading {
|
if isSubscribedOnOtherPlatform {
|
||||||
|
// User is subscribed on another platform
|
||||||
|
CrossPlatformSubscriptionNotice(
|
||||||
|
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
|
||||||
|
)
|
||||||
|
.padding(.horizontal)
|
||||||
|
} else if storeKit.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
.tint(Color.appPrimary)
|
.tint(Color.appPrimary)
|
||||||
.padding()
|
.padding()
|
||||||
@@ -129,6 +166,7 @@ struct FeatureComparisonView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restore Purchases
|
// Restore Purchases
|
||||||
|
if !isSubscribedOnOtherPlatform {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
handleRestore()
|
handleRestore()
|
||||||
}) {
|
}) {
|
||||||
@@ -139,6 +177,7 @@ struct FeatureComparisonView: View {
|
|||||||
.padding(.bottom, AppSpacing.xl)
|
.padding(.bottom, AppSpacing.xl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.background(WarmGradientBackground())
|
.background(WarmGradientBackground())
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
@@ -216,7 +255,7 @@ struct SubscriptionButton: View {
|
|||||||
let onSelect: () -> Void
|
let onSelect: () -> Void
|
||||||
|
|
||||||
var isAnnual: Bool {
|
var isAnnual: Bool {
|
||||||
product.id.contains("annual")
|
product.subscription?.subscriptionPeriod.unit == .year
|
||||||
}
|
}
|
||||||
|
|
||||||
var savingsText: String? {
|
var savingsText: String? {
|
||||||
@@ -293,6 +332,58 @@ struct ComparisonRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Cross-Platform Subscription Notice
|
||||||
|
|
||||||
|
struct CrossPlatformSubscriptionNotice: View {
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: AppSpacing.md) {
|
||||||
|
Image(systemName: "checkmark.seal.fill")
|
||||||
|
.font(.system(size: 36))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
|
||||||
|
Text("You're already subscribed")
|
||||||
|
.font(.headline)
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
if source == "stripe" {
|
||||||
|
Text("Manage your subscription at casera.app")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
if let url = URL(string: "https://casera.app/settings") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Label("Open casera.app", systemImage: "globe")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.padding()
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.cornerRadius(AppRadius.md)
|
||||||
|
}
|
||||||
|
} else if source == "android" {
|
||||||
|
Text("Your subscription is managed through Google Play on your Android device.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
} else {
|
||||||
|
Text("Your subscription is managed on another platform.")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.appBackgroundSecondary)
|
||||||
|
.cornerRadius(AppRadius.lg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
FeatureComparisonView(isPresented: .constant(true))
|
FeatureComparisonView(isPresented: .constant(true))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ class SubscriptionCacheWrapper: ObservableObject {
|
|||||||
/// Current tier derived from backend subscription status, with StoreKit fallback.
|
/// Current tier derived from backend subscription status, with StoreKit fallback.
|
||||||
/// Mirrors the logic in Kotlin SubscriptionHelper.currentTier.
|
/// Mirrors the logic in Kotlin SubscriptionHelper.currentTier.
|
||||||
var currentTier: String {
|
var currentTier: String {
|
||||||
|
// Active trial grants pro access.
|
||||||
|
if let subscription = currentSubscription, subscription.trialActive {
|
||||||
|
return "pro"
|
||||||
|
}
|
||||||
|
|
||||||
// Prefer backend subscription state when available.
|
// Prefer backend subscription state when available.
|
||||||
// `expiresAt` is only expected for active paid plans.
|
// `expiresAt` is only expected for active paid plans.
|
||||||
if let subscription = currentSubscription,
|
if let subscription = currentSubscription,
|
||||||
|
|||||||
@@ -31,9 +31,39 @@ struct UpgradeFeatureView: View {
|
|||||||
triggerData?.buttonText ?? "Upgrade to Pro"
|
triggerData?.buttonText ?? "Upgrade to Pro"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the user is already subscribed from a non-iOS platform
|
||||||
|
private var isSubscribedOnOtherPlatform: Bool {
|
||||||
|
guard let subscription = subscriptionCache.currentSubscription,
|
||||||
|
subscriptionCache.currentTier == "pro",
|
||||||
|
let source = subscription.subscriptionSource,
|
||||||
|
source != "ios" else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: OrganicSpacing.comfortable) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
// Trial banner
|
||||||
|
if let subscription = subscriptionCache.currentSubscription,
|
||||||
|
subscription.trialActive,
|
||||||
|
let trialEnd = subscription.trialEnd {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.appAccent.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
VStack(spacing: OrganicSpacing.comfortable) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -68,7 +98,7 @@ struct UpgradeFeatureView: View {
|
|||||||
)
|
)
|
||||||
.frame(width: 80, height: 80)
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
Image(systemName: "star.fill")
|
Image(systemName: icon)
|
||||||
.font(.system(size: 36, weight: .medium))
|
.font(.system(size: 36, weight: .medium))
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
}
|
}
|
||||||
@@ -110,6 +140,12 @@ struct UpgradeFeatureView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Subscription Products
|
// Subscription Products
|
||||||
|
if isSubscribedOnOtherPlatform {
|
||||||
|
CrossPlatformSubscriptionNotice(
|
||||||
|
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
if storeKit.isLoading {
|
if storeKit.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -145,6 +181,7 @@ struct UpgradeFeatureView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Error Message
|
// Error Message
|
||||||
if let error = errorMessage {
|
if let error = errorMessage {
|
||||||
@@ -172,6 +209,7 @@ struct UpgradeFeatureView: View {
|
|||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isSubscribedOnOtherPlatform {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
handleRestore()
|
handleRestore()
|
||||||
}) {
|
}) {
|
||||||
@@ -180,6 +218,7 @@ struct UpgradeFeatureView: View {
|
|||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.bottom, OrganicSpacing.airy)
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,17 @@ struct UpgradePromptView: View {
|
|||||||
subscriptionCache.upgradeTriggers[triggerKey]
|
subscriptionCache.upgradeTriggers[triggerKey]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the user is already subscribed from a non-iOS platform
|
||||||
|
private var isSubscribedOnOtherPlatform: Bool {
|
||||||
|
guard let subscription = subscriptionCache.currentSubscription,
|
||||||
|
subscriptionCache.currentTier == "pro",
|
||||||
|
let source = subscription.subscriptionSource,
|
||||||
|
source != "ios" else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -142,6 +153,25 @@ struct UpgradePromptView: View {
|
|||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
VStack(spacing: OrganicSpacing.comfortable) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
|
// Trial banner
|
||||||
|
if let subscription = subscriptionCache.currentSubscription,
|
||||||
|
subscription.trialActive,
|
||||||
|
let trialEnd = subscription.trialEnd {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "clock.fill")
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
Text("Free trial ends \(DateUtils.formatDateMedium(trialEnd))")
|
||||||
|
.font(.subheadline)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundColor(Color.appAccent)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(Color.appAccent.opacity(0.1))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Hero Section
|
// Hero Section
|
||||||
VStack(spacing: OrganicSpacing.comfortable) {
|
VStack(spacing: OrganicSpacing.comfortable) {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -218,6 +248,12 @@ struct UpgradePromptView: View {
|
|||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
|
||||||
// Subscription Products
|
// Subscription Products
|
||||||
|
if isSubscribedOnOtherPlatform {
|
||||||
|
CrossPlatformSubscriptionNotice(
|
||||||
|
source: subscriptionCache.currentSubscription?.subscriptionSource ?? ""
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
} else {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
if storeKit.isLoading {
|
if storeKit.isLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
@@ -253,6 +289,7 @@ struct UpgradePromptView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
// Error Message
|
// Error Message
|
||||||
if let error = errorMessage {
|
if let error = errorMessage {
|
||||||
@@ -280,6 +317,7 @@ struct UpgradePromptView: View {
|
|||||||
.foregroundColor(Color.appPrimary)
|
.foregroundColor(Color.appPrimary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isSubscribedOnOtherPlatform {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
handleRestore()
|
handleRestore()
|
||||||
}) {
|
}) {
|
||||||
@@ -288,6 +326,7 @@ struct UpgradePromptView: View {
|
|||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.padding(.bottom, OrganicSpacing.airy)
|
.padding(.bottom, OrganicSpacing.airy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ struct CameraPickerView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
func makeUIViewController(context: Context) -> UIImagePickerController {
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
||||||
let picker = UIImagePickerController()
|
let picker = UIImagePickerController()
|
||||||
picker.sourceType = .camera
|
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera) ? .camera : .photoLibrary
|
||||||
picker.delegate = context.coordinator
|
picker.delegate = context.coordinator
|
||||||
return picker
|
return picker
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import ComposeApp
|
|
||||||
@@ -186,7 +186,9 @@ private struct PropertyIconView: View {
|
|||||||
// MARK: - Pulse Ring Animation
|
// MARK: - Pulse Ring Animation
|
||||||
|
|
||||||
private struct PulseRing: View {
|
private struct PulseRing: View {
|
||||||
|
@State private var isAnimating = false
|
||||||
@State private var isPulsing = false
|
@State private var isPulsing = false
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -195,14 +197,21 @@ private struct PulseRing: View {
|
|||||||
.scaleEffect(isPulsing ? 1.15 : 1.0)
|
.scaleEffect(isPulsing ? 1.15 : 1.0)
|
||||||
.opacity(isPulsing ? 0 : 1)
|
.opacity(isPulsing ? 0 : 1)
|
||||||
.animation(
|
.animation(
|
||||||
Animation
|
reduceMotion
|
||||||
.easeOut(duration: 1.5)
|
? .easeOut(duration: 1.5)
|
||||||
.repeatForever(autoreverses: false),
|
: isAnimating
|
||||||
|
? Animation.easeOut(duration: 1.5).repeatForever(autoreverses: false)
|
||||||
|
: .default,
|
||||||
value: isPulsing
|
value: isPulsing
|
||||||
)
|
)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
isPulsing = true
|
isPulsing = true
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
isAnimating = false
|
||||||
|
isPulsing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ struct DynamicTaskColumnView: View {
|
|||||||
let onArchiveTask: (TaskResponse) -> Void
|
let onArchiveTask: (TaskResponse) -> Void
|
||||||
let onUnarchiveTask: (Int32) -> Void
|
let onUnarchiveTask: (Int32) -> Void
|
||||||
|
|
||||||
|
// Completion animation state (passed from parent)
|
||||||
|
var animatingTaskId: Int32? = nil
|
||||||
|
var animationPhase: AnimationPhase = .idle
|
||||||
|
var animationType: TaskAnimationType = .none
|
||||||
|
|
||||||
// Get icon from API response, with fallback
|
// Get icon from API response, with fallback
|
||||||
private var columnIcon: String {
|
private var columnIcon: String {
|
||||||
column.icons["ios"] ?? "list.bullet"
|
column.icons["ios"] ?? "list.bullet"
|
||||||
@@ -71,6 +76,10 @@ struct DynamicTaskColumnView: View {
|
|||||||
onArchive: { onArchiveTask(task) },
|
onArchive: { onArchiveTask(task) },
|
||||||
onUnarchive: { onUnarchiveTask(task.id) }
|
onUnarchive: { onUnarchiveTask(task.id) }
|
||||||
)
|
)
|
||||||
|
.taskAnimation(
|
||||||
|
type: animationType,
|
||||||
|
phase: animatingTaskId == task.id ? animationPhase : .idle
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ struct PhotoViewerSheet: View {
|
|||||||
@State private var selectedImage: TaskCompletionImage?
|
@State private var selectedImage: TaskCompletionImage?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationView {
|
NavigationStack {
|
||||||
Group {
|
Group {
|
||||||
if let selectedImage = selectedImage {
|
if let selectedImage = selectedImage {
|
||||||
// Single image view
|
// Single image view
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ComposeApp
|
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.
|
||||||
|
|
||||||
// MARK: - Edit Task Button
|
// MARK: - Edit Task Button
|
||||||
struct EditTaskButton: View {
|
struct EditTaskButton: View {
|
||||||
let taskId: Int32
|
let taskId: Int32
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ struct TasksSection: View {
|
|||||||
let onArchiveTask: (TaskResponse) -> Void
|
let onArchiveTask: (TaskResponse) -> Void
|
||||||
let onUnarchiveTask: (Int32) -> Void
|
let onUnarchiveTask: (Int32) -> Void
|
||||||
|
|
||||||
|
// Completion animation state (passed from parent)
|
||||||
|
var animatingTaskId: Int32? = nil
|
||||||
|
var animationPhase: AnimationPhase = .idle
|
||||||
|
var animationType: TaskAnimationType = .none
|
||||||
|
|
||||||
private var hasNoTasks: Bool {
|
private var hasNoTasks: Bool {
|
||||||
tasksResponse.columns.allSatisfy { $0.tasks.isEmpty }
|
tasksResponse.columns.allSatisfy { $0.tasks.isEmpty }
|
||||||
}
|
}
|
||||||
@@ -58,7 +63,10 @@ struct TasksSection: View {
|
|||||||
},
|
},
|
||||||
onUnarchiveTask: { taskId in
|
onUnarchiveTask: { taskId in
|
||||||
onUnarchiveTask(taskId)
|
onUnarchiveTask(taskId)
|
||||||
}
|
},
|
||||||
|
animatingTaskId: animatingTaskId,
|
||||||
|
animationPhase: animationPhase,
|
||||||
|
animationType: animationType
|
||||||
)
|
)
|
||||||
|
|
||||||
// Show swipe hint on first column when it's empty but others have tasks
|
// Show swipe hint on first column when it's empty but others have tasks
|
||||||
|
|||||||
@@ -21,6 +21,13 @@ struct AllTasksView: View {
|
|||||||
@State private var pendingTaskId: Int32?
|
@State private var pendingTaskId: Int32?
|
||||||
@State private var scrollToColumnIndex: Int?
|
@State private var scrollToColumnIndex: Int?
|
||||||
|
|
||||||
|
// Completion animation state
|
||||||
|
@StateObject private var animationPreference = AnimationPreference.shared
|
||||||
|
@State private var animatingTaskId: Int32? = nil
|
||||||
|
@State private var animationPhase: AnimationPhase = .idle
|
||||||
|
@State private var pendingCompletedTask: TaskResponse? = nil
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
|
||||||
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
|
||||||
private var hasTasks: Bool { taskViewModel.hasTasks }
|
private var hasTasks: Bool { taskViewModel.hasTasks }
|
||||||
@@ -48,10 +55,17 @@ struct AllTasksView: View {
|
|||||||
EditTaskView(task: task, isPresented: $showEditTask)
|
EditTaskView(task: task, isPresented: $showEditTask)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $selectedTaskForComplete) { task in
|
.sheet(item: $selectedTaskForComplete, onDismiss: {
|
||||||
|
if let task = pendingCompletedTask {
|
||||||
|
startCompletionAnimation(for: task)
|
||||||
|
} else {
|
||||||
|
taskViewModel.isAnimatingCompletion = false
|
||||||
|
loadAllTasks(forceRefresh: true)
|
||||||
|
}
|
||||||
|
}) { task in
|
||||||
CompleteTaskView(task: task) { updatedTask in
|
CompleteTaskView(task: task) { updatedTask in
|
||||||
if let updatedTask = updatedTask {
|
if let updatedTask = updatedTask {
|
||||||
updateTaskInKanban(updatedTask)
|
pendingCompletedTask = updatedTask
|
||||||
}
|
}
|
||||||
selectedTaskForComplete = nil
|
selectedTaskForComplete = nil
|
||||||
}
|
}
|
||||||
@@ -190,6 +204,9 @@ struct AllTasksView: View {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onCompleteTask: { task in
|
onCompleteTask: { task in
|
||||||
|
// Block DataManager BEFORE sheet opens so the
|
||||||
|
// API response can't move the task while we wait
|
||||||
|
taskViewModel.isAnimatingCompletion = true
|
||||||
selectedTaskForComplete = task
|
selectedTaskForComplete = task
|
||||||
},
|
},
|
||||||
onArchiveTask: { task in
|
onArchiveTask: { task in
|
||||||
@@ -200,7 +217,10 @@ struct AllTasksView: View {
|
|||||||
taskViewModel.unarchiveTask(id: taskId) { _ in
|
taskViewModel.unarchiveTask(id: taskId) { _ in
|
||||||
loadAllTasks()
|
loadAllTasks()
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
animatingTaskId: animatingTaskId,
|
||||||
|
animationPhase: animationPhase,
|
||||||
|
animationType: animationPreference.selectedAnimation
|
||||||
)
|
)
|
||||||
|
|
||||||
if index == 0 && shouldShowSwipeHint {
|
if index == 0 && shouldShowSwipeHint {
|
||||||
@@ -273,6 +293,39 @@ struct AllTasksView: View {
|
|||||||
taskViewModel.updateTaskInKanban(updatedTask)
|
taskViewModel.updateTaskInKanban(updatedTask)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Called after the completion sheet is fully dismissed.
|
||||||
|
/// Plays the celebration animation on the card, then moves it to Done.
|
||||||
|
private func startCompletionAnimation(for updatedTask: TaskResponse) {
|
||||||
|
let duration = animationPreference.animationDuration(reduceMotion: reduceMotion)
|
||||||
|
|
||||||
|
guard duration > 0 else {
|
||||||
|
taskViewModel.isAnimatingCompletion = false
|
||||||
|
updateTaskInKanban(updatedTask)
|
||||||
|
pendingCompletedTask = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
animatingTaskId = updatedTask.id
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .exiting
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||||
|
taskViewModel.isAnimatingCompletion = false
|
||||||
|
updateTaskInKanban(updatedTask)
|
||||||
|
animatingTaskId = nil
|
||||||
|
animationPhase = .idle
|
||||||
|
pendingCompletedTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
|
private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) {
|
||||||
for (index, column) in response.columns.enumerated() {
|
for (index, column) in response.columns.enumerated() {
|
||||||
if column.tasks.contains(where: { $0.id == taskId }) {
|
if column.tasks.contains(where: { $0.id == taskId }) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ struct CompleteTaskView: View {
|
|||||||
@State private var showCamera: Bool = false
|
@State private var showCamera: Bool = false
|
||||||
@State private var selectedContractor: ContractorSummary? = nil
|
@State private var selectedContractor: ContractorSummary? = nil
|
||||||
@State private var showContractorPicker: Bool = false
|
@State private var showContractorPicker: Bool = false
|
||||||
|
@State private var observationTask: Task<Void, Never>? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -293,6 +294,10 @@ struct CompleteTaskView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
contractorViewModel.loadContractors()
|
contractorViewModel.loadContractors()
|
||||||
}
|
}
|
||||||
|
.onDisappear {
|
||||||
|
observationTask?.cancel()
|
||||||
|
observationTask = nil
|
||||||
|
}
|
||||||
.handleErrors(
|
.handleErrors(
|
||||||
error: errorMessage,
|
error: errorMessage,
|
||||||
onRetry: { handleComplete() }
|
onRetry: { handleComplete() }
|
||||||
@@ -333,9 +338,11 @@ struct CompleteTaskView: View {
|
|||||||
completionViewModel.createTaskCompletion(request: request)
|
completionViewModel.createTaskCompletion(request: request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Observe the result
|
// Observe the result — store the Task so it can be cancelled on dismiss
|
||||||
Task {
|
observationTask?.cancel()
|
||||||
|
observationTask = Task {
|
||||||
for await state in completionViewModel.createCompletionState {
|
for await state in completionViewModel.createCompletionState {
|
||||||
|
if Task.isCancelled { break }
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
if let success = state as? ApiResultSuccess<TaskCompletionResponse> {
|
||||||
self.isSubmitting = false
|
self.isSubmitting = false
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ struct TaskFormView: View {
|
|||||||
// Parse date from string - use effective due date (nextDueDate if set, otherwise dueDate)
|
// Parse date from string - use effective due date (nextDueDate if set, otherwise dueDate)
|
||||||
_dueDate = State(initialValue: (task.effectiveDueDate ?? "").toDate() ?? Date())
|
_dueDate = State(initialValue: (task.effectiveDueDate ?? "").toDate() ?? Date())
|
||||||
|
|
||||||
_intervalDays = State(initialValue: task.customIntervalDays != nil ? String(task.customIntervalDays!.int32Value) : "")
|
_intervalDays = State(initialValue: task.customIntervalDays.map { "\($0.int32Value)" } ?? "")
|
||||||
_estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "")
|
_estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "")
|
||||||
} else {
|
} else {
|
||||||
_title = State(initialValue: "")
|
_title = State(initialValue: "")
|
||||||
@@ -444,6 +444,11 @@ struct TaskFormView: View {
|
|||||||
isValid = false
|
isValid = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !intervalDays.isEmpty, Int32(intervalDays) == nil {
|
||||||
|
viewModel.errorMessage = "Custom interval must be a valid number"
|
||||||
|
isValid = false
|
||||||
|
}
|
||||||
|
|
||||||
return isValid
|
return isValid
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ class TaskViewModel: ObservableObject {
|
|||||||
// MARK: - Published Properties (from DataManager observation)
|
// MARK: - Published Properties (from DataManager observation)
|
||||||
@Published var tasksResponse: TaskColumnsResponse?
|
@Published var tasksResponse: TaskColumnsResponse?
|
||||||
|
|
||||||
|
/// When true, DataManager observation is paused to allow completion animation to play
|
||||||
|
/// without the task being moved out of its column prematurely.
|
||||||
|
var isAnimatingCompletion = false
|
||||||
|
|
||||||
// MARK: - Local State
|
// MARK: - Local State
|
||||||
@Published var actionState: ActionState<TaskActionType> = .idle
|
@Published var actionState: ActionState<TaskActionType> = .idle
|
||||||
@Published var errorMessage: String?
|
@Published var errorMessage: String?
|
||||||
@@ -42,6 +46,9 @@ class TaskViewModel: ObservableObject {
|
|||||||
DataManagerObservable.shared.$allTasks
|
DataManagerObservable.shared.$allTasks
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] allTasks in
|
.sink { [weak self] allTasks in
|
||||||
|
// Skip DataManager updates during completion animation to prevent
|
||||||
|
// the task from being moved out of its column before the animation finishes
|
||||||
|
guard self?.isAnimatingCompletion != true else { return }
|
||||||
// Only update if we're showing all tasks (no residence filter)
|
// Only update if we're showing all tasks (no residence filter)
|
||||||
if self?.currentResidenceId == nil {
|
if self?.currentResidenceId == nil {
|
||||||
self?.tasksResponse = allTasks
|
self?.tasksResponse = allTasks
|
||||||
@@ -56,6 +63,7 @@ class TaskViewModel: ObservableObject {
|
|||||||
DataManagerObservable.shared.$tasksByResidence
|
DataManagerObservable.shared.$tasksByResidence
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [weak self] tasksByResidence in
|
.sink { [weak self] tasksByResidence in
|
||||||
|
guard self?.isAnimatingCompletion != true else { return }
|
||||||
// Only update if we're filtering by residence
|
// Only update if we're filtering by residence
|
||||||
if let resId = self?.currentResidenceId,
|
if let resId = self?.currentResidenceId,
|
||||||
let tasks = tasksByResidence[resId] {
|
let tasks = tasksByResidence[resId] {
|
||||||
|
|||||||
Reference in New Issue
Block a user