Harden iOS app: fix concurrency, animations, formatters, privacy, and logging
- Eliminate NumberFormatters shared singleton data race; use local formatters - Add reduceMotion checks to empty-state animations in 3 list views - Wrap 68+ print() statements in #if DEBUG across push notification code - Remove redundant .receive(on: DispatchQueue.main) in SubscriptionCache - Remove redundant initializeLookups() call from iOSApp.init() - Clean up StoreKitManager Task capture in listenForTransactions() - Add memory warning observer to AuthenticatedImage cache - Cache parseContent result in UpgradePromptView init - Add DiskSpace and FileTimestamp API declarations to Privacy Manifest - Add FIXME for analytics debug/production API key separation - Use static formatter in PropertyHeaderCard Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,8 @@ final class AnalyticsManager {
|
||||
// MARK: - Configuration
|
||||
|
||||
#if DEBUG
|
||||
private static let apiKey = "phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ"
|
||||
// FIXME: Replace with separate production API key to prevent debug events polluting production analytics
|
||||
private static let apiKey = "phc_DEVELOPMENT_KEY_REPLACE_ME"
|
||||
#else
|
||||
private static let apiKey = "phc_oeZddTz7Iw0NxDXFeCycS7TG9YRVtv7WP2DjOvFPUeQ"
|
||||
#endif
|
||||
|
||||
@@ -64,6 +64,11 @@ extension AuthenticatedImage {
|
||||
self.mediaURL = mediaURL
|
||||
self.contentMode = contentMode
|
||||
}
|
||||
|
||||
/// Clear the in-memory image cache (call on logout to free memory and avoid stale data)
|
||||
static func clearCache() {
|
||||
AuthenticatedImageLoader.clearCache()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Image Loader
|
||||
@@ -83,9 +88,26 @@ private class AuthenticatedImageLoader: ObservableObject {
|
||||
private var currentURL: String?
|
||||
|
||||
// In-memory cache for loaded images
|
||||
private static var imageCache = NSCache<NSString, UIImage>()
|
||||
private static var imageCache: NSCache<NSString, UIImage> = {
|
||||
let cache = NSCache<NSString, UIImage>()
|
||||
cache.countLimit = 100
|
||||
cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
|
||||
return cache
|
||||
}()
|
||||
|
||||
private static let memoryWarningObserver: NSObjectProtocol = {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.didReceiveMemoryWarningNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { _ in
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
}()
|
||||
|
||||
func load(mediaURL: String?) {
|
||||
_ = Self.memoryWarningObserver
|
||||
|
||||
guard let mediaURL = mediaURL, !mediaURL.isEmpty else {
|
||||
state = .failure(NSError(domain: "AuthenticatedImage", code: -1, userInfo: [NSLocalizedDescriptionKey: "No URL provided"]))
|
||||
return
|
||||
|
||||
@@ -409,6 +409,7 @@ private struct OrganicToolbarButton: View {
|
||||
private struct OrganicEmptyContractorsView: View {
|
||||
let hasFilters: Bool
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
@@ -431,7 +432,9 @@ private struct OrganicEmptyContractorsView: View {
|
||||
.frame(width: 120, height: 120)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
@@ -459,6 +462,9 @@ private struct OrganicEmptyContractorsView: View {
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,8 @@ extension View {
|
||||
/// A shimmer effect for loading placeholders
|
||||
struct ShimmerModifier: ViewModifier {
|
||||
@State private var phase: CGFloat = 0
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
@@ -124,10 +126,19 @@ struct ShimmerModifier: ViewModifier {
|
||||
)
|
||||
.mask(content)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
guard !reduceMotion else {
|
||||
phase = 0.5
|
||||
return
|
||||
}
|
||||
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
phase = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
|
||||
/// Utility for observing Kotlin StateFlow and handling ApiResult states
|
||||
/// This eliminates the repeated boilerplate pattern across ViewModels
|
||||
/// Utility for observing Kotlin StateFlow and handling ApiResult states.
|
||||
/// This eliminates the repeated boilerplate pattern across ViewModels.
|
||||
///
|
||||
/// **Important:** All observation methods return a `Task` that the caller MUST store
|
||||
/// and cancel when the observer is no longer needed (e.g., in `deinit` or when the
|
||||
/// view disappears). Failing to store the returned `Task` will cause a memory leak
|
||||
/// because the async `for await` loop retains its closure indefinitely.
|
||||
///
|
||||
/// Example usage:
|
||||
/// ```swift
|
||||
/// private var observationTask: Task<Void, Never>?
|
||||
///
|
||||
/// func startObserving() {
|
||||
/// observationTask = StateFlowObserver.observe(stateFlow, onSuccess: { ... })
|
||||
/// }
|
||||
///
|
||||
/// deinit { observationTask?.cancel() }
|
||||
/// ```
|
||||
@MainActor
|
||||
enum StateFlowObserver {
|
||||
|
||||
/// Observe a Kotlin StateFlow and handle loading/success/error states
|
||||
/// Observe a Kotlin StateFlow and handle loading/success/error states.
|
||||
///
|
||||
/// - Returns: A `Task` that the caller **must** store and cancel when observation
|
||||
/// is no longer needed. Discarding this task leaks the observation loop.
|
||||
/// - Parameters:
|
||||
/// - stateFlow: The Kotlin StateFlow to observe
|
||||
/// - onLoading: Called when state is ApiResultLoading
|
||||
@@ -19,8 +38,8 @@ enum StateFlowObserver {
|
||||
onSuccess: @escaping (T) -> Void,
|
||||
onError: ((String) -> Void)? = nil,
|
||||
resetState: (() -> Void)? = nil
|
||||
) {
|
||||
Task {
|
||||
) -> Task<Void, Never> {
|
||||
let task = Task {
|
||||
for await state in stateFlow {
|
||||
if state is ApiResultLoading {
|
||||
await MainActor.run {
|
||||
@@ -47,10 +66,14 @@ enum StateFlowObserver {
|
||||
}
|
||||
}
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
/// Observe a StateFlow with automatic isLoading and errorMessage binding
|
||||
/// Use this when you want standard loading/error state management
|
||||
/// Observe a StateFlow with automatic isLoading and errorMessage binding.
|
||||
/// Use this when you want standard loading/error state management.
|
||||
///
|
||||
/// - Returns: A `Task` that the caller **must** store and cancel when observation
|
||||
/// is no longer needed. Discarding this task leaks the observation loop.
|
||||
/// - Parameters:
|
||||
/// - stateFlow: The Kotlin StateFlow to observe
|
||||
/// - loadingSetter: Closure to set loading state
|
||||
@@ -63,8 +86,8 @@ enum StateFlowObserver {
|
||||
errorSetter: @escaping (String?) -> Void,
|
||||
onSuccess: @escaping (T) -> Void,
|
||||
resetState: (() -> Void)? = nil
|
||||
) {
|
||||
observe(
|
||||
) -> Task<Void, Never> {
|
||||
return observe(
|
||||
stateFlow,
|
||||
onLoading: {
|
||||
loadingSetter(true)
|
||||
@@ -81,8 +104,11 @@ enum StateFlowObserver {
|
||||
)
|
||||
}
|
||||
|
||||
/// Observe a StateFlow with a completion callback
|
||||
/// Use this for create/update/delete operations that need success/failure feedback
|
||||
/// Observe a StateFlow with a completion callback.
|
||||
/// Use this for create/update/delete operations that need success/failure feedback.
|
||||
///
|
||||
/// - Returns: A `Task` that the caller **must** store and cancel when observation
|
||||
/// is no longer needed. Discarding this task leaks the observation loop.
|
||||
/// - Parameters:
|
||||
/// - stateFlow: The Kotlin StateFlow to observe
|
||||
/// - loadingSetter: Closure to set loading state
|
||||
@@ -97,8 +123,8 @@ enum StateFlowObserver {
|
||||
onSuccess: ((T) -> Void)? = nil,
|
||||
completion: @escaping (Bool) -> Void,
|
||||
resetState: (() -> Void)? = nil
|
||||
) {
|
||||
observe(
|
||||
) -> Task<Void, Never> {
|
||||
return observe(
|
||||
stateFlow,
|
||||
onLoading: {
|
||||
loadingSetter(true)
|
||||
|
||||
@@ -81,6 +81,7 @@ class DataManagerObservable: ObservableObject {
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var observationTasks: [Task<Void, Never>] = []
|
||||
private var widgetSaveWorkItem: DispatchWorkItem?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
@@ -93,9 +94,10 @@ class DataManagerObservable: ObservableObject {
|
||||
/// Start observing all DataManager StateFlows
|
||||
private func startObserving() {
|
||||
// Authentication - authToken
|
||||
let authTokenTask = Task {
|
||||
let authTokenTask = Task { [weak self] in
|
||||
for await token in DataManager.shared.authToken {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
let previousToken = self.authToken
|
||||
let wasAuthenticated = previousToken != nil
|
||||
self.authToken = token
|
||||
@@ -124,9 +126,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(authTokenTask)
|
||||
|
||||
// Authentication - currentUser
|
||||
let currentUserTask = Task {
|
||||
let currentUserTask = Task { [weak self] in
|
||||
for await user in DataManager.shared.currentUser {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.currentUser = user
|
||||
}
|
||||
}
|
||||
@@ -134,9 +137,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(currentUserTask)
|
||||
|
||||
// Theme
|
||||
let themeIdTask = Task {
|
||||
let themeIdTask = Task { [weak self] in
|
||||
for await id in DataManager.shared.themeId {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.themeId = id
|
||||
}
|
||||
}
|
||||
@@ -144,9 +148,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(themeIdTask)
|
||||
|
||||
// Residences
|
||||
let residencesTask = Task {
|
||||
let residencesTask = Task { [weak self] in
|
||||
for await list in DataManager.shared.residences {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.residences = list
|
||||
}
|
||||
}
|
||||
@@ -154,9 +159,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(residencesTask)
|
||||
|
||||
// MyResidences
|
||||
let myResidencesTask = Task {
|
||||
let myResidencesTask = Task { [weak self] in
|
||||
for await response in DataManager.shared.myResidences {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.myResidences = response
|
||||
}
|
||||
}
|
||||
@@ -164,9 +170,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(myResidencesTask)
|
||||
|
||||
// TotalSummary
|
||||
let totalSummaryTask = Task {
|
||||
let totalSummaryTask = Task { [weak self] in
|
||||
for await summary in DataManager.shared.totalSummary {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.totalSummary = summary
|
||||
}
|
||||
}
|
||||
@@ -174,9 +181,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(totalSummaryTask)
|
||||
|
||||
// ResidenceSummaries
|
||||
let residenceSummariesTask = Task {
|
||||
let residenceSummariesTask = Task { [weak self] in
|
||||
for await summaries in DataManager.shared.residenceSummaries {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.residenceSummaries = self.convertIntMap(summaries)
|
||||
}
|
||||
}
|
||||
@@ -184,13 +192,14 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(residenceSummariesTask)
|
||||
|
||||
// AllTasks
|
||||
let allTasksTask = Task {
|
||||
let allTasksTask = Task { [weak self] in
|
||||
for await tasks in DataManager.shared.allTasks {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.allTasks = tasks
|
||||
// Save to widget shared container
|
||||
// Save to widget shared container (debounced)
|
||||
if let tasks = tasks {
|
||||
WidgetDataManager.shared.saveTasks(from: tasks)
|
||||
self.debouncedWidgetSave(tasks: tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,9 +207,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(allTasksTask)
|
||||
|
||||
// TasksByResidence
|
||||
let tasksByResidenceTask = Task {
|
||||
let tasksByResidenceTask = Task { [weak self] in
|
||||
for await tasks in DataManager.shared.tasksByResidence {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.tasksByResidence = self.convertIntMap(tasks)
|
||||
}
|
||||
}
|
||||
@@ -208,9 +218,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(tasksByResidenceTask)
|
||||
|
||||
// Documents
|
||||
let documentsTask = Task {
|
||||
let documentsTask = Task { [weak self] in
|
||||
for await docs in DataManager.shared.documents {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.documents = docs
|
||||
}
|
||||
}
|
||||
@@ -218,9 +229,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(documentsTask)
|
||||
|
||||
// DocumentsByResidence
|
||||
let documentsByResidenceTask = Task {
|
||||
let documentsByResidenceTask = Task { [weak self] in
|
||||
for await docs in DataManager.shared.documentsByResidence {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.documentsByResidence = self.convertIntArrayMap(docs)
|
||||
}
|
||||
}
|
||||
@@ -228,9 +240,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(documentsByResidenceTask)
|
||||
|
||||
// Contractors
|
||||
let contractorsTask = Task {
|
||||
let contractorsTask = Task { [weak self] in
|
||||
for await list in DataManager.shared.contractors {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.contractors = list
|
||||
}
|
||||
}
|
||||
@@ -238,9 +251,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(contractorsTask)
|
||||
|
||||
// Subscription
|
||||
let subscriptionTask = Task {
|
||||
let subscriptionTask = Task { [weak self] in
|
||||
for await sub in DataManager.shared.subscription {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.subscription = sub
|
||||
}
|
||||
}
|
||||
@@ -248,9 +262,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(subscriptionTask)
|
||||
|
||||
// UpgradeTriggers
|
||||
let upgradeTriggersTask = Task {
|
||||
let upgradeTriggersTask = Task { [weak self] in
|
||||
for await triggers in DataManager.shared.upgradeTriggers {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.upgradeTriggers = self.convertStringMap(triggers)
|
||||
}
|
||||
}
|
||||
@@ -258,9 +273,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(upgradeTriggersTask)
|
||||
|
||||
// FeatureBenefits
|
||||
let featureBenefitsTask = Task {
|
||||
let featureBenefitsTask = Task { [weak self] in
|
||||
for await benefits in DataManager.shared.featureBenefits {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.featureBenefits = benefits
|
||||
}
|
||||
}
|
||||
@@ -268,9 +284,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(featureBenefitsTask)
|
||||
|
||||
// Promotions
|
||||
let promotionsTask = Task {
|
||||
let promotionsTask = Task { [weak self] in
|
||||
for await promos in DataManager.shared.promotions {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.promotions = promos
|
||||
}
|
||||
}
|
||||
@@ -278,9 +295,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(promotionsTask)
|
||||
|
||||
// Lookups - ResidenceTypes
|
||||
let residenceTypesTask = Task {
|
||||
let residenceTypesTask = Task { [weak self] in
|
||||
for await types in DataManager.shared.residenceTypes {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.residenceTypes = types
|
||||
}
|
||||
}
|
||||
@@ -288,9 +306,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(residenceTypesTask)
|
||||
|
||||
// Lookups - TaskFrequencies
|
||||
let taskFrequenciesTask = Task {
|
||||
let taskFrequenciesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskFrequencies {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskFrequencies = items
|
||||
}
|
||||
}
|
||||
@@ -298,9 +317,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(taskFrequenciesTask)
|
||||
|
||||
// Lookups - TaskPriorities
|
||||
let taskPrioritiesTask = Task {
|
||||
let taskPrioritiesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskPriorities {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskPriorities = items
|
||||
}
|
||||
}
|
||||
@@ -308,9 +328,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(taskPrioritiesTask)
|
||||
|
||||
// Lookups - TaskCategories
|
||||
let taskCategoriesTask = Task {
|
||||
let taskCategoriesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskCategories {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskCategories = items
|
||||
}
|
||||
}
|
||||
@@ -318,9 +339,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(taskCategoriesTask)
|
||||
|
||||
// Lookups - ContractorSpecialties
|
||||
let contractorSpecialtiesTask = Task {
|
||||
let contractorSpecialtiesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.contractorSpecialties {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.contractorSpecialties = items
|
||||
}
|
||||
}
|
||||
@@ -328,9 +350,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(contractorSpecialtiesTask)
|
||||
|
||||
// Task Templates
|
||||
let taskTemplatesTask = Task {
|
||||
let taskTemplatesTask = Task { [weak self] in
|
||||
for await items in DataManager.shared.taskTemplates {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskTemplates = items
|
||||
}
|
||||
}
|
||||
@@ -338,9 +361,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(taskTemplatesTask)
|
||||
|
||||
// Task Templates Grouped
|
||||
let taskTemplatesGroupedTask = Task {
|
||||
let taskTemplatesGroupedTask = Task { [weak self] in
|
||||
for await response in DataManager.shared.taskTemplatesGrouped {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.taskTemplatesGrouped = response
|
||||
}
|
||||
}
|
||||
@@ -348,9 +372,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(taskTemplatesGroupedTask)
|
||||
|
||||
// Metadata - isInitialized
|
||||
let isInitializedTask = Task {
|
||||
let isInitializedTask = Task { [weak self] in
|
||||
for await initialized in DataManager.shared.isInitialized {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.isInitialized = initialized.boolValue
|
||||
}
|
||||
}
|
||||
@@ -358,9 +383,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(isInitializedTask)
|
||||
|
||||
// Metadata - lookupsInitialized
|
||||
let lookupsInitializedTask = Task {
|
||||
let lookupsInitializedTask = Task { [weak self] in
|
||||
for await initialized in DataManager.shared.lookupsInitialized {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.lookupsInitialized = initialized.boolValue
|
||||
}
|
||||
}
|
||||
@@ -368,9 +394,10 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(lookupsInitializedTask)
|
||||
|
||||
// Metadata - lastSyncTime
|
||||
let lastSyncTimeTask = Task {
|
||||
let lastSyncTimeTask = Task { [weak self] in
|
||||
for await time in DataManager.shared.lastSyncTime {
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
self.lastSyncTime = time.int64Value
|
||||
}
|
||||
}
|
||||
@@ -378,6 +405,20 @@ class DataManagerObservable: ObservableObject {
|
||||
observationTasks.append(lastSyncTimeTask)
|
||||
}
|
||||
|
||||
// MARK: - Widget Save Debounce
|
||||
|
||||
/// Debounce widget saves to avoid excessive disk I/O on rapid task updates.
|
||||
/// Cancels any pending save and schedules a new one after 2 seconds.
|
||||
private func debouncedWidgetSave(tasks: TaskColumnsResponse) {
|
||||
widgetSaveWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard self != nil else { return }
|
||||
WidgetDataManager.shared.saveTasks(from: tasks)
|
||||
}
|
||||
widgetSaveWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0, execute: workItem)
|
||||
}
|
||||
|
||||
/// Stop all observations
|
||||
func stopObserving() {
|
||||
observationTasks.forEach { $0.cancel() }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
// MARK: - Organic Design System
|
||||
// Warm, natural aesthetic with soft shapes, subtle textures, and flowing layouts
|
||||
@@ -99,29 +100,59 @@ struct OrganicRoundedRectangle: Shape {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grain Texture Cache
|
||||
|
||||
/// Generates and caches a grain texture image so it is only computed once,
|
||||
/// regardless of how many GrainTexture views are on screen.
|
||||
private final class GrainTextureCache {
|
||||
static let shared = GrainTextureCache()
|
||||
|
||||
/// Cached grain image at a fixed tile size. Tiled across the view.
|
||||
private(set) var cachedImage: UIImage?
|
||||
private let tileSize = CGSize(width: 128, height: 128)
|
||||
private let lock = NSLock()
|
||||
|
||||
private init() {
|
||||
generateIfNeeded()
|
||||
}
|
||||
|
||||
func generateIfNeeded() {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard cachedImage == nil else { return }
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: tileSize)
|
||||
cachedImage = renderer.image { ctx in
|
||||
let cgCtx = ctx.cgContext
|
||||
let w = tileSize.width
|
||||
let h = tileSize.height
|
||||
let dotCount = Int(w * h / 200)
|
||||
|
||||
for _ in 0..<dotCount {
|
||||
let x = CGFloat.random(in: 0...w)
|
||||
let y = CGFloat.random(in: 0...h)
|
||||
let grainOpacity = CGFloat.random(in: 0.3...1.0)
|
||||
|
||||
cgCtx.setFillColor(UIColor.black.withAlphaComponent(grainOpacity).cgColor)
|
||||
cgCtx.fillEllipse(in: CGRect(x: x, y: y, width: 1, height: 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Grain Texture Overlay
|
||||
|
||||
struct GrainTexture: View {
|
||||
var opacity: Double = 0.03
|
||||
var animated: Bool = false
|
||||
@State private var phase: Double = 0
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
Canvas { context, size in
|
||||
for _ in 0..<Int(size.width * size.height / 50) {
|
||||
let x = CGFloat.random(in: 0...size.width)
|
||||
let y = CGFloat.random(in: 0...size.height)
|
||||
let grainOpacity = Double.random(in: 0.3...1.0)
|
||||
|
||||
context.fill(
|
||||
Path(ellipseIn: CGRect(x: x, y: y, width: 1, height: 1)),
|
||||
with: .color(.black.opacity(grainOpacity * opacity))
|
||||
)
|
||||
}
|
||||
}
|
||||
if let uiImage = GrainTextureCache.shared.cachedImage {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable(resizingMode: .tile)
|
||||
.opacity(opacity)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,6 +424,8 @@ struct OrganicSpacing {
|
||||
struct FloatingLeaf: View {
|
||||
@State private var rotation: Double = 0
|
||||
@State private var offset: CGFloat = 0
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
var delay: Double = 0
|
||||
var size: CGFloat = 20
|
||||
var color: Color = Color.appPrimary
|
||||
@@ -404,6 +437,8 @@ struct FloatingLeaf: View {
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.offset(y: offset)
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
guard !reduceMotion else { return }
|
||||
withAnimation(
|
||||
Animation
|
||||
.easeInOut(duration: 4)
|
||||
@@ -414,6 +449,11 @@ struct FloatingLeaf: View {
|
||||
offset = 8
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
rotation = 0
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,15 @@ enum ThemeID: String, CaseIterable, Codable {
|
||||
|
||||
// MARK: - Shared App Group UserDefaults
|
||||
private let appGroupID = "group.com.tt.casera.CaseraDev"
|
||||
private let sharedDefaults = UserDefaults(suiteName: appGroupID) ?? UserDefaults.standard
|
||||
private let sharedDefaults: UserDefaults = {
|
||||
guard let defaults = UserDefaults(suiteName: appGroupID) else {
|
||||
#if DEBUG
|
||||
assertionFailure("App Group '\(appGroupID)' not configured — theme won't sync to widgets")
|
||||
#endif
|
||||
return UserDefaults.standard
|
||||
}
|
||||
return defaults
|
||||
}()
|
||||
|
||||
// MARK: - Theme Manager
|
||||
#if WIDGET_EXTENSION
|
||||
@@ -84,6 +92,7 @@ class ThemeManager {
|
||||
}
|
||||
#else
|
||||
// Full ThemeManager for main app with ObservableObject support
|
||||
@MainActor
|
||||
class ThemeManager: ObservableObject {
|
||||
static let shared = ThemeManager()
|
||||
|
||||
|
||||
@@ -83,8 +83,10 @@ class LoginViewModel: ObservableObject {
|
||||
self.isVerified = response.user.verified
|
||||
self.isLoading = false
|
||||
|
||||
#if DEBUG
|
||||
print("Login successful!")
|
||||
print("User: \(response.user.username ?? "unknown"), Verified: \(self.isVerified)")
|
||||
#endif
|
||||
|
||||
// Share token and API URL with widget extension
|
||||
WidgetDataManager.shared.saveAuthToken(response.token)
|
||||
@@ -93,10 +95,8 @@ class LoginViewModel: ObservableObject {
|
||||
// Track successful login
|
||||
AnalyticsManager.shared.track(.userSignedIn(method: "email"))
|
||||
|
||||
// Initialize lookups via APILayer
|
||||
Task {
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
}
|
||||
// Lookups are already initialized by APILayer.login() internally
|
||||
// (see APILayer.kt line 1205) — no need to call again here
|
||||
|
||||
// Call login success callback
|
||||
self.onLoginSuccess?(self.isVerified)
|
||||
@@ -138,13 +138,21 @@ class LoginViewModel: ObservableObject {
|
||||
} else {
|
||||
self.errorMessage = ErrorMessageParser.parse(error.message)
|
||||
}
|
||||
#if DEBUG
|
||||
print("API Error: \(error.message)")
|
||||
#endif
|
||||
}
|
||||
|
||||
func logout() {
|
||||
Task {
|
||||
// APILayer.logout clears DataManager
|
||||
try? await APILayer.shared.logout()
|
||||
do {
|
||||
_ = try await APILayer.shared.logout()
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Logout error: \(error)")
|
||||
#endif
|
||||
}
|
||||
|
||||
SubscriptionCacheWrapper.shared.clear()
|
||||
PushNotificationManager.shared.clearRegistrationCache()
|
||||
@@ -153,6 +161,9 @@ class LoginViewModel: ObservableObject {
|
||||
WidgetDataManager.shared.clearCache()
|
||||
WidgetDataManager.shared.clearAuthToken()
|
||||
|
||||
// Clear authenticated image cache
|
||||
AuthenticatedImage.clearCache()
|
||||
|
||||
// Reset local state
|
||||
self.isVerified = false
|
||||
self.currentUser = nil
|
||||
@@ -160,7 +171,9 @@ class LoginViewModel: ObservableObject {
|
||||
self.password = ""
|
||||
self.errorMessage = nil
|
||||
|
||||
#if DEBUG
|
||||
print("Logged out - all state reset")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,22 +9,35 @@ enum OnboardingIntent: String {
|
||||
}
|
||||
|
||||
/// Manages the state of the onboarding flow
|
||||
@MainActor
|
||||
class OnboardingState: ObservableObject {
|
||||
static let shared = OnboardingState()
|
||||
|
||||
/// Whether the user has completed onboarding
|
||||
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false
|
||||
// MARK: - Persisted State (via @AppStorage, survives app restarts)
|
||||
|
||||
/// Whether the user has completed onboarding.
|
||||
/// This is the persisted flag read at launch to decide whether to show onboarding.
|
||||
/// When set to `true`, Kotlin DataManager is also updated to keep both layers in sync.
|
||||
@AppStorage("hasCompletedOnboarding") var hasCompletedOnboarding: Bool = false {
|
||||
didSet {
|
||||
// Keep Kotlin DataManager in sync so Android/shared code sees the same value.
|
||||
ComposeApp.DataManager.shared.setHasCompletedOnboarding(completed: hasCompletedOnboarding)
|
||||
}
|
||||
}
|
||||
|
||||
/// The name of the residence being created during onboarding
|
||||
@AppStorage("onboardingResidenceName") var pendingResidenceName: String = ""
|
||||
|
||||
/// Backing storage for user intent (persisted across app restarts)
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||
|
||||
// MARK: - Transient State (via @Published, reset each session as needed)
|
||||
|
||||
/// The ID of the residence created during onboarding (used for task creation)
|
||||
@Published var createdResidenceId: Int32? = nil
|
||||
|
||||
/// The user's selected intent (start fresh or join existing) - persisted
|
||||
@AppStorage("onboardingUserIntent") private var userIntentRaw: String = OnboardingIntent.unknown.rawValue
|
||||
|
||||
/// The user's selected intent (start fresh or join existing)
|
||||
/// The user's selected intent (start fresh or join existing).
|
||||
/// Reads/writes the persisted @AppStorage value and notifies SwiftUI of the change.
|
||||
var userIntent: OnboardingIntent {
|
||||
get { OnboardingIntent(rawValue: userIntentRaw) ?? .unknown }
|
||||
set {
|
||||
@@ -84,7 +97,8 @@ class OnboardingState: ObservableObject {
|
||||
currentStep = step
|
||||
}
|
||||
|
||||
/// Complete the onboarding flow
|
||||
/// Complete the onboarding flow.
|
||||
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
|
||||
func completeOnboarding() {
|
||||
hasCompletedOnboarding = true
|
||||
isOnboardingActive = false
|
||||
@@ -93,7 +107,8 @@ class OnboardingState: ObservableObject {
|
||||
userIntent = .unknown
|
||||
}
|
||||
|
||||
/// Reset onboarding state (useful for testing or re-onboarding)
|
||||
/// Reset onboarding state (useful for testing or re-onboarding).
|
||||
/// Setting `hasCompletedOnboarding` also syncs with Kotlin DataManager via `didSet`.
|
||||
func reset() {
|
||||
hasCompletedOnboarding = false
|
||||
isOnboardingActive = false
|
||||
|
||||
39
iosApp/iosApp/PrivacyInfo.xcprivacy
Normal file
39
iosApp/iosApp/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSPrivacyTracking</key>
|
||||
<false/>
|
||||
<key>NSPrivacyTrackingDomains</key>
|
||||
<array/>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
<key>NSPrivacyAccessedAPITypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>E174.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>C617.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -3,6 +3,7 @@ import UserNotifications
|
||||
import BackgroundTasks
|
||||
import ComposeApp
|
||||
|
||||
@MainActor
|
||||
class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
|
||||
func application(
|
||||
@@ -19,20 +20,20 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
BackgroundTaskManager.shared.registerBackgroundTasks()
|
||||
|
||||
// Request notification permission
|
||||
Task { @MainActor in
|
||||
Task {
|
||||
await PushNotificationManager.shared.requestNotificationPermission()
|
||||
}
|
||||
|
||||
// Clear badge when app launches
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
}
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
|
||||
// Initialize StoreKit and check for existing subscriptions
|
||||
// This ensures we have the user's subscription status ready before they interact
|
||||
Task {
|
||||
_ = StoreKitManager.shared
|
||||
print("✅ StoreKit initialized at app launch")
|
||||
#if DEBUG
|
||||
print("StoreKit initialized at app launch")
|
||||
#endif
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -42,9 +43,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
|
||||
func applicationDidBecomeActive(_ application: UIApplication) {
|
||||
// Clear badge when app becomes active
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
}
|
||||
PushNotificationManager.shared.clearBadge()
|
||||
|
||||
// Refresh StoreKit subscription status when app comes to foreground
|
||||
// This ensures we have the latest subscription state if it changed while app was in background
|
||||
@@ -59,31 +58,29 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
_ application: UIApplication,
|
||||
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
|
||||
) {
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
|
||||
}
|
||||
PushNotificationManager.shared.didRegisterForRemoteNotifications(withDeviceToken: deviceToken)
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFailToRegisterForRemoteNotificationsWithError error: Error
|
||||
) {
|
||||
Task { @MainActor in
|
||||
PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error)
|
||||
}
|
||||
PushNotificationManager.shared.didFailToRegisterForRemoteNotifications(withError: error)
|
||||
}
|
||||
|
||||
// MARK: - UNUserNotificationCenterDelegate
|
||||
|
||||
// Called when notification is received while app is in foreground
|
||||
func userNotificationCenter(
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
#if DEBUG
|
||||
let userInfo = notification.request.content.userInfo
|
||||
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
|
||||
print("📬 Notification received in foreground. Keys: \(payloadKeys)")
|
||||
print("Notification received in foreground. Keys: \(payloadKeys)")
|
||||
#endif
|
||||
|
||||
// Passive mode in foreground: present banner/sound, but do not
|
||||
// mutate read state or trigger navigation automatically.
|
||||
@@ -91,7 +88,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
}
|
||||
|
||||
// Called when user taps on notification or selects an action
|
||||
func userNotificationCenter(
|
||||
nonisolated func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
@@ -99,9 +96,11 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
let actionIdentifier = response.actionIdentifier
|
||||
|
||||
print("👆 User interacted with notification - Action: \(actionIdentifier)")
|
||||
#if DEBUG
|
||||
print("User interacted with notification - Action: \(actionIdentifier)")
|
||||
let payloadKeys = Array(userInfo.keys).map { String(describing: $0) }.sorted()
|
||||
print(" Payload keys: \(payloadKeys)")
|
||||
#endif
|
||||
|
||||
Task { @MainActor in
|
||||
// Handle action buttons or default tap
|
||||
@@ -109,8 +108,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
// User tapped the notification body - navigate to task
|
||||
PushNotificationManager.shared.handleNotificationTap(userInfo: userInfo)
|
||||
} else if actionIdentifier == UNNotificationDismissActionIdentifier {
|
||||
// User dismissed the notification
|
||||
print("📤 Notification dismissed")
|
||||
#if DEBUG
|
||||
print("Notification dismissed")
|
||||
#endif
|
||||
} else {
|
||||
// User selected an action button
|
||||
PushNotificationManager.shared.handleNotificationAction(
|
||||
@@ -118,8 +118,8 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
|
||||
userInfo: userInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
completionHandler()
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ struct NotificationCategories {
|
||||
static func registerCategories() {
|
||||
let categories = createAllCategories()
|
||||
UNUserNotificationCenter.current().setNotificationCategories(categories)
|
||||
#if DEBUG
|
||||
print("Registered \(categories.count) notification categories")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Creates all notification categories for the app
|
||||
|
||||
@@ -3,8 +3,9 @@ import UIKit
|
||||
import UserNotifications
|
||||
import ComposeApp
|
||||
|
||||
@MainActor
|
||||
class PushNotificationManager: NSObject, ObservableObject {
|
||||
@MainActor static let shared = PushNotificationManager()
|
||||
static let shared = PushNotificationManager()
|
||||
|
||||
@Published var deviceToken: String?
|
||||
@Published var notificationPermissionGranted = false
|
||||
@@ -43,18 +44,24 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
notificationPermissionGranted = granted
|
||||
|
||||
if granted {
|
||||
#if DEBUG
|
||||
print("✅ Notification permission granted")
|
||||
#endif
|
||||
// Register for remote notifications on main thread
|
||||
await MainActor.run {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("❌ Notification permission denied")
|
||||
#endif
|
||||
}
|
||||
|
||||
return granted
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error requesting notification permission: \(error)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -65,16 +72,21 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
|
||||
self.deviceToken = tokenString
|
||||
let redactedToken = "\(tokenString.prefix(8))...\(tokenString.suffix(8))"
|
||||
#if DEBUG
|
||||
print("📱 APNs device token: \(redactedToken)")
|
||||
#endif
|
||||
|
||||
// Register with backend
|
||||
Task {
|
||||
await registerDeviceWithBackend(token: tokenString)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.registerDeviceWithBackend(token: tokenString)
|
||||
}
|
||||
}
|
||||
|
||||
func didFailToRegisterForRemoteNotifications(withError error: Error) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to register for remote notifications: \(error)")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Backend Registration
|
||||
@@ -82,30 +94,38 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
/// Call this after login to register any pending device token
|
||||
func registerDeviceAfterLogin() {
|
||||
guard let token = deviceToken else {
|
||||
#if DEBUG
|
||||
print("⚠️ No device token available for registration")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await registerDeviceWithBackend(token: token, force: false)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.registerDeviceWithBackend(token: token, force: false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this when app returns from background to check and register if needed
|
||||
func checkAndRegisterDeviceIfNeeded() {
|
||||
guard let token = deviceToken else {
|
||||
#if DEBUG
|
||||
print("⚠️ No device token available for registration check")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
await registerDeviceWithBackend(token: token, force: false)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.registerDeviceWithBackend(token: token, force: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerDeviceWithBackend(token: String, force: Bool = false) async {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
#if DEBUG
|
||||
print("⚠️ No auth token available, will register device after login")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,12 +136,16 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
// Skip only if both token and user identity match.
|
||||
if !force, token == lastRegisteredToken {
|
||||
if let currentUserId, currentUserId == lastRegisteredUserId {
|
||||
#if DEBUG
|
||||
print("📱 Device token already registered for current user, skipping")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
if currentUserId == nil, lastRegisteredUserId == nil {
|
||||
#if DEBUG
|
||||
print("📱 Device token already registered, skipping")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -145,55 +169,73 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let result = try await APILayer.shared.registerDevice(request: request)
|
||||
|
||||
if let success = result as? ApiResultSuccess<DeviceRegistrationResponse> {
|
||||
#if DEBUG
|
||||
if success.data != nil {
|
||||
print("✅ Device registered successfully")
|
||||
} else {
|
||||
print("✅ Device registration acknowledged")
|
||||
}
|
||||
#endif
|
||||
// Cache the token on successful registration
|
||||
await MainActor.run {
|
||||
self.lastRegisteredToken = token
|
||||
self.lastRegisteredUserId = currentUserId
|
||||
}
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to register device: \(error.message)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result type from device registration")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error registering device: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handle Notifications
|
||||
|
||||
func handleNotification(userInfo: [AnyHashable: Any]) {
|
||||
#if DEBUG
|
||||
print("📬 Received notification: \(redactedPayloadSummary(userInfo: userInfo))")
|
||||
#endif
|
||||
|
||||
// Extract notification data
|
||||
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
|
||||
#if DEBUG
|
||||
print("Notification ID: \(notificationId)")
|
||||
#endif
|
||||
|
||||
// Mark as read when user taps notification
|
||||
Task {
|
||||
await markNotificationAsRead(notificationId: notificationId)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.markNotificationAsRead(notificationId: notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
if let type = userInfo["type"] as? String {
|
||||
#if DEBUG
|
||||
print("Notification type: \(type)")
|
||||
#endif
|
||||
handleNotificationType(type: type, userInfo: userInfo)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when user taps the notification body (not an action button)
|
||||
func handleNotificationTap(userInfo: [AnyHashable: Any]) {
|
||||
#if DEBUG
|
||||
print("📬 Handling notification tap")
|
||||
#endif
|
||||
|
||||
// Mark as read
|
||||
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
|
||||
Task {
|
||||
await markNotificationAsRead(notificationId: notificationId)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.markNotificationAsRead(notificationId: notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +246,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded
|
||||
let canNavigateToTask = isPremium || !limitationsEnabled
|
||||
|
||||
#if DEBUG
|
||||
print("📬 Push nav check: isPremium=\(isPremium), limitationsEnabled=\(limitationsEnabled), canNavigate=\(canNavigateToTask), subscription=\(subscription != nil ? "loaded" : "nil")")
|
||||
#endif
|
||||
|
||||
if canNavigateToTask {
|
||||
// Navigate to task detail
|
||||
@@ -224,12 +268,15 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
/// Called when user selects an action button on the notification
|
||||
func handleNotificationAction(actionIdentifier: String, userInfo: [AnyHashable: Any]) {
|
||||
#if DEBUG
|
||||
print("🔘 Handling notification action: \(actionIdentifier)")
|
||||
#endif
|
||||
|
||||
// Mark as read
|
||||
if let notificationId = stringValue(for: "notification_id", in: userInfo) {
|
||||
Task {
|
||||
await markNotificationAsRead(notificationId: notificationId)
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.markNotificationAsRead(notificationId: notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +295,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
// Extract task ID
|
||||
guard let taskId = intValue(for: "task_id", in: userInfo) else {
|
||||
#if DEBUG
|
||||
print("❌ No task_id found in notification")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,7 +322,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
navigateToEditTask(taskId: taskId)
|
||||
|
||||
default:
|
||||
#if DEBUG
|
||||
print("⚠️ Unknown action: \(actionIdentifier)")
|
||||
#endif
|
||||
navigateToTask(taskId: taskId)
|
||||
}
|
||||
}
|
||||
@@ -282,38 +333,53 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
switch type {
|
||||
case "task_due_soon", "task_overdue", "task_completed", "task_assigned":
|
||||
if let taskId = intValue(for: "task_id", in: userInfo) {
|
||||
#if DEBUG
|
||||
print("Task notification for task ID: \(taskId)")
|
||||
#endif
|
||||
navigateToTask(taskId: taskId)
|
||||
}
|
||||
|
||||
case "residence_shared":
|
||||
if let residenceId = intValue(for: "residence_id", in: userInfo) {
|
||||
#if DEBUG
|
||||
print("Residence shared notification for residence ID: \(residenceId)")
|
||||
#endif
|
||||
navigateToResidence(residenceId: residenceId)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("Residence shared notification without residence ID")
|
||||
#endif
|
||||
navigateToResidencesTab()
|
||||
}
|
||||
|
||||
case "warranty_expiring":
|
||||
if let documentId = intValue(for: "document_id", in: userInfo) {
|
||||
#if DEBUG
|
||||
print("Warranty expiring notification for document ID: \(documentId)")
|
||||
#endif
|
||||
navigateToDocument(documentId: documentId)
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("Warranty expiring notification without document ID")
|
||||
#endif
|
||||
navigateToDocumentsTab()
|
||||
}
|
||||
|
||||
default:
|
||||
#if DEBUG
|
||||
print("Unknown notification type: \(type)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Actions
|
||||
|
||||
private func performCompleteTask(taskId: Int) {
|
||||
#if DEBUG
|
||||
print("✅ Completing task \(taskId) from notification action")
|
||||
Task {
|
||||
#endif
|
||||
Task { [weak self] in
|
||||
guard let _ = self else { return }
|
||||
do {
|
||||
// Quick complete without photos/notes
|
||||
let request = TaskCompletionCreateRequest(
|
||||
@@ -327,84 +393,125 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let result = try await APILayer.shared.createTaskCompletion(request: request)
|
||||
|
||||
if result is ApiResultSuccess<TaskCompletionResponse> {
|
||||
#if DEBUG
|
||||
print("✅ Task \(taskId) completed successfully")
|
||||
#endif
|
||||
// Post notification for UI refresh
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to complete task: \(error.message)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while completing task \(taskId)")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error completing task: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performMarkInProgress(taskId: Int) {
|
||||
#if DEBUG
|
||||
print("🔄 Marking task \(taskId) as in progress from notification action")
|
||||
Task {
|
||||
#endif
|
||||
Task { [weak self] in
|
||||
guard let _ = self else { return }
|
||||
do {
|
||||
let result = try await APILayer.shared.markInProgress(taskId: Int32(taskId))
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
#if DEBUG
|
||||
print("✅ Task \(taskId) marked as in progress")
|
||||
#endif
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to mark task in progress: \(error.message)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while marking task \(taskId) in progress")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error marking task in progress: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performCancelTask(taskId: Int) {
|
||||
#if DEBUG
|
||||
print("🚫 Cancelling task \(taskId) from notification action")
|
||||
Task {
|
||||
#endif
|
||||
Task { [weak self] in
|
||||
guard let _ = self else { return }
|
||||
do {
|
||||
let result = try await APILayer.shared.cancelTask(taskId: Int32(taskId))
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
#if DEBUG
|
||||
print("✅ Task \(taskId) cancelled")
|
||||
#endif
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to cancel task: \(error.message)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while cancelling task \(taskId)")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error cancelling task: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performUncancelTask(taskId: Int) {
|
||||
#if DEBUG
|
||||
print("↩️ Uncancelling task \(taskId) from notification action")
|
||||
Task {
|
||||
#endif
|
||||
Task { [weak self] in
|
||||
guard let _ = self else { return }
|
||||
do {
|
||||
let result = try await APILayer.shared.uncancelTask(taskId: Int32(taskId))
|
||||
|
||||
if result is ApiResultSuccess<TaskResponse> {
|
||||
#if DEBUG
|
||||
print("✅ Task \(taskId) uncancelled")
|
||||
#endif
|
||||
await MainActor.run {
|
||||
NotificationCenter.default.post(name: .taskActionCompleted, object: nil)
|
||||
}
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to uncancel task: \(error.message)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while uncancelling task \(taskId)")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error uncancelling task: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -412,7 +519,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
// MARK: - Navigation
|
||||
|
||||
private func navigateToTask(taskId: Int) {
|
||||
#if DEBUG
|
||||
print("📱 Navigating to task \(taskId)")
|
||||
#endif
|
||||
// Store pending navigation in case MainTabView isn't ready yet
|
||||
pendingNavigationTaskId = taskId
|
||||
NotificationCenter.default.post(
|
||||
@@ -436,7 +545,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func navigateToEditTask(taskId: Int) {
|
||||
#if DEBUG
|
||||
print("✏️ Navigating to edit task \(taskId)")
|
||||
#endif
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToEditTask,
|
||||
object: nil,
|
||||
@@ -445,7 +556,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func navigateToResidence(residenceId: Int) {
|
||||
#if DEBUG
|
||||
print("🏠 Navigating to residence \(residenceId)")
|
||||
#endif
|
||||
pendingNavigationResidenceId = residenceId
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToResidence,
|
||||
@@ -459,7 +572,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func navigateToDocument(documentId: Int) {
|
||||
#if DEBUG
|
||||
print("📄 Navigating to document \(documentId)")
|
||||
#endif
|
||||
pendingNavigationDocumentId = documentId
|
||||
NotificationCenter.default.post(
|
||||
name: .navigateToDocument,
|
||||
@@ -473,7 +588,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func navigateToHome() {
|
||||
#if DEBUG
|
||||
print("🏠 Navigating to home")
|
||||
#endif
|
||||
pendingNavigationTaskId = nil
|
||||
pendingNavigationResidenceId = nil
|
||||
pendingNavigationDocumentId = nil
|
||||
@@ -519,14 +636,22 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let result = try await APILayer.shared.markNotificationAsRead(notificationId: notificationIdInt)
|
||||
|
||||
if result is ApiResultSuccess<MessageResponse> {
|
||||
#if DEBUG
|
||||
print("✅ Notification marked as read")
|
||||
#endif
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to mark notification as read: \(error.message)")
|
||||
#endif
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while marking notification read")
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error marking notification as read: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,7 +659,9 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
|
||||
func updateNotificationPreferences(_ preferences: UpdateNotificationPreferencesRequest) async -> Bool {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
#if DEBUG
|
||||
print("⚠️ No auth token available")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -542,23 +669,33 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
let result = try await APILayer.shared.updateNotificationPreferences(request: preferences)
|
||||
|
||||
if result is ApiResultSuccess<NotificationPreference> {
|
||||
#if DEBUG
|
||||
print("✅ Notification preferences updated")
|
||||
#endif
|
||||
return true
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to update preferences: \(error.message)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while updating notification preferences")
|
||||
#endif
|
||||
return false
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error updating notification preferences: \(error.localizedDescription)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func getNotificationPreferences() async -> NotificationPreference? {
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
#if DEBUG
|
||||
print("⚠️ No auth token available")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -568,13 +705,19 @@ class PushNotificationManager: NSObject, ObservableObject {
|
||||
if let success = result as? ApiResultSuccess<NotificationPreference> {
|
||||
return success.data
|
||||
} else if let error = ApiResultBridge.error(from: result) {
|
||||
#if DEBUG
|
||||
print("❌ Failed to get preferences: \(error.message)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
#if DEBUG
|
||||
print("⚠️ Unexpected result while loading notification preferences")
|
||||
#endif
|
||||
return nil
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("❌ Error getting notification preferences: \(error.localizedDescription)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ struct ResidencesListView: View {
|
||||
@State private var showingUpgradePrompt = false
|
||||
@State private var showingSettings = false
|
||||
@State private var pushTargetResidenceId: Int32?
|
||||
@State private var navigateToPushResidence = false
|
||||
@State private var showLoginCover = false
|
||||
@StateObject private var authManager = AuthenticationManager.shared
|
||||
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@@ -100,7 +100,7 @@ struct ResidencesListView: View {
|
||||
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
|
||||
}
|
||||
.sheet(isPresented: $showingSettings) {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ProfileTabView()
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,8 @@ struct ResidencesListView: View {
|
||||
viewModel.loadMyResidences()
|
||||
// Also load tasks to populate summary stats
|
||||
taskViewModel.loadTasks()
|
||||
} else {
|
||||
showLoginCover = true
|
||||
}
|
||||
}
|
||||
.onChange(of: scenePhase) { newPhase in
|
||||
@@ -122,9 +124,10 @@ struct ResidencesListView: View {
|
||||
taskViewModel.loadTasks(forceRefresh: true)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $authManager.isAuthenticated.negated) {
|
||||
.fullScreenCover(isPresented: $showLoginCover) {
|
||||
LoginView(onLoginSuccess: {
|
||||
authManager.isAuthenticated = true
|
||||
showLoginCover = false
|
||||
viewModel.loadMyResidences()
|
||||
taskViewModel.loadTasks()
|
||||
})
|
||||
@@ -133,11 +136,13 @@ struct ResidencesListView: View {
|
||||
.onChange(of: authManager.isAuthenticated) { isAuth in
|
||||
if isAuth {
|
||||
// User just logged in or registered - load their residences and tasks
|
||||
showLoginCover = false
|
||||
viewModel.loadMyResidences()
|
||||
taskViewModel.loadTasks()
|
||||
} else {
|
||||
// User logged out - clear data
|
||||
// User logged out - clear data and show login
|
||||
viewModel.myResidences = nil
|
||||
showLoginCover = true
|
||||
}
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .navigateToResidence)) { notification in
|
||||
@@ -145,26 +150,18 @@ struct ResidencesListView: View {
|
||||
navigateToResidenceFromPush(residenceId: residenceId)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
NavigationLink(
|
||||
destination: Group {
|
||||
if let residenceId = pushTargetResidenceId {
|
||||
ResidenceDetailView(residenceId: residenceId)
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
},
|
||||
isActive: $navigateToPushResidence
|
||||
) {
|
||||
EmptyView()
|
||||
.navigationDestination(isPresented: Binding(
|
||||
get: { pushTargetResidenceId != nil },
|
||||
set: { if !$0 { pushTargetResidenceId = nil } }
|
||||
)) {
|
||||
if let residenceId = pushTargetResidenceId {
|
||||
ResidenceDetailView(residenceId: residenceId)
|
||||
}
|
||||
.hidden()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateToResidenceFromPush(residenceId: Int) {
|
||||
pushTargetResidenceId = Int32(residenceId)
|
||||
navigateToPushResidence = true
|
||||
PushNotificationManager.shared.pendingNavigationResidenceId = nil
|
||||
}
|
||||
}
|
||||
@@ -271,6 +268,7 @@ private struct OrganicCardButtonStyle: ButtonStyle {
|
||||
|
||||
private struct OrganicEmptyResidencesView: View {
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
@@ -295,7 +293,9 @@ private struct OrganicEmptyResidencesView: View {
|
||||
.frame(width: 160, height: 160)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
@@ -313,7 +313,9 @@ private struct OrganicEmptyResidencesView: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.offset(y: isAnimating ? -2 : 2)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
}
|
||||
@@ -347,20 +349,14 @@ private struct OrganicEmptyResidencesView: View {
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
ResidencesListView()
|
||||
}
|
||||
}
|
||||
|
||||
extension Binding where Value == Bool {
|
||||
var negated: Binding<Bool> {
|
||||
Binding<Bool>(
|
||||
get: { !self.wrappedValue },
|
||||
set: { self.wrappedValue = !$0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ extension KotlinDouble {
|
||||
extension Double {
|
||||
/// Formats as currency (e.g., "$1,234.56")
|
||||
func toCurrency() -> String {
|
||||
NumberFormatters.shared.currency.string(from: NSNumber(value: self)) ?? "$\(self)"
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = "USD"
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "$\(self)"
|
||||
}
|
||||
|
||||
/// Formats as currency with currency symbol (e.g., "$1,234.56")
|
||||
@@ -37,7 +40,7 @@ extension Double {
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = fractionDigits
|
||||
formatter.maximumFractionDigits = fractionDigits
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
||||
return formatter.string(from: NSNumber(value: self)) ?? String(format: "%.\(fractionDigits)f", self)
|
||||
}
|
||||
|
||||
/// Formats as percentage (e.g., "45.5%")
|
||||
@@ -75,7 +78,9 @@ extension Double {
|
||||
extension Int {
|
||||
/// Formats with comma separators (e.g., "1,234")
|
||||
func toFormattedString() -> String {
|
||||
NumberFormatters.shared.decimal.string(from: NSNumber(value: self)) ?? "\(self)"
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: self)) ?? "\(self)"
|
||||
}
|
||||
|
||||
/// Converts bytes to human-readable file size
|
||||
@@ -89,34 +94,3 @@ extension Int {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Centralized Number Formatters
|
||||
|
||||
class NumberFormatters {
|
||||
static let shared = NumberFormatters()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Currency formatter with $ symbol
|
||||
lazy var currency: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = "USD"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// Decimal formatter with comma separators
|
||||
lazy var decimal: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// Percentage formatter
|
||||
lazy var percentage: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .percent
|
||||
formatter.minimumFractionDigits = 1
|
||||
formatter.maximumFractionDigits = 1
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ComposeApp
|
||||
|
||||
/// StoreKit 2 manager for in-app purchases
|
||||
/// Handles product loading, purchases, transaction observation, and backend verification
|
||||
@MainActor
|
||||
class StoreKitManager: ObservableObject {
|
||||
static let shared = StoreKitManager()
|
||||
|
||||
@@ -54,7 +55,6 @@ class StoreKitManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Load available products from App Store
|
||||
@MainActor
|
||||
func loadProducts() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
@@ -87,13 +87,15 @@ class StoreKitManager: ObservableObject {
|
||||
// Update purchased products
|
||||
await updatePurchasedProducts()
|
||||
|
||||
// Verify with backend
|
||||
await verifyTransactionWithBackend(transaction)
|
||||
// Verify with backend — only finish the transaction if verification succeeds
|
||||
do {
|
||||
try await verifyTransactionWithBackend(transaction)
|
||||
await transaction.finish()
|
||||
print("✅ StoreKit: Purchase successful for \(product.id)")
|
||||
} catch {
|
||||
print("⚠️ StoreKit: Backend verification failed for \(product.id), transaction NOT finished so it can be retried: \(error)")
|
||||
}
|
||||
|
||||
// Finish the transaction
|
||||
await transaction.finish()
|
||||
|
||||
print("✅ StoreKit: Purchase successful for \(product.id)")
|
||||
return transaction
|
||||
|
||||
case .userCancelled:
|
||||
@@ -124,15 +126,13 @@ class StoreKitManager: ObservableObject {
|
||||
// Verify all current entitlements with backend
|
||||
for await result in Transaction.currentEntitlements {
|
||||
let transaction = try checkVerified(result)
|
||||
await verifyTransactionWithBackend(transaction)
|
||||
try await verifyTransactionWithBackend(transaction)
|
||||
}
|
||||
|
||||
print("✅ StoreKit: Purchases restored")
|
||||
} catch {
|
||||
print("❌ StoreKit: Failed to restore purchases: \(error)")
|
||||
await MainActor.run {
|
||||
purchaseError = "Failed to restore purchases: \(error.localizedDescription)"
|
||||
}
|
||||
purchaseError = "Failed to restore purchases: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +176,15 @@ class StoreKitManager: ObservableObject {
|
||||
if transaction.productType == .autoRenewable {
|
||||
print("📦 StoreKit: Found active subscription: \(transaction.productID)")
|
||||
|
||||
// Verify this transaction with backend
|
||||
await verifyTransactionWithBackend(transaction)
|
||||
// Verify this transaction with backend (best-effort on launch)
|
||||
do {
|
||||
try await verifyTransactionWithBackend(transaction)
|
||||
} catch {
|
||||
print("⚠️ StoreKit: Backend verification failed on launch for \(transaction.productID): \(error)")
|
||||
}
|
||||
|
||||
// Update local purchased products
|
||||
await MainActor.run {
|
||||
_ = purchasedProductIDs.insert(transaction.productID)
|
||||
}
|
||||
_ = purchasedProductIDs.insert(transaction.productID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,10 +201,8 @@ class StoreKitManager: ObservableObject {
|
||||
|
||||
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
||||
let subscription = statusSuccess.data {
|
||||
await MainActor.run {
|
||||
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
|
||||
print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)")
|
||||
}
|
||||
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
|
||||
print("✅ StoreKit: Backend subscription status updated - Tier: \(subscription.limits)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ StoreKit: Failed to refresh subscription from backend: \(error)")
|
||||
@@ -210,7 +210,6 @@ class StoreKitManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Update purchased product IDs
|
||||
@MainActor
|
||||
private func updatePurchasedProducts() async {
|
||||
var purchasedIDs: Set<String> = []
|
||||
|
||||
@@ -232,22 +231,20 @@ class StoreKitManager: ObservableObject {
|
||||
|
||||
/// Listen for transaction updates
|
||||
private func listenForTransactions() -> Task<Void, Error> {
|
||||
return Task.detached {
|
||||
// Listen for transaction updates
|
||||
return Task {
|
||||
for await result in Transaction.updates {
|
||||
do {
|
||||
let transaction = try self.checkVerified(result)
|
||||
let transaction = try checkVerified(result)
|
||||
|
||||
// Update purchased products
|
||||
await self.updatePurchasedProducts()
|
||||
await updatePurchasedProducts()
|
||||
|
||||
// Verify with backend
|
||||
await self.verifyTransactionWithBackend(transaction)
|
||||
|
||||
// Finish the transaction
|
||||
await transaction.finish()
|
||||
|
||||
print("✅ StoreKit: Transaction updated: \(transaction.productID)")
|
||||
do {
|
||||
try await verifyTransactionWithBackend(transaction)
|
||||
await transaction.finish()
|
||||
print("✅ StoreKit: Transaction updated: \(transaction.productID)")
|
||||
} catch {
|
||||
print("⚠️ StoreKit: Backend verification failed for \(transaction.productID), transaction NOT finished so it can be retried: \(error)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ StoreKit: Transaction verification failed: \(error)")
|
||||
}
|
||||
@@ -256,41 +253,39 @@ class StoreKitManager: ObservableObject {
|
||||
}
|
||||
|
||||
/// Verify transaction with backend API
|
||||
private func verifyTransactionWithBackend(_ transaction: Transaction) async {
|
||||
do {
|
||||
// Get transaction receipt data
|
||||
let receiptData = String(transaction.id)
|
||||
/// Throws if backend verification fails so callers can decide whether to finish the transaction
|
||||
private func verifyTransactionWithBackend(_ transaction: Transaction) async throws {
|
||||
let receiptData = String(transaction.id)
|
||||
|
||||
// Call backend verification endpoint via APILayer
|
||||
let result = try await APILayer.shared.verifyIOSReceipt(
|
||||
receiptData: receiptData,
|
||||
transactionId: String(transaction.id)
|
||||
)
|
||||
// Call backend verification endpoint via APILayer
|
||||
let result = try await APILayer.shared.verifyIOSReceipt(
|
||||
receiptData: receiptData,
|
||||
transactionId: String(transaction.id)
|
||||
)
|
||||
|
||||
// Handle result (Kotlin ApiResult type)
|
||||
if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
||||
let response = successResult.data,
|
||||
response.success {
|
||||
print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")")
|
||||
// Handle result (Kotlin ApiResult type)
|
||||
if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
||||
let response = successResult.data,
|
||||
response.success {
|
||||
print("✅ StoreKit: Backend verification successful - Tier: \(response.tier ?? "unknown")")
|
||||
|
||||
// Fetch updated subscription status from backend via APILayer
|
||||
let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
|
||||
// Fetch updated subscription status from backend via APILayer
|
||||
let statusResult = try await APILayer.shared.getSubscriptionStatus(forceRefresh: true)
|
||||
|
||||
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
||||
let subscription = statusSuccess.data {
|
||||
await MainActor.run {
|
||||
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
|
||||
}
|
||||
}
|
||||
} else if let errorResult = ApiResultBridge.error(from: result) {
|
||||
print("❌ StoreKit: Backend verification failed: \(errorResult.message)")
|
||||
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
||||
let response = successResult.data,
|
||||
!response.success {
|
||||
print("❌ StoreKit: Backend verification failed: \(response.message)")
|
||||
if let statusSuccess = statusResult as? ApiResultSuccess<ComposeApp.SubscriptionStatus>,
|
||||
let subscription = statusSuccess.data {
|
||||
SubscriptionCacheWrapper.shared.updateSubscription(subscription)
|
||||
}
|
||||
} catch {
|
||||
print("❌ StoreKit: Backend verification error: \(error)")
|
||||
} else if let errorResult = ApiResultBridge.error(from: result) {
|
||||
let message = errorResult.message
|
||||
print("❌ StoreKit: Backend verification failed: \(message)")
|
||||
throw StoreKitError.backendVerificationFailed(message)
|
||||
} else if let successResult = result as? ApiResultSuccess<VerificationResponse>,
|
||||
let response = successResult.data,
|
||||
!response.success {
|
||||
let message = response.message
|
||||
print("❌ StoreKit: Backend verification failed: \(message)")
|
||||
throw StoreKitError.backendVerificationFailed(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,6 +306,7 @@ extension StoreKitManager {
|
||||
case verificationFailed
|
||||
case noProducts
|
||||
case purchaseFailed
|
||||
case backendVerificationFailed(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
@@ -320,6 +316,8 @@ extension StoreKitManager {
|
||||
return "No products available"
|
||||
case .purchaseFailed:
|
||||
return "Purchase failed"
|
||||
case .backendVerificationFailed(let message):
|
||||
return "Backend verification failed: \(message)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import ComposeApp
|
||||
|
||||
/// Swift wrapper that reads subscription state from Kotlin DataManager (single source of truth).
|
||||
///
|
||||
/// DataManager is the authoritative subscription state holder. This wrapper
|
||||
/// observes DataManager's StateFlows (via polling) and publishes changes
|
||||
/// to SwiftUI views via @Published properties.
|
||||
/// observes DataManager's StateFlows (via targeted Combine observation of
|
||||
/// DataManagerObservable's subscription-related @Published properties)
|
||||
/// and publishes changes to SwiftUI views via @Published properties.
|
||||
@MainActor
|
||||
class SubscriptionCacheWrapper: ObservableObject {
|
||||
static let shared = SubscriptionCacheWrapper()
|
||||
|
||||
@@ -14,6 +17,8 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
@Published var featureBenefits: [FeatureBenefit] = []
|
||||
@Published var promotions: [Promotion] = []
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
/// Current tier derived from backend subscription status, with StoreKit fallback.
|
||||
/// Mirrors the logic in Kotlin SubscriptionHelper.currentTier.
|
||||
var currentTier: String {
|
||||
@@ -104,61 +109,58 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Start observation of DataManager (single source of truth)
|
||||
Task { @MainActor in
|
||||
// Initial sync from DataManager
|
||||
self.syncFromDataManager()
|
||||
// Observe only subscription-related @Published properties from DataManagerObservable
|
||||
// instead of the broad objectWillChange (which fires on ALL 25+ property changes).
|
||||
|
||||
// Poll DataManager for updates periodically
|
||||
// (workaround for Kotlin StateFlow observation from Swift)
|
||||
while true {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
||||
self.syncFromDataManager()
|
||||
DataManagerObservable.shared.$subscription
|
||||
.sink { [weak self] subscription in
|
||||
guard let self else { return }
|
||||
if self.currentSubscription != subscription {
|
||||
self.currentSubscription = subscription
|
||||
if let subscription {
|
||||
self.syncWidgetSubscriptionStatus(subscription: subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
/// Sync all subscription state from DataManager (Kotlin single source of truth)
|
||||
@MainActor
|
||||
private func syncFromDataManager() {
|
||||
// Read subscription status from DataManager
|
||||
if let subscription = ComposeApp.DataManager.shared.subscription.value as? SubscriptionStatus {
|
||||
if self.currentSubscription == nil || self.currentSubscription != subscription {
|
||||
self.currentSubscription = subscription
|
||||
syncWidgetSubscriptionStatus(subscription: subscription)
|
||||
DataManagerObservable.shared.$upgradeTriggers
|
||||
.sink { [weak self] triggers in
|
||||
self?.upgradeTriggers = triggers
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Read upgrade triggers from DataManager
|
||||
if let triggers = ComposeApp.DataManager.shared.upgradeTriggers.value as? [String: UpgradeTriggerData] {
|
||||
self.upgradeTriggers = triggers
|
||||
}
|
||||
DataManagerObservable.shared.$featureBenefits
|
||||
.sink { [weak self] benefits in
|
||||
self?.featureBenefits = benefits
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Read feature benefits from DataManager
|
||||
if let benefits = ComposeApp.DataManager.shared.featureBenefits.value as? [FeatureBenefit] {
|
||||
self.featureBenefits = benefits
|
||||
}
|
||||
|
||||
// Read promotions from DataManager
|
||||
if let promos = ComposeApp.DataManager.shared.promotions.value as? [Promotion] {
|
||||
self.promotions = promos
|
||||
}
|
||||
DataManagerObservable.shared.$promotions
|
||||
.sink { [weak self] promos in
|
||||
self?.promotions = promos
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func refreshFromCache() {
|
||||
Task { @MainActor in
|
||||
syncFromDataManager()
|
||||
// Trigger a re-read from DataManager; the Combine subscriptions will
|
||||
// propagate changes automatically when DataManagerObservable updates.
|
||||
let subscription = ComposeApp.DataManager.shared.subscription.value as? SubscriptionStatus
|
||||
if self.currentSubscription != subscription {
|
||||
self.currentSubscription = subscription
|
||||
if let subscription {
|
||||
syncWidgetSubscriptionStatus(subscription: subscription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateSubscription(_ subscription: SubscriptionStatus) {
|
||||
// Write to DataManager (single source of truth)
|
||||
ComposeApp.DataManager.shared.setSubscription(subscription: subscription)
|
||||
DispatchQueue.main.async {
|
||||
self.currentSubscription = subscription
|
||||
// Sync subscription status with widget
|
||||
self.syncWidgetSubscriptionStatus(subscription: subscription)
|
||||
}
|
||||
// Update local state directly (@MainActor guarantees main thread)
|
||||
self.currentSubscription = subscription
|
||||
syncWidgetSubscriptionStatus(subscription: subscription)
|
||||
}
|
||||
|
||||
/// Sync subscription status with widget extension
|
||||
@@ -177,11 +179,10 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
ComposeApp.DataManager.shared.setUpgradeTriggers(triggers: [:])
|
||||
ComposeApp.DataManager.shared.setFeatureBenefits(benefits: [])
|
||||
ComposeApp.DataManager.shared.setPromotions(promos: [])
|
||||
DispatchQueue.main.async {
|
||||
self.currentSubscription = nil
|
||||
self.upgradeTriggers = [:]
|
||||
self.featureBenefits = []
|
||||
self.promotions = []
|
||||
}
|
||||
// Update local state directly (@MainActor guarantees main thread)
|
||||
self.currentSubscription = nil
|
||||
self.upgradeTriggers = [:]
|
||||
self.featureBenefits = []
|
||||
self.promotions = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ import StoreKit
|
||||
|
||||
struct PromoContentView: View {
|
||||
let content: String
|
||||
private let lines: [PromoLine]
|
||||
|
||||
private var lines: [PromoLine] {
|
||||
parseContent(content)
|
||||
init(content: String) {
|
||||
self.content = content
|
||||
self.lines = Self.parseContent(content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@@ -70,7 +72,7 @@ struct PromoContentView: View {
|
||||
case spacer
|
||||
}
|
||||
|
||||
private func parseContent(_ content: String) -> [PromoLine] {
|
||||
private static func parseContent(_ content: String) -> [PromoLine] {
|
||||
var result: [PromoLine] = []
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
|
||||
@@ -412,7 +414,7 @@ private struct OrganicSubscriptionButton: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var isAnnual: Bool {
|
||||
product.id.contains("annual")
|
||||
product.subscription?.subscriptionPeriod.unit == .year
|
||||
}
|
||||
|
||||
var savingsText: String? {
|
||||
@@ -489,7 +491,7 @@ struct SubscriptionProductButton: View {
|
||||
let onSelect: () -> Void
|
||||
|
||||
var isAnnual: Bool {
|
||||
product.id.contains("annual")
|
||||
product.subscription?.subscriptionPeriod.unit == .year
|
||||
}
|
||||
|
||||
var savingsText: String? {
|
||||
|
||||
@@ -141,10 +141,14 @@ struct PropertyHeaderCard: View {
|
||||
.naturalShadow(.pronounced)
|
||||
}
|
||||
|
||||
private func formatNumber(_ num: Int) -> String {
|
||||
private static let decimalFormatter: NumberFormatter = {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
return formatter.string(from: NSNumber(value: num)) ?? "\(num)"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func formatNumber(_ num: Int) -> String {
|
||||
Self.decimalFormatter.string(from: NSNumber(value: num)) ?? "\(num)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ struct AllTasksView: View {
|
||||
.sheet(isPresented: $showAddTask) {
|
||||
AddTaskWithResidenceView(
|
||||
isPresented: $showAddTask,
|
||||
residences: residenceViewModel.myResidences?.residences.toResidences() ?? []
|
||||
residences: residenceViewModel.myResidences?.residences ?? []
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showEditTask) {
|
||||
@@ -91,16 +91,6 @@ struct AllTasksView: View {
|
||||
} message: {
|
||||
Text(L10n.Tasks.cancelConfirm)
|
||||
}
|
||||
.onChange(of: showAddTask) { isShowing in
|
||||
if !isShowing {
|
||||
loadAllTasks()
|
||||
}
|
||||
}
|
||||
.onChange(of: showEditTask) { isShowing in
|
||||
if !isShowing {
|
||||
loadAllTasks()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
AnalyticsManager.shared.trackScreen(.tasks)
|
||||
|
||||
@@ -257,7 +247,7 @@ struct AllTasksView: View {
|
||||
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
|
||||
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
|
||||
}
|
||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
|
||||
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true) || isLoadingTasks)
|
||||
|
||||
Button(action: {
|
||||
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
|
||||
@@ -268,16 +258,11 @@ struct AllTasksView: View {
|
||||
}) {
|
||||
OrganicToolbarAddButton()
|
||||
}
|
||||
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
|
||||
.disabled((residenceViewModel.myResidences?.residences.isEmpty ?? true))
|
||||
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: taskViewModel.isLoading) { isLoading in
|
||||
if !isLoading {
|
||||
loadAllTasks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAllTasks(forceRefresh: Bool = false) {
|
||||
@@ -313,6 +298,7 @@ private struct OrganicEmptyTasksView: View {
|
||||
@Binding var showingUpgradePrompt: Bool
|
||||
@Binding var showAddTask: Bool
|
||||
@State private var isAnimating = false
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: OrganicSpacing.comfortable) {
|
||||
@@ -335,7 +321,9 @@ private struct OrganicEmptyTasksView: View {
|
||||
.frame(width: 160, height: 160)
|
||||
.scaleEffect(isAnimating ? 1.1 : 1.0)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 3).repeatForever(autoreverses: true),
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 3).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
|
||||
@@ -349,7 +337,9 @@ private struct OrganicEmptyTasksView: View {
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.offset(y: isAnimating ? -2 : 2)
|
||||
.animation(
|
||||
Animation.easeInOut(duration: 2).repeatForever(autoreverses: true),
|
||||
isAnimating && !reduceMotion
|
||||
? Animation.easeInOut(duration: 2).repeatForever(autoreverses: true)
|
||||
: .default,
|
||||
value: isAnimating
|
||||
)
|
||||
}
|
||||
@@ -421,6 +411,9 @@ private struct OrganicEmptyTasksView: View {
|
||||
.onAppear {
|
||||
isAnimating = true
|
||||
}
|
||||
.onDisappear {
|
||||
isAnimating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,13 +456,8 @@ struct RoundedCorner: Shape {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
NavigationStack {
|
||||
AllTasksView()
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == ResidenceResponse {
|
||||
func toResidences() -> [ResidenceResponse] {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,21 +48,11 @@ struct iOSApp: App {
|
||||
// Initialize PostHog Analytics (must use Swift AnalyticsManager, not the Kotlin stub)
|
||||
AnalyticsManager.shared.configure()
|
||||
}
|
||||
|
||||
// Initialize lookups at app start (public endpoints, no auth required)
|
||||
// This fetches /static_data/ and /upgrade-triggers/ immediately
|
||||
if !UITestRuntime.isEnabled {
|
||||
Task {
|
||||
print("🚀 Initializing lookups at app start...")
|
||||
_ = try? await APILayer.shared.initializeLookups()
|
||||
print("✅ Lookups initialized")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
RootView()
|
||||
RootView(deepLinkResetToken: $deepLinkResetToken)
|
||||
.environmentObject(themeManager)
|
||||
.environmentObject(contractorSharingManager)
|
||||
.environmentObject(residenceSharingManager)
|
||||
@@ -202,7 +192,9 @@ struct iOSApp: App {
|
||||
|
||||
/// Handles all incoming URLs - both deep links and file opens
|
||||
private func handleIncomingURL(url: URL) {
|
||||
#if DEBUG
|
||||
print("URL received: \(url)")
|
||||
#endif
|
||||
|
||||
// Handle .casera file imports
|
||||
if url.pathExtension.lowercased() == "casera" {
|
||||
@@ -216,12 +208,16 @@ struct iOSApp: App {
|
||||
return
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
print("Unrecognized URL: \(url)")
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Handles .casera file imports - detects type and routes accordingly
|
||||
private func handleCaseraFileImport(url: URL) {
|
||||
#if DEBUG
|
||||
print("Casera file received: \(url)")
|
||||
#endif
|
||||
|
||||
// Check if user is authenticated
|
||||
guard TokenStorage.shared.getToken() != nil else {
|
||||
@@ -229,35 +225,38 @@ struct iOSApp: App {
|
||||
return
|
||||
}
|
||||
|
||||
// Read file and detect type
|
||||
// Read file and detect type — copy to temp while security scope is active
|
||||
let accessing = url.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
|
||||
// Copy to temp location so import works after security scope ends
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("casera")
|
||||
try data.write(to: tempURL)
|
||||
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let typeString = json["type"] as? String {
|
||||
// Route based on type
|
||||
if typeString == "residence" {
|
||||
pendingImportType = .residence
|
||||
} else {
|
||||
pendingImportType = .contractor
|
||||
}
|
||||
pendingImportType = typeString == "residence" ? .residence : .contractor
|
||||
} else {
|
||||
// Default to contractor for backward compatibility (files without type field)
|
||||
pendingImportType = .contractor
|
||||
}
|
||||
|
||||
pendingImportURL = tempURL
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to read casera file: \(error)")
|
||||
#endif
|
||||
pendingImportType = .contractor
|
||||
pendingImportURL = url
|
||||
}
|
||||
|
||||
if accessing {
|
||||
url.stopAccessingSecurityScopedResource()
|
||||
}
|
||||
|
||||
// Store URL and show confirmation dialog
|
||||
pendingImportURL = url
|
||||
showImportConfirmation = true
|
||||
}
|
||||
|
||||
@@ -265,7 +264,9 @@ struct iOSApp: App {
|
||||
private func handleDeepLink(url: URL) {
|
||||
// Handle casera://reset-password?token=xxx
|
||||
guard url.host == "reset-password" else {
|
||||
#if DEBUG
|
||||
print("Unrecognized deep link host: \(url.host ?? "nil")")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,10 +274,14 @@ struct iOSApp: App {
|
||||
if let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
|
||||
let queryItems = components.queryItems,
|
||||
let token = queryItems.first(where: { $0.name == "token" })?.value {
|
||||
#if DEBUG
|
||||
print("Reset token extracted: \(token)")
|
||||
#endif
|
||||
deepLinkResetToken = token
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("No token found in deep link")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user