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