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:
Trey t
2025-12-03 00:21:24 -06:00
parent b79fda8aee
commit cf0cd1cda2
17 changed files with 1721 additions and 489 deletions

View 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 }
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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 {