Files
honeyDueKMP/iosApp/iosApp/Data/DataManagerObservable.swift
Trey t 4a04aff1e6 Replace status_id with in_progress boolean across mobile apps
- Remove TaskStatus model and status_id foreign key references
- Add in_progress boolean field to task models and forms
- Update TaskApi to use dedicated POST endpoints for task actions:
  - POST /tasks/:id/cancel/ instead of PATCH with is_cancelled
  - POST /tasks/:id/uncancel/
  - POST /tasks/:id/archive/
  - POST /tasks/:id/unarchive/
- Fix iOS TaskViewModel to use error-first pattern for Kotlin-Swift
  generic type bridging issues
- Update iOS callback signatures to pass full TaskResponse instead
  of just taskId to avoid stale closure lookups
- Add in_progress localization strings
- Update widget preview data to use inProgress boolean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 20:47:59 -06:00

504 lines
16 KiB
Swift

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 totalSummary: TotalSummary?
@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: [ContractorSummary] = []
// 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 taskCategories: [TaskCategory] = []
@Published var contractorSpecialties: [ContractorSpecialty] = []
// MARK: - Task Templates
@Published var taskTemplates: [TaskTemplate] = []
@Published var taskTemplatesGrouped: TaskTemplatesGroupedResponse?
// 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
// Clear widget cache on logout
if token == nil {
WidgetDataManager.shared.clearCache()
}
}
}
}
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)
// TotalSummary
let totalSummaryTask = Task {
for await summary in DataManager.shared.totalSummary {
await MainActor.run {
self.totalSummary = summary
}
}
}
observationTasks.append(totalSummaryTask)
// 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
// Save to widget shared container
if let tasks = tasks {
WidgetDataManager.shared.saveTasks(from: 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 - 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)
// Task Templates
let taskTemplatesTask = Task {
for await items in DataManager.shared.taskTemplates {
await MainActor.run {
self.taskTemplates = items
}
}
}
observationTasks.append(taskTemplatesTask)
// Task Templates Grouped
let taskTemplatesGroupedTask = Task {
for await response in DataManager.shared.taskTemplatesGrouped {
await MainActor.run {
self.taskTemplatesGrouped = response
}
}
}
observationTasks.append(taskTemplatesGroupedTask)
// 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]
/// Uses ObjectIdentifier-based iteration to avoid Swift bridging issues with KotlinInt keys
private func convertIntMap<V>(_ kotlinMap: Any?) -> [Int32: V] {
guard let kotlinMap = kotlinMap else {
return [:]
}
var result: [Int32: V] = [:]
// Cast to NSDictionary to avoid Swift's strict type bridging
// which can crash when iterating [KotlinInt: V] dictionaries
let nsDict = kotlinMap as! NSDictionary
for key in nsDict.allKeys {
guard let value = nsDict[key], let typedValue = value as? V else { continue }
// Extract the int value from whatever key type we have
if let kotlinKey = key as? KotlinInt {
result[kotlinKey.int32Value] = typedValue
} else if let nsNumberKey = key as? NSNumber {
result[nsNumberKey.int32Value] = typedValue
}
}
return result
}
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
private func convertIntArrayMap<V>(_ kotlinMap: Any?) -> [Int32: [V]] {
guard let kotlinMap = kotlinMap else {
return [:]
}
var result: [Int32: [V]] = [:]
let nsDict = kotlinMap as! NSDictionary
for key in nsDict.allKeys {
guard let value = nsDict[key], let typedValue = value as? [V] else { continue }
if let kotlinKey = key as? KotlinInt {
result[kotlinKey.int32Value] = typedValue
} else if let nsNumberKey = key as? NSNumber {
result[nsNumberKey.int32Value] = typedValue
}
}
return result
}
/// Convert Kotlin Map<String, V> to Swift [String: V]
private func convertStringMap<V>(_ kotlinMap: Any?) -> [String: V] {
guard let map = kotlinMap as? [String: V] else {
return [:]
}
return map
}
// 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 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 }
}
// MARK: - Task Template Helpers
/// Search task templates by query string
func searchTaskTemplates(query: String) -> [TaskTemplate] {
return DataManager.shared.searchTaskTemplates(query: query)
}
/// Get total task template count
var taskTemplateCount: Int {
return taskTemplates.count
}
}