- 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>
504 lines
16 KiB
Swift
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
|
|
}
|
|
}
|