Add unified DataManager as single source of truth for all app data
- Create DataManager.kt with StateFlows for all cached data: - Authentication (token, user) - Residences, tasks, documents, contractors - Subscription status and upgrade triggers - All lookup data (residence types, task categories, etc.) - Theme preferences and state metadata - Add PersistenceManager with platform-specific implementations: - Android: SharedPreferences - iOS: NSUserDefaults - JVM: Properties file - WasmJS: localStorage - Migrate APILayer to update DataManager on every API response - Update Kotlin ViewModels to use DataManager for token access - Deprecate LookupsRepository (delegates to DataManager) - Create iOS DataManagerObservable Swift wrapper for SwiftUI - Update iOS auth flow to use DataManager.isAuthenticated() Data flow: User Action → API Call → DataManager Updated → All Screens React 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
429
iosApp/iosApp/Data/DataManagerObservable.swift
Normal file
429
iosApp/iosApp/Data/DataManagerObservable.swift
Normal file
@@ -0,0 +1,429 @@
|
||||
import Foundation
|
||||
import ComposeApp
|
||||
import Combine
|
||||
|
||||
/// SwiftUI-compatible wrapper for Kotlin DataManager.
|
||||
/// Observes all DataManager StateFlows and publishes changes via @Published properties.
|
||||
///
|
||||
/// Usage in SwiftUI views:
|
||||
/// ```swift
|
||||
/// @StateObject private var dataManager = DataManagerObservable.shared
|
||||
/// // or
|
||||
/// @EnvironmentObject var dataManager: DataManagerObservable
|
||||
/// ```
|
||||
///
|
||||
/// This is the Swift-side Single Source of Truth that mirrors Kotlin's DataManager.
|
||||
/// All screens should observe this instead of making duplicate API calls.
|
||||
@MainActor
|
||||
class DataManagerObservable: ObservableObject {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = DataManagerObservable()
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
@Published var authToken: String?
|
||||
@Published var currentUser: User?
|
||||
@Published var isAuthenticated: Bool = false
|
||||
|
||||
// MARK: - App Preferences
|
||||
|
||||
@Published var themeId: String = "default"
|
||||
|
||||
// MARK: - Residences
|
||||
|
||||
@Published var residences: [ResidenceResponse] = []
|
||||
@Published var myResidences: MyResidencesResponse?
|
||||
@Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:]
|
||||
|
||||
// MARK: - Tasks
|
||||
|
||||
@Published var allTasks: TaskColumnsResponse?
|
||||
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
|
||||
|
||||
// MARK: - Documents
|
||||
|
||||
@Published var documents: [Document] = []
|
||||
@Published var documentsByResidence: [Int32: [Document]] = [:]
|
||||
|
||||
// MARK: - Contractors
|
||||
|
||||
@Published var contractors: [Contractor] = []
|
||||
|
||||
// MARK: - Subscription
|
||||
|
||||
@Published var subscription: SubscriptionStatus?
|
||||
@Published var upgradeTriggers: [String: UpgradeTriggerData] = [:]
|
||||
@Published var featureBenefits: [FeatureBenefit] = []
|
||||
@Published var promotions: [Promotion] = []
|
||||
|
||||
// MARK: - Lookups (Reference Data)
|
||||
|
||||
@Published var residenceTypes: [ResidenceType] = []
|
||||
@Published var taskFrequencies: [TaskFrequency] = []
|
||||
@Published var taskPriorities: [TaskPriority] = []
|
||||
@Published var taskStatuses: [TaskStatus] = []
|
||||
@Published var taskCategories: [TaskCategory] = []
|
||||
@Published var contractorSpecialties: [ContractorSpecialty] = []
|
||||
|
||||
// MARK: - State Metadata
|
||||
|
||||
@Published var isInitialized: Bool = false
|
||||
@Published var lookupsInitialized: Bool = false
|
||||
@Published var lastSyncTime: Int64 = 0
|
||||
|
||||
// MARK: - Private Properties
|
||||
|
||||
private var observationTasks: [Task<Void, Never>] = []
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {
|
||||
startObserving()
|
||||
}
|
||||
|
||||
// MARK: - Observation Setup
|
||||
|
||||
/// Start observing all DataManager StateFlows
|
||||
private func startObserving() {
|
||||
// Authentication - authToken
|
||||
let authTokenTask = Task {
|
||||
for await token in DataManager.shared.authToken {
|
||||
await MainActor.run {
|
||||
self.authToken = token
|
||||
self.isAuthenticated = token != nil
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(authTokenTask)
|
||||
|
||||
// Authentication - currentUser
|
||||
let currentUserTask = Task {
|
||||
for await user in DataManager.shared.currentUser {
|
||||
await MainActor.run {
|
||||
self.currentUser = user
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(currentUserTask)
|
||||
|
||||
// Theme
|
||||
let themeIdTask = Task {
|
||||
for await id in DataManager.shared.themeId {
|
||||
await MainActor.run {
|
||||
self.themeId = id
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(themeIdTask)
|
||||
|
||||
// Residences
|
||||
let residencesTask = Task {
|
||||
for await list in DataManager.shared.residences {
|
||||
await MainActor.run {
|
||||
self.residences = list
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(residencesTask)
|
||||
|
||||
// MyResidences
|
||||
let myResidencesTask = Task {
|
||||
for await response in DataManager.shared.myResidences {
|
||||
await MainActor.run {
|
||||
self.myResidences = response
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(myResidencesTask)
|
||||
|
||||
// ResidenceSummaries
|
||||
let residenceSummariesTask = Task {
|
||||
for await summaries in DataManager.shared.residenceSummaries {
|
||||
await MainActor.run {
|
||||
self.residenceSummaries = self.convertIntMap(summaries)
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(residenceSummariesTask)
|
||||
|
||||
// AllTasks
|
||||
let allTasksTask = Task {
|
||||
for await tasks in DataManager.shared.allTasks {
|
||||
await MainActor.run {
|
||||
self.allTasks = tasks
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(allTasksTask)
|
||||
|
||||
// TasksByResidence
|
||||
let tasksByResidenceTask = Task {
|
||||
for await tasks in DataManager.shared.tasksByResidence {
|
||||
await MainActor.run {
|
||||
self.tasksByResidence = self.convertIntMap(tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(tasksByResidenceTask)
|
||||
|
||||
// Documents
|
||||
let documentsTask = Task {
|
||||
for await docs in DataManager.shared.documents {
|
||||
await MainActor.run {
|
||||
self.documents = docs
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(documentsTask)
|
||||
|
||||
// DocumentsByResidence
|
||||
let documentsByResidenceTask = Task {
|
||||
for await docs in DataManager.shared.documentsByResidence {
|
||||
await MainActor.run {
|
||||
self.documentsByResidence = self.convertIntArrayMap(docs)
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(documentsByResidenceTask)
|
||||
|
||||
// Contractors
|
||||
let contractorsTask = Task {
|
||||
for await list in DataManager.shared.contractors {
|
||||
await MainActor.run {
|
||||
self.contractors = list
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(contractorsTask)
|
||||
|
||||
// Subscription
|
||||
let subscriptionTask = Task {
|
||||
for await sub in DataManager.shared.subscription {
|
||||
await MainActor.run {
|
||||
self.subscription = sub
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(subscriptionTask)
|
||||
|
||||
// UpgradeTriggers
|
||||
let upgradeTriggersTask = Task {
|
||||
for await triggers in DataManager.shared.upgradeTriggers {
|
||||
await MainActor.run {
|
||||
self.upgradeTriggers = self.convertStringMap(triggers)
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(upgradeTriggersTask)
|
||||
|
||||
// FeatureBenefits
|
||||
let featureBenefitsTask = Task {
|
||||
for await benefits in DataManager.shared.featureBenefits {
|
||||
await MainActor.run {
|
||||
self.featureBenefits = benefits
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(featureBenefitsTask)
|
||||
|
||||
// Promotions
|
||||
let promotionsTask = Task {
|
||||
for await promos in DataManager.shared.promotions {
|
||||
await MainActor.run {
|
||||
self.promotions = promos
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(promotionsTask)
|
||||
|
||||
// Lookups - ResidenceTypes
|
||||
let residenceTypesTask = Task {
|
||||
for await types in DataManager.shared.residenceTypes {
|
||||
await MainActor.run {
|
||||
self.residenceTypes = types
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(residenceTypesTask)
|
||||
|
||||
// Lookups - TaskFrequencies
|
||||
let taskFrequenciesTask = Task {
|
||||
for await items in DataManager.shared.taskFrequencies {
|
||||
await MainActor.run {
|
||||
self.taskFrequencies = items
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskFrequenciesTask)
|
||||
|
||||
// Lookups - TaskPriorities
|
||||
let taskPrioritiesTask = Task {
|
||||
for await items in DataManager.shared.taskPriorities {
|
||||
await MainActor.run {
|
||||
self.taskPriorities = items
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskPrioritiesTask)
|
||||
|
||||
// Lookups - TaskStatuses
|
||||
let taskStatusesTask = Task {
|
||||
for await items in DataManager.shared.taskStatuses {
|
||||
await MainActor.run {
|
||||
self.taskStatuses = items
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskStatusesTask)
|
||||
|
||||
// Lookups - TaskCategories
|
||||
let taskCategoriesTask = Task {
|
||||
for await items in DataManager.shared.taskCategories {
|
||||
await MainActor.run {
|
||||
self.taskCategories = items
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(taskCategoriesTask)
|
||||
|
||||
// Lookups - ContractorSpecialties
|
||||
let contractorSpecialtiesTask = Task {
|
||||
for await items in DataManager.shared.contractorSpecialties {
|
||||
await MainActor.run {
|
||||
self.contractorSpecialties = items
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(contractorSpecialtiesTask)
|
||||
|
||||
// Metadata - isInitialized
|
||||
let isInitializedTask = Task {
|
||||
for await initialized in DataManager.shared.isInitialized {
|
||||
await MainActor.run {
|
||||
self.isInitialized = initialized.boolValue
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(isInitializedTask)
|
||||
|
||||
// Metadata - lookupsInitialized
|
||||
let lookupsInitializedTask = Task {
|
||||
for await initialized in DataManager.shared.lookupsInitialized {
|
||||
await MainActor.run {
|
||||
self.lookupsInitialized = initialized.boolValue
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(lookupsInitializedTask)
|
||||
|
||||
// Metadata - lastSyncTime
|
||||
let lastSyncTimeTask = Task {
|
||||
for await time in DataManager.shared.lastSyncTime {
|
||||
await MainActor.run {
|
||||
self.lastSyncTime = time.int64Value
|
||||
}
|
||||
}
|
||||
}
|
||||
observationTasks.append(lastSyncTimeTask)
|
||||
}
|
||||
|
||||
/// Stop all observations
|
||||
func stopObserving() {
|
||||
observationTasks.forEach { $0.cancel() }
|
||||
observationTasks.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Map Conversion Helpers
|
||||
|
||||
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
|
||||
private func convertIntMap<V>(_ kotlinMap: [KotlinInt: V]) -> [Int32: V] {
|
||||
var result: [Int32: V] = [:]
|
||||
for (key, value) in kotlinMap {
|
||||
result[key.int32Value] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
|
||||
private func convertIntArrayMap<V>(_ kotlinMap: [KotlinInt: [V]]) -> [Int32: [V]] {
|
||||
var result: [Int32: [V]] = [:]
|
||||
for (key, value) in kotlinMap {
|
||||
result[key.int32Value] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Convert Kotlin Map<String, V> to Swift [String: V]
|
||||
private func convertStringMap<V>(_ kotlinMap: [String: V]) -> [String: V] {
|
||||
return kotlinMap
|
||||
}
|
||||
|
||||
// MARK: - Convenience Lookup Methods
|
||||
|
||||
/// Get residence type by ID
|
||||
func getResidenceType(id: Int32?) -> ResidenceType? {
|
||||
guard let id = id else { return nil }
|
||||
return residenceTypes.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get task frequency by ID
|
||||
func getTaskFrequency(id: Int32?) -> TaskFrequency? {
|
||||
guard let id = id else { return nil }
|
||||
return taskFrequencies.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get task priority by ID
|
||||
func getTaskPriority(id: Int32?) -> TaskPriority? {
|
||||
guard let id = id else { return nil }
|
||||
return taskPriorities.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get task status by ID
|
||||
func getTaskStatus(id: Int32?) -> TaskStatus? {
|
||||
guard let id = id else { return nil }
|
||||
return taskStatuses.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get task category by ID
|
||||
func getTaskCategory(id: Int32?) -> TaskCategory? {
|
||||
guard let id = id else { return nil }
|
||||
return taskCategories.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get contractor specialty by ID
|
||||
func getContractorSpecialty(id: Int32?) -> ContractorSpecialty? {
|
||||
guard let id = id else { return nil }
|
||||
return contractorSpecialties.first { $0.id == id }
|
||||
}
|
||||
|
||||
// MARK: - Task Helpers
|
||||
|
||||
/// Get tasks for a specific residence
|
||||
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
|
||||
return tasksByResidence[residenceId]
|
||||
}
|
||||
|
||||
/// Get documents for a specific residence
|
||||
func documents(for residenceId: Int32) -> [Document] {
|
||||
return documentsByResidence[residenceId] ?? []
|
||||
}
|
||||
|
||||
/// Get residence summary
|
||||
func summary(for residenceId: Int32) -> ResidenceSummaryResponse? {
|
||||
return residenceSummaries[residenceId]
|
||||
}
|
||||
|
||||
/// Total task count across all columns
|
||||
var totalTaskCount: Int {
|
||||
guard let response = allTasks else { return 0 }
|
||||
return response.columns.reduce(0) { $0 + Int($1.count) }
|
||||
}
|
||||
|
||||
/// Check if there are no tasks
|
||||
var hasNoTasks: Bool {
|
||||
guard let response = allTasks else { return true }
|
||||
return response.columns.allSatisfy { $0.tasks.isEmpty }
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ class AuthenticationManager: ObservableObject {
|
||||
func checkAuthenticationStatus() {
|
||||
isCheckingAuth = true
|
||||
|
||||
// Check if token exists
|
||||
guard let token = TokenStorage.shared.getToken(), !token.isEmpty else {
|
||||
// Check if token exists via DataManager (single source of truth)
|
||||
guard DataManager.shared.isAuthenticated() else {
|
||||
isAuthenticated = false
|
||||
isVerified = false
|
||||
isCheckingAuth = false
|
||||
@@ -45,15 +45,15 @@ class AuthenticationManager: ObservableObject {
|
||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||
}
|
||||
} else if result is ApiResultError {
|
||||
// Token is invalid, clear it
|
||||
TokenStorage.shared.clearToken()
|
||||
// Token is invalid, clear all data via DataManager
|
||||
DataManager.shared.clear()
|
||||
self.isAuthenticated = false
|
||||
self.isVerified = false
|
||||
}
|
||||
} catch {
|
||||
print("❌ Failed to check auth status: \(error)")
|
||||
// On error, assume token is invalid
|
||||
TokenStorage.shared.clearToken()
|
||||
DataManager.shared.clear()
|
||||
self.isAuthenticated = false
|
||||
self.isVerified = false
|
||||
}
|
||||
@@ -85,18 +85,9 @@ class AuthenticationManager: ObservableObject {
|
||||
}
|
||||
|
||||
func logout() {
|
||||
// Call shared ViewModel logout
|
||||
// Call shared ViewModel logout which clears DataManager
|
||||
sharedViewModel.logout()
|
||||
|
||||
// Clear token from storage
|
||||
TokenStorage.shared.clearToken()
|
||||
|
||||
// Clear lookups data on logout via DataCache
|
||||
DataCache.shared.clearLookups()
|
||||
|
||||
// Clear all cached data
|
||||
DataCache.shared.clearAll()
|
||||
|
||||
// Clear widget task data
|
||||
WidgetDataManager.shared.clearCache()
|
||||
|
||||
|
||||
@@ -252,7 +252,7 @@ class TaskViewModel: ObservableObject {
|
||||
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
|
||||
/// - forceRefresh: Whether to bypass cache
|
||||
func loadTasks(residenceId: Int32? = nil, forceRefresh: Bool = false) {
|
||||
guard TokenStorage.shared.getToken() != nil else { return }
|
||||
guard DataManager.shared.isAuthenticated() else { return }
|
||||
|
||||
currentResidenceId = residenceId
|
||||
isLoadingTasks = true
|
||||
|
||||
@@ -8,8 +8,16 @@ struct iOSApp: App {
|
||||
@State private var deepLinkResetToken: String?
|
||||
|
||||
init() {
|
||||
// Initialize TokenStorage once at app startup
|
||||
TokenStorage.shared.initialize(manager: TokenManager())
|
||||
// Initialize DataManager with platform-specific managers
|
||||
// This must be done before any other operations that access DataManager
|
||||
DataManager.shared.initialize(
|
||||
tokenMgr: TokenManager.Companion.shared.getInstance(),
|
||||
themeMgr: ThemeStorageManager.Companion.shared.getInstance(),
|
||||
persistenceMgr: PersistenceManager()
|
||||
)
|
||||
|
||||
// Initialize TokenStorage once at app startup (legacy support)
|
||||
TokenStorage.shared.initialize(manager: TokenManager.Companion.shared.getInstance())
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
Reference in New Issue
Block a user