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:
@@ -0,0 +1,43 @@
|
|||||||
|
package com.example.casera.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android implementation of PersistenceManager using SharedPreferences.
|
||||||
|
*/
|
||||||
|
actual class PersistenceManager(context: Context) {
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences(
|
||||||
|
PREFS_NAME,
|
||||||
|
Context.MODE_PRIVATE
|
||||||
|
)
|
||||||
|
|
||||||
|
actual fun save(key: String, value: String) {
|
||||||
|
prefs.edit().putString(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun load(key: String): String? {
|
||||||
|
return prefs.getString(key, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun remove(key: String) {
|
||||||
|
prefs.edit().remove(key).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PREFS_NAME = "casera_data_manager"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: PersistenceManager? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): PersistenceManager {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
instance ?: PersistenceManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,8 @@ import com.example.casera.models.TaskPriority
|
|||||||
import com.example.casera.models.TaskStatus
|
import com.example.casera.models.TaskStatus
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.AuthApi
|
import com.example.casera.network.AuthApi
|
||||||
import com.example.casera.storage.TokenStorage
|
import com.example.casera.data.DataManager
|
||||||
|
import com.example.casera.network.APILayer
|
||||||
|
|
||||||
import casera.composeapp.generated.resources.Res
|
import casera.composeapp.generated.resources.Res
|
||||||
import casera.composeapp.generated.resources.compose_multiplatform
|
import casera.composeapp.generated.resources.compose_multiplatform
|
||||||
@@ -64,32 +65,27 @@ fun App(
|
|||||||
deepLinkResetToken: String? = null,
|
deepLinkResetToken: String? = null,
|
||||||
onClearDeepLinkToken: () -> Unit = {}
|
onClearDeepLinkToken: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
var isLoggedIn by remember { mutableStateOf(TokenStorage.hasToken()) }
|
var isLoggedIn by remember { mutableStateOf(DataManager.authToken.value != null) }
|
||||||
var isVerified by remember { mutableStateOf(false) }
|
var isVerified by remember { mutableStateOf(false) }
|
||||||
var isCheckingAuth by remember { mutableStateOf(true) }
|
var isCheckingAuth by remember { mutableStateOf(true) }
|
||||||
val navController = rememberNavController()
|
val navController = rememberNavController()
|
||||||
|
|
||||||
// Check for stored token and verification status on app start
|
// Check for stored token and verification status on app start
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
val hasToken = TokenStorage.hasToken()
|
val hasToken = DataManager.authToken.value != null
|
||||||
isLoggedIn = hasToken
|
isLoggedIn = hasToken
|
||||||
|
|
||||||
if (hasToken) {
|
if (hasToken) {
|
||||||
// Fetch current user to check verification status
|
// Fetch current user to check verification status
|
||||||
val authApi = AuthApi()
|
when (val result = APILayer.getCurrentUser(forceRefresh = true)) {
|
||||||
val token = TokenStorage.getToken()
|
is ApiResult.Success -> {
|
||||||
|
isVerified = result.data.verified
|
||||||
if (token != null) {
|
APILayer.initializeLookups()
|
||||||
when (val result = authApi.getCurrentUser(token)) {
|
}
|
||||||
is ApiResult.Success -> {
|
else -> {
|
||||||
isVerified = result.data.verified
|
// If fetching user fails, clear DataManager and logout
|
||||||
LookupsRepository.initialize()
|
DataManager.clear()
|
||||||
}
|
isLoggedIn = false
|
||||||
else -> {
|
|
||||||
// If fetching user fails, clear token and logout
|
|
||||||
TokenStorage.clearToken()
|
|
||||||
isLoggedIn = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -251,8 +247,7 @@ fun App(
|
|||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
// Clear token and lookups on logout
|
// Clear token and lookups on logout
|
||||||
TokenStorage.clearToken()
|
DataManager.clear()
|
||||||
LookupsRepository.clear()
|
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
navController.navigate(LoginRoute) {
|
navController.navigate(LoginRoute) {
|
||||||
@@ -266,8 +261,7 @@ fun App(
|
|||||||
MainScreen(
|
MainScreen(
|
||||||
onLogout = {
|
onLogout = {
|
||||||
// Clear token and lookups on logout
|
// Clear token and lookups on logout
|
||||||
TokenStorage.clearToken()
|
DataManager.clear()
|
||||||
LookupsRepository.clear()
|
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
navController.navigate(LoginRoute) {
|
navController.navigate(LoginRoute) {
|
||||||
@@ -346,8 +340,7 @@ fun App(
|
|||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
// Clear token and lookups on logout
|
// Clear token and lookups on logout
|
||||||
TokenStorage.clearToken()
|
DataManager.clear()
|
||||||
LookupsRepository.clear()
|
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
navController.navigate(LoginRoute) {
|
navController.navigate(LoginRoute) {
|
||||||
@@ -374,8 +367,7 @@ fun App(
|
|||||||
shouldRefresh = shouldRefresh,
|
shouldRefresh = shouldRefresh,
|
||||||
onLogout = {
|
onLogout = {
|
||||||
// Clear token and lookups on logout
|
// Clear token and lookups on logout
|
||||||
TokenStorage.clearToken()
|
DataManager.clear()
|
||||||
LookupsRepository.clear()
|
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
navController.navigate(LoginRoute) {
|
navController.navigate(LoginRoute) {
|
||||||
@@ -541,8 +533,7 @@ fun App(
|
|||||||
},
|
},
|
||||||
onLogout = {
|
onLogout = {
|
||||||
// Clear token and lookups on logout
|
// Clear token and lookups on logout
|
||||||
TokenStorage.clearToken()
|
DataManager.clear()
|
||||||
LookupsRepository.clear()
|
|
||||||
isLoggedIn = false
|
isLoggedIn = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
navController.navigate(LoginRoute) {
|
navController.navigate(LoginRoute) {
|
||||||
|
|||||||
@@ -0,0 +1,707 @@
|
|||||||
|
package com.example.casera.data
|
||||||
|
|
||||||
|
import com.example.casera.models.*
|
||||||
|
import com.example.casera.storage.TokenManager
|
||||||
|
import com.example.casera.storage.ThemeStorageManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlin.time.Clock
|
||||||
|
import kotlin.time.ExperimentalTime
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified DataManager - Single Source of Truth for all app data.
|
||||||
|
*
|
||||||
|
* Core Principles:
|
||||||
|
* 1. All data is cached here - no other caches exist
|
||||||
|
* 2. Every API response updates DataManager immediately
|
||||||
|
* 3. All screens observe DataManager StateFlows directly
|
||||||
|
* 4. All data is persisted to disk for offline access
|
||||||
|
* 5. Includes auth token and theme preferences
|
||||||
|
*
|
||||||
|
* Data Flow:
|
||||||
|
* User Action → API Call → Server Response → DataManager Updated → All Screens React
|
||||||
|
*/
|
||||||
|
object DataManager {
|
||||||
|
|
||||||
|
// Platform-specific persistence managers (initialized at app start)
|
||||||
|
private var tokenManager: TokenManager? = null
|
||||||
|
private var themeManager: ThemeStorageManager? = null
|
||||||
|
private var persistenceManager: PersistenceManager? = null
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AUTHENTICATION ====================
|
||||||
|
|
||||||
|
private val _authToken = MutableStateFlow<String?>(null)
|
||||||
|
val authToken: StateFlow<String?> = _authToken.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentUser = MutableStateFlow<User?>(null)
|
||||||
|
val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== APP PREFERENCES ====================
|
||||||
|
|
||||||
|
private val _themeId = MutableStateFlow("default")
|
||||||
|
val themeId: StateFlow<String> = _themeId.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== RESIDENCES ====================
|
||||||
|
|
||||||
|
private val _residences = MutableStateFlow<List<Residence>>(emptyList())
|
||||||
|
val residences: StateFlow<List<Residence>> = _residences.asStateFlow()
|
||||||
|
|
||||||
|
private val _myResidences = MutableStateFlow<MyResidencesResponse?>(null)
|
||||||
|
val myResidences: StateFlow<MyResidencesResponse?> = _myResidences.asStateFlow()
|
||||||
|
|
||||||
|
private val _residenceSummaries = MutableStateFlow<Map<Int, ResidenceSummaryResponse>>(emptyMap())
|
||||||
|
val residenceSummaries: StateFlow<Map<Int, ResidenceSummaryResponse>> = _residenceSummaries.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== TASKS ====================
|
||||||
|
|
||||||
|
private val _allTasks = MutableStateFlow<TaskColumnsResponse?>(null)
|
||||||
|
val allTasks: StateFlow<TaskColumnsResponse?> = _allTasks.asStateFlow()
|
||||||
|
|
||||||
|
private val _tasksByResidence = MutableStateFlow<Map<Int, TaskColumnsResponse>>(emptyMap())
|
||||||
|
val tasksByResidence: StateFlow<Map<Int, TaskColumnsResponse>> = _tasksByResidence.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
|
private val _documents = MutableStateFlow<List<Document>>(emptyList())
|
||||||
|
val documents: StateFlow<List<Document>> = _documents.asStateFlow()
|
||||||
|
|
||||||
|
private val _documentsByResidence = MutableStateFlow<Map<Int, List<Document>>>(emptyMap())
|
||||||
|
val documentsByResidence: StateFlow<Map<Int, List<Document>>> = _documentsByResidence.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== CONTRACTORS ====================
|
||||||
|
|
||||||
|
private val _contractors = MutableStateFlow<List<Contractor>>(emptyList())
|
||||||
|
val contractors: StateFlow<List<Contractor>> = _contractors.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== SUBSCRIPTION ====================
|
||||||
|
|
||||||
|
private val _subscription = MutableStateFlow<SubscriptionStatus?>(null)
|
||||||
|
val subscription: StateFlow<SubscriptionStatus?> = _subscription.asStateFlow()
|
||||||
|
|
||||||
|
private val _upgradeTriggers = MutableStateFlow<Map<String, UpgradeTriggerData>>(emptyMap())
|
||||||
|
val upgradeTriggers: StateFlow<Map<String, UpgradeTriggerData>> = _upgradeTriggers.asStateFlow()
|
||||||
|
|
||||||
|
private val _featureBenefits = MutableStateFlow<List<FeatureBenefit>>(emptyList())
|
||||||
|
val featureBenefits: StateFlow<List<FeatureBenefit>> = _featureBenefits.asStateFlow()
|
||||||
|
|
||||||
|
private val _promotions = MutableStateFlow<List<Promotion>>(emptyList())
|
||||||
|
val promotions: StateFlow<List<Promotion>> = _promotions.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== LOOKUPS (Reference Data) ====================
|
||||||
|
|
||||||
|
// List-based for dropdowns/pickers
|
||||||
|
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
|
||||||
|
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
|
||||||
|
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
|
||||||
|
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
|
||||||
|
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
||||||
|
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories.asStateFlow()
|
||||||
|
|
||||||
|
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
|
||||||
|
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties.asStateFlow()
|
||||||
|
|
||||||
|
// Map-based for O(1) ID resolution
|
||||||
|
private val _residenceTypesMap = MutableStateFlow<Map<Int, ResidenceType>>(emptyMap())
|
||||||
|
val residenceTypesMap: StateFlow<Map<Int, ResidenceType>> = _residenceTypesMap.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskFrequenciesMap = MutableStateFlow<Map<Int, TaskFrequency>>(emptyMap())
|
||||||
|
val taskFrequenciesMap: StateFlow<Map<Int, TaskFrequency>> = _taskFrequenciesMap.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskPrioritiesMap = MutableStateFlow<Map<Int, TaskPriority>>(emptyMap())
|
||||||
|
val taskPrioritiesMap: StateFlow<Map<Int, TaskPriority>> = _taskPrioritiesMap.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskStatusesMap = MutableStateFlow<Map<Int, TaskStatus>>(emptyMap())
|
||||||
|
val taskStatusesMap: StateFlow<Map<Int, TaskStatus>> = _taskStatusesMap.asStateFlow()
|
||||||
|
|
||||||
|
private val _taskCategoriesMap = MutableStateFlow<Map<Int, TaskCategory>>(emptyMap())
|
||||||
|
val taskCategoriesMap: StateFlow<Map<Int, TaskCategory>> = _taskCategoriesMap.asStateFlow()
|
||||||
|
|
||||||
|
private val _contractorSpecialtiesMap = MutableStateFlow<Map<Int, ContractorSpecialty>>(emptyMap())
|
||||||
|
val contractorSpecialtiesMap: StateFlow<Map<Int, ContractorSpecialty>> = _contractorSpecialtiesMap.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== STATE METADATA ====================
|
||||||
|
|
||||||
|
private val _isInitialized = MutableStateFlow(false)
|
||||||
|
val isInitialized: StateFlow<Boolean> = _isInitialized.asStateFlow()
|
||||||
|
|
||||||
|
private val _lookupsInitialized = MutableStateFlow(false)
|
||||||
|
val lookupsInitialized: StateFlow<Boolean> = _lookupsInitialized.asStateFlow()
|
||||||
|
|
||||||
|
private val _lastSyncTime = MutableStateFlow(0L)
|
||||||
|
val lastSyncTime: StateFlow<Long> = _lastSyncTime.asStateFlow()
|
||||||
|
|
||||||
|
// ==================== INITIALIZATION ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize DataManager with platform-specific managers.
|
||||||
|
* Call this once at app startup.
|
||||||
|
*/
|
||||||
|
fun initialize(
|
||||||
|
tokenMgr: TokenManager,
|
||||||
|
themeMgr: ThemeStorageManager,
|
||||||
|
persistenceMgr: PersistenceManager
|
||||||
|
) {
|
||||||
|
tokenManager = tokenMgr
|
||||||
|
themeManager = themeMgr
|
||||||
|
persistenceManager = persistenceMgr
|
||||||
|
|
||||||
|
// Load auth token from secure storage
|
||||||
|
_authToken.value = tokenMgr.getToken()
|
||||||
|
|
||||||
|
// Load theme preference
|
||||||
|
_themeId.value = themeMgr.getThemeId() ?: "default"
|
||||||
|
|
||||||
|
// Load cached data from disk
|
||||||
|
loadFromDisk()
|
||||||
|
|
||||||
|
_isInitialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user is authenticated (has valid token)
|
||||||
|
*/
|
||||||
|
fun isAuthenticated(): Boolean = _authToken.value != null
|
||||||
|
|
||||||
|
// ==================== O(1) LOOKUP HELPERS ====================
|
||||||
|
|
||||||
|
fun getResidenceType(id: Int?): ResidenceType? = id?.let { _residenceTypesMap.value[it] }
|
||||||
|
fun getTaskFrequency(id: Int?): TaskFrequency? = id?.let { _taskFrequenciesMap.value[it] }
|
||||||
|
fun getTaskPriority(id: Int?): TaskPriority? = id?.let { _taskPrioritiesMap.value[it] }
|
||||||
|
fun getTaskStatus(id: Int?): TaskStatus? = id?.let { _taskStatusesMap.value[it] }
|
||||||
|
fun getTaskCategory(id: Int?): TaskCategory? = id?.let { _taskCategoriesMap.value[it] }
|
||||||
|
fun getContractorSpecialty(id: Int?): ContractorSpecialty? = id?.let { _contractorSpecialtiesMap.value[it] }
|
||||||
|
|
||||||
|
// ==================== AUTH UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setAuthToken(token: String?) {
|
||||||
|
_authToken.value = token
|
||||||
|
if (token != null) {
|
||||||
|
tokenManager?.saveToken(token)
|
||||||
|
} else {
|
||||||
|
tokenManager?.clearToken()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setCurrentUser(user: User?) {
|
||||||
|
_currentUser.value = user
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== THEME UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setThemeId(id: String) {
|
||||||
|
_themeId.value = id
|
||||||
|
themeManager?.saveThemeId(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RESIDENCE UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setResidences(residences: List<Residence>) {
|
||||||
|
_residences.value = residences
|
||||||
|
updateLastSyncTime()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setMyResidences(response: MyResidencesResponse) {
|
||||||
|
_myResidences.value = response
|
||||||
|
updateLastSyncTime()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setResidenceSummary(residenceId: Int, summary: ResidenceSummaryResponse) {
|
||||||
|
_residenceSummaries.value = _residenceSummaries.value + (residenceId to summary)
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addResidence(residence: Residence) {
|
||||||
|
_residences.value = _residences.value + residence
|
||||||
|
updateLastSyncTime()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateResidence(residence: Residence) {
|
||||||
|
_residences.value = _residences.value.map {
|
||||||
|
if (it.id == residence.id) residence else it
|
||||||
|
}
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeResidence(residenceId: Int) {
|
||||||
|
_residences.value = _residences.value.filter { it.id != residenceId }
|
||||||
|
_tasksByResidence.value = _tasksByResidence.value - residenceId
|
||||||
|
_documentsByResidence.value = _documentsByResidence.value - residenceId
|
||||||
|
_residenceSummaries.value = _residenceSummaries.value - residenceId
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== TASK UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setAllTasks(response: TaskColumnsResponse) {
|
||||||
|
_allTasks.value = response
|
||||||
|
updateLastSyncTime()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTasksForResidence(residenceId: Int, response: TaskColumnsResponse) {
|
||||||
|
_tasksByResidence.value = _tasksByResidence.value + (residenceId to response)
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single task - moves it to the correct kanban column based on kanban_column field.
|
||||||
|
* This is called after task completion, status change, etc.
|
||||||
|
*/
|
||||||
|
fun updateTask(task: TaskResponse) {
|
||||||
|
// Update in allTasks
|
||||||
|
_allTasks.value?.let { current ->
|
||||||
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
|
val newColumns = current.columns.map { column ->
|
||||||
|
// Remove task from this column if present
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||||
|
// Add task if this is the target column
|
||||||
|
val updatedTasks = if (column.name == targetColumn) {
|
||||||
|
filteredTasks + task
|
||||||
|
} else {
|
||||||
|
filteredTasks
|
||||||
|
}
|
||||||
|
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||||
|
}
|
||||||
|
_allTasks.value = current.copy(columns = newColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update in tasksByResidence if this task's residence is cached
|
||||||
|
task.residenceId?.let { residenceId ->
|
||||||
|
_tasksByResidence.value[residenceId]?.let { current ->
|
||||||
|
val targetColumn = task.kanbanColumn ?: "upcoming_tasks"
|
||||||
|
val newColumns = current.columns.map { column ->
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != task.id }
|
||||||
|
val updatedTasks = if (column.name == targetColumn) {
|
||||||
|
filteredTasks + task
|
||||||
|
} else {
|
||||||
|
filteredTasks
|
||||||
|
}
|
||||||
|
column.copy(tasks = updatedTasks, count = updatedTasks.size)
|
||||||
|
}
|
||||||
|
_tasksByResidence.value = _tasksByResidence.value + (residenceId to current.copy(columns = newColumns))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeTask(taskId: Int) {
|
||||||
|
// Remove from allTasks
|
||||||
|
_allTasks.value?.let { current ->
|
||||||
|
val newColumns = current.columns.map { column ->
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != taskId }
|
||||||
|
column.copy(tasks = filteredTasks, count = filteredTasks.size)
|
||||||
|
}
|
||||||
|
_allTasks.value = current.copy(columns = newColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from all residence task caches
|
||||||
|
_tasksByResidence.value = _tasksByResidence.value.mapValues { (_, tasks) ->
|
||||||
|
val newColumns = tasks.columns.map { column ->
|
||||||
|
val filteredTasks = column.tasks.filter { it.id != taskId }
|
||||||
|
column.copy(tasks = filteredTasks, count = filteredTasks.size)
|
||||||
|
}
|
||||||
|
tasks.copy(columns = newColumns)
|
||||||
|
}
|
||||||
|
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DOCUMENT UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setDocuments(documents: List<Document>) {
|
||||||
|
_documents.value = documents
|
||||||
|
updateLastSyncTime()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDocumentsForResidence(residenceId: Int, documents: List<Document>) {
|
||||||
|
_documentsByResidence.value = _documentsByResidence.value + (residenceId to documents)
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDocument(document: Document) {
|
||||||
|
_documents.value = _documents.value + document
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateDocument(document: Document) {
|
||||||
|
_documents.value = _documents.value.map {
|
||||||
|
if (it.id == document.id) document else it
|
||||||
|
}
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDocument(documentId: Int) {
|
||||||
|
_documents.value = _documents.value.filter { it.id != documentId }
|
||||||
|
// Also remove from residence-specific caches
|
||||||
|
_documentsByResidence.value = _documentsByResidence.value.mapValues { (_, docs) ->
|
||||||
|
docs.filter { it.id != documentId }
|
||||||
|
}
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CONTRACTOR UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setContractors(contractors: List<Contractor>) {
|
||||||
|
_contractors.value = contractors
|
||||||
|
updateLastSyncTime()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContractor(contractor: Contractor) {
|
||||||
|
_contractors.value = _contractors.value + contractor
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateContractor(contractor: Contractor) {
|
||||||
|
_contractors.value = _contractors.value.map {
|
||||||
|
if (it.id == contractor.id) contractor else it
|
||||||
|
}
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeContractor(contractorId: Int) {
|
||||||
|
_contractors.value = _contractors.value.filter { it.id != contractorId }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== SUBSCRIPTION UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setSubscription(subscription: SubscriptionStatus) {
|
||||||
|
_subscription.value = subscription
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setUpgradeTriggers(triggers: Map<String, UpgradeTriggerData>) {
|
||||||
|
_upgradeTriggers.value = triggers
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFeatureBenefits(benefits: List<FeatureBenefit>) {
|
||||||
|
_featureBenefits.value = benefits
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPromotions(promos: List<Promotion>) {
|
||||||
|
_promotions.value = promos
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== LOOKUP UPDATE METHODS ====================
|
||||||
|
|
||||||
|
fun setResidenceTypes(types: List<ResidenceType>) {
|
||||||
|
_residenceTypes.value = types
|
||||||
|
_residenceTypesMap.value = types.associateBy { it.id }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTaskFrequencies(frequencies: List<TaskFrequency>) {
|
||||||
|
_taskFrequencies.value = frequencies
|
||||||
|
_taskFrequenciesMap.value = frequencies.associateBy { it.id }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTaskPriorities(priorities: List<TaskPriority>) {
|
||||||
|
_taskPriorities.value = priorities
|
||||||
|
_taskPrioritiesMap.value = priorities.associateBy { it.id }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTaskStatuses(statuses: List<TaskStatus>) {
|
||||||
|
_taskStatuses.value = statuses
|
||||||
|
_taskStatusesMap.value = statuses.associateBy { it.id }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setTaskCategories(categories: List<TaskCategory>) {
|
||||||
|
_taskCategories.value = categories
|
||||||
|
_taskCategoriesMap.value = categories.associateBy { it.id }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setContractorSpecialties(specialties: List<ContractorSpecialty>) {
|
||||||
|
_contractorSpecialties.value = specialties
|
||||||
|
_contractorSpecialtiesMap.value = specialties.associateBy { it.id }
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAllLookups(staticData: StaticDataResponse) {
|
||||||
|
setResidenceTypes(staticData.residenceTypes)
|
||||||
|
setTaskFrequencies(staticData.taskFrequencies)
|
||||||
|
setTaskPriorities(staticData.taskPriorities)
|
||||||
|
setTaskStatuses(staticData.taskStatuses)
|
||||||
|
setTaskCategories(staticData.taskCategories)
|
||||||
|
setContractorSpecialties(staticData.contractorSpecialties)
|
||||||
|
_lookupsInitialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markLookupsInitialized() {
|
||||||
|
_lookupsInitialized.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CLEAR METHODS ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all data - called on logout
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
// Clear auth
|
||||||
|
_authToken.value = null
|
||||||
|
_currentUser.value = null
|
||||||
|
tokenManager?.clearToken()
|
||||||
|
|
||||||
|
// Clear user data
|
||||||
|
_residences.value = emptyList()
|
||||||
|
_myResidences.value = null
|
||||||
|
_residenceSummaries.value = emptyMap()
|
||||||
|
_allTasks.value = null
|
||||||
|
_tasksByResidence.value = emptyMap()
|
||||||
|
_documents.value = emptyList()
|
||||||
|
_documentsByResidence.value = emptyMap()
|
||||||
|
_contractors.value = emptyList()
|
||||||
|
|
||||||
|
// Clear subscription
|
||||||
|
_subscription.value = null
|
||||||
|
_upgradeTriggers.value = emptyMap()
|
||||||
|
_featureBenefits.value = emptyList()
|
||||||
|
_promotions.value = emptyList()
|
||||||
|
|
||||||
|
// Clear lookups
|
||||||
|
_residenceTypes.value = emptyList()
|
||||||
|
_residenceTypesMap.value = emptyMap()
|
||||||
|
_taskFrequencies.value = emptyList()
|
||||||
|
_taskFrequenciesMap.value = emptyMap()
|
||||||
|
_taskPriorities.value = emptyList()
|
||||||
|
_taskPrioritiesMap.value = emptyMap()
|
||||||
|
_taskStatuses.value = emptyList()
|
||||||
|
_taskStatusesMap.value = emptyMap()
|
||||||
|
_taskCategories.value = emptyList()
|
||||||
|
_taskCategoriesMap.value = emptyMap()
|
||||||
|
_contractorSpecialties.value = emptyList()
|
||||||
|
_contractorSpecialtiesMap.value = emptyMap()
|
||||||
|
_lookupsInitialized.value = false
|
||||||
|
|
||||||
|
// Clear metadata
|
||||||
|
_lastSyncTime.value = 0L
|
||||||
|
|
||||||
|
// Clear persistent storage (except theme)
|
||||||
|
persistenceManager?.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear only user-specific data (keep lookups and preferences)
|
||||||
|
*/
|
||||||
|
fun clearUserData() {
|
||||||
|
_currentUser.value = null
|
||||||
|
_residences.value = emptyList()
|
||||||
|
_myResidences.value = null
|
||||||
|
_residenceSummaries.value = emptyMap()
|
||||||
|
_allTasks.value = null
|
||||||
|
_tasksByResidence.value = emptyMap()
|
||||||
|
_documents.value = emptyList()
|
||||||
|
_documentsByResidence.value = emptyMap()
|
||||||
|
_contractors.value = emptyList()
|
||||||
|
_subscription.value = null
|
||||||
|
_upgradeTriggers.value = emptyMap()
|
||||||
|
_featureBenefits.value = emptyList()
|
||||||
|
_promotions.value = emptyList()
|
||||||
|
persistToDisk()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PERSISTENCE ====================
|
||||||
|
|
||||||
|
@OptIn(ExperimentalTime::class)
|
||||||
|
private fun updateLastSyncTime() {
|
||||||
|
_lastSyncTime.value = Clock.System.now().toEpochMilliseconds()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist current state to disk.
|
||||||
|
* Called automatically after each update.
|
||||||
|
*/
|
||||||
|
private fun persistToDisk() {
|
||||||
|
val manager = persistenceManager ?: return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Persist each data type
|
||||||
|
_currentUser.value?.let {
|
||||||
|
manager.save(KEY_CURRENT_USER, json.encodeToString(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_residences.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_RESIDENCES, json.encodeToString(_residences.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
_myResidences.value?.let {
|
||||||
|
manager.save(KEY_MY_RESIDENCES, json.encodeToString(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
_allTasks.value?.let {
|
||||||
|
manager.save(KEY_ALL_TASKS, json.encodeToString(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_documents.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_DOCUMENTS, json.encodeToString(_documents.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_contractors.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_CONTRACTORS, json.encodeToString(_contractors.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
_subscription.value?.let {
|
||||||
|
manager.save(KEY_SUBSCRIPTION, json.encodeToString(it))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist lookups
|
||||||
|
if (_residenceTypes.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_RESIDENCE_TYPES, json.encodeToString(_residenceTypes.value))
|
||||||
|
}
|
||||||
|
if (_taskFrequencies.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_TASK_FREQUENCIES, json.encodeToString(_taskFrequencies.value))
|
||||||
|
}
|
||||||
|
if (_taskPriorities.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_TASK_PRIORITIES, json.encodeToString(_taskPriorities.value))
|
||||||
|
}
|
||||||
|
if (_taskStatuses.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_TASK_STATUSES, json.encodeToString(_taskStatuses.value))
|
||||||
|
}
|
||||||
|
if (_taskCategories.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_TASK_CATEGORIES, json.encodeToString(_taskCategories.value))
|
||||||
|
}
|
||||||
|
if (_contractorSpecialties.value.isNotEmpty()) {
|
||||||
|
manager.save(KEY_CONTRACTOR_SPECIALTIES, json.encodeToString(_contractorSpecialties.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.save(KEY_LAST_SYNC_TIME, _lastSyncTime.value.toString())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but don't crash - persistence is best-effort
|
||||||
|
println("DataManager: Error persisting to disk: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load cached state from disk.
|
||||||
|
* Called during initialization.
|
||||||
|
*/
|
||||||
|
private fun loadFromDisk() {
|
||||||
|
val manager = persistenceManager ?: return
|
||||||
|
|
||||||
|
try {
|
||||||
|
manager.load(KEY_CURRENT_USER)?.let { data ->
|
||||||
|
_currentUser.value = json.decodeFromString<User>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_RESIDENCES)?.let { data ->
|
||||||
|
_residences.value = json.decodeFromString<List<Residence>>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_MY_RESIDENCES)?.let { data ->
|
||||||
|
_myResidences.value = json.decodeFromString<MyResidencesResponse>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_ALL_TASKS)?.let { data ->
|
||||||
|
_allTasks.value = json.decodeFromString<TaskColumnsResponse>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_DOCUMENTS)?.let { data ->
|
||||||
|
_documents.value = json.decodeFromString<List<Document>>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_CONTRACTORS)?.let { data ->
|
||||||
|
_contractors.value = json.decodeFromString<List<Contractor>>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_SUBSCRIPTION)?.let { data ->
|
||||||
|
_subscription.value = json.decodeFromString<SubscriptionStatus>(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load lookups
|
||||||
|
manager.load(KEY_RESIDENCE_TYPES)?.let { data ->
|
||||||
|
val types = json.decodeFromString<List<ResidenceType>>(data)
|
||||||
|
_residenceTypes.value = types
|
||||||
|
_residenceTypesMap.value = types.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_TASK_FREQUENCIES)?.let { data ->
|
||||||
|
val items = json.decodeFromString<List<TaskFrequency>>(data)
|
||||||
|
_taskFrequencies.value = items
|
||||||
|
_taskFrequenciesMap.value = items.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_TASK_PRIORITIES)?.let { data ->
|
||||||
|
val items = json.decodeFromString<List<TaskPriority>>(data)
|
||||||
|
_taskPriorities.value = items
|
||||||
|
_taskPrioritiesMap.value = items.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_TASK_STATUSES)?.let { data ->
|
||||||
|
val items = json.decodeFromString<List<TaskStatus>>(data)
|
||||||
|
_taskStatuses.value = items
|
||||||
|
_taskStatusesMap.value = items.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_TASK_CATEGORIES)?.let { data ->
|
||||||
|
val items = json.decodeFromString<List<TaskCategory>>(data)
|
||||||
|
_taskCategories.value = items
|
||||||
|
_taskCategoriesMap.value = items.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_CONTRACTOR_SPECIALTIES)?.let { data ->
|
||||||
|
val items = json.decodeFromString<List<ContractorSpecialty>>(data)
|
||||||
|
_contractorSpecialties.value = items
|
||||||
|
_contractorSpecialtiesMap.value = items.associateBy { it.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
manager.load(KEY_LAST_SYNC_TIME)?.let { data ->
|
||||||
|
_lastSyncTime.value = data.toLongOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark lookups initialized if we have data
|
||||||
|
if (_residenceTypes.value.isNotEmpty()) {
|
||||||
|
_lookupsInitialized.value = true
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Log error but don't crash - cache miss is OK
|
||||||
|
println("DataManager: Error loading from disk: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PERSISTENCE KEYS ====================
|
||||||
|
|
||||||
|
private const val KEY_CURRENT_USER = "dm_current_user"
|
||||||
|
private const val KEY_RESIDENCES = "dm_residences"
|
||||||
|
private const val KEY_MY_RESIDENCES = "dm_my_residences"
|
||||||
|
private const val KEY_ALL_TASKS = "dm_all_tasks"
|
||||||
|
private const val KEY_DOCUMENTS = "dm_documents"
|
||||||
|
private const val KEY_CONTRACTORS = "dm_contractors"
|
||||||
|
private const val KEY_SUBSCRIPTION = "dm_subscription"
|
||||||
|
private const val KEY_RESIDENCE_TYPES = "dm_residence_types"
|
||||||
|
private const val KEY_TASK_FREQUENCIES = "dm_task_frequencies"
|
||||||
|
private const val KEY_TASK_PRIORITIES = "dm_task_priorities"
|
||||||
|
private const val KEY_TASK_STATUSES = "dm_task_statuses"
|
||||||
|
private const val KEY_TASK_CATEGORIES = "dm_task_categories"
|
||||||
|
private const val KEY_CONTRACTOR_SPECIALTIES = "dm_contractor_specialties"
|
||||||
|
private const val KEY_LAST_SYNC_TIME = "dm_last_sync_time"
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.example.casera.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-specific persistence manager for storing app data to disk.
|
||||||
|
* Each platform implements this using their native storage mechanisms.
|
||||||
|
*
|
||||||
|
* Android: SharedPreferences
|
||||||
|
* iOS: UserDefaults
|
||||||
|
* JVM: Properties file
|
||||||
|
* Wasm: LocalStorage
|
||||||
|
*/
|
||||||
|
@Suppress("NO_ACTUAL_FOR_EXPECT")
|
||||||
|
expect class PersistenceManager {
|
||||||
|
/**
|
||||||
|
* Save a string value to persistent storage.
|
||||||
|
*/
|
||||||
|
fun save(key: String, value: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a string value from persistent storage.
|
||||||
|
* Returns null if the key doesn't exist.
|
||||||
|
*/
|
||||||
|
fun load(key: String): String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific key from storage.
|
||||||
|
*/
|
||||||
|
fun remove(key: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored data.
|
||||||
|
*/
|
||||||
|
fun clear()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ package com.example.casera.network
|
|||||||
*/
|
*/
|
||||||
object ApiConfig {
|
object ApiConfig {
|
||||||
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
|
||||||
val CURRENT_ENV = Environment.LOCAL
|
val CURRENT_ENV = Environment.DEV
|
||||||
|
|
||||||
enum class Environment {
|
enum class Environment {
|
||||||
LOCAL,
|
LOCAL,
|
||||||
|
|||||||
@@ -1,175 +1,62 @@
|
|||||||
package com.example.casera.repository
|
package com.example.casera.repository
|
||||||
|
|
||||||
import com.example.casera.cache.SubscriptionCache
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.models.*
|
import com.example.casera.models.*
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.LookupsApi
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.network.SubscriptionApi
|
|
||||||
import com.example.casera.storage.TokenStorage
|
|
||||||
import com.example.casera.storage.TaskCacheStorage
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton repository for managing lookup data across the entire app.
|
* Singleton repository for managing lookup data across the entire app.
|
||||||
* Fetches data once on initialization and caches it for the app session.
|
*
|
||||||
|
* @deprecated Use DataManager directly. This class is kept for backwards compatibility
|
||||||
|
* and simply delegates to DataManager.
|
||||||
*/
|
*/
|
||||||
object LookupsRepository {
|
object LookupsRepository {
|
||||||
private val lookupsApi = LookupsApi()
|
|
||||||
private val subscriptionApi = SubscriptionApi()
|
|
||||||
private val scope = CoroutineScope(Dispatchers.Default)
|
private val scope = CoroutineScope(Dispatchers.Default)
|
||||||
|
|
||||||
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
|
// Delegate to DataManager
|
||||||
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes
|
val residenceTypes: StateFlow<List<ResidenceType>> = DataManager.residenceTypes
|
||||||
|
val taskFrequencies: StateFlow<List<TaskFrequency>> = DataManager.taskFrequencies
|
||||||
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
|
val taskPriorities: StateFlow<List<TaskPriority>> = DataManager.taskPriorities
|
||||||
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies
|
val taskStatuses: StateFlow<List<TaskStatus>> = DataManager.taskStatuses
|
||||||
|
val taskCategories: StateFlow<List<TaskCategory>> = DataManager.taskCategories
|
||||||
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
|
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = DataManager.contractorSpecialties
|
||||||
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities
|
val isInitialized: StateFlow<Boolean> = DataManager.lookupsInitialized
|
||||||
|
|
||||||
private val _taskStatuses = MutableStateFlow<List<TaskStatus>>(emptyList())
|
|
||||||
val taskStatuses: StateFlow<List<TaskStatus>> = _taskStatuses
|
|
||||||
|
|
||||||
private val _taskCategories = MutableStateFlow<List<TaskCategory>>(emptyList())
|
|
||||||
val taskCategories: StateFlow<List<TaskCategory>> = _taskCategories
|
|
||||||
|
|
||||||
private val _contractorSpecialties = MutableStateFlow<List<ContractorSpecialty>>(emptyList())
|
|
||||||
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = _contractorSpecialties
|
|
||||||
|
|
||||||
private val _allTasks = MutableStateFlow<List<CustomTask>>(emptyList())
|
|
||||||
val allTasks: StateFlow<List<CustomTask>> = _allTasks
|
|
||||||
|
|
||||||
private val _isLoading = MutableStateFlow(false)
|
|
||||||
val isLoading: StateFlow<Boolean> = _isLoading
|
|
||||||
|
|
||||||
private val _isInitialized = MutableStateFlow(false)
|
|
||||||
val isInitialized: StateFlow<Boolean> = _isInitialized
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all lookups from the API.
|
* Load all lookups from the API via DataManager.
|
||||||
* This should be called once when the user logs in.
|
* This should be called once when the user logs in.
|
||||||
*/
|
*/
|
||||||
fun initialize() {
|
fun initialize() {
|
||||||
// Only initialize once per app session
|
// DataManager handles initialization via APILayer.initializeLookups()
|
||||||
if (_isInitialized.value) {
|
if (DataManager.lookupsInitialized.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
_isLoading.value = true
|
APILayer.initializeLookups()
|
||||||
|
|
||||||
// Load cached tasks from disk immediately for offline access
|
|
||||||
val cachedTasks = TaskCacheStorage.getTasks()
|
|
||||||
if (cachedTasks != null) {
|
|
||||||
_allTasks.value = cachedTasks
|
|
||||||
println("Loaded ${cachedTasks.size} tasks from cache")
|
|
||||||
}
|
|
||||||
|
|
||||||
val token = TokenStorage.getToken()
|
|
||||||
|
|
||||||
if (token != null) {
|
|
||||||
// Load all static data in a single API call
|
|
||||||
launch {
|
|
||||||
when (val result = lookupsApi.getStaticData(token)) {
|
|
||||||
is ApiResult.Success -> {
|
|
||||||
_residenceTypes.value = result.data.residenceTypes
|
|
||||||
_taskFrequencies.value = result.data.taskFrequencies
|
|
||||||
_taskPriorities.value = result.data.taskPriorities
|
|
||||||
_taskStatuses.value = result.data.taskStatuses
|
|
||||||
_taskCategories.value = result.data.taskCategories
|
|
||||||
_contractorSpecialties.value = result.data.contractorSpecialties
|
|
||||||
println("Loaded all static data successfully")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
println("Failed to fetch static data")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
launch {
|
|
||||||
when (val result = lookupsApi.getAllTasks(token)) {
|
|
||||||
is ApiResult.Success -> {
|
|
||||||
_allTasks.value = result.data
|
|
||||||
// Save to disk cache for offline access
|
|
||||||
TaskCacheStorage.saveTasks(result.data)
|
|
||||||
println("Fetched and cached ${result.data.size} tasks from API")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
println("Failed to fetch tasks from API, using cached data if available")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load subscription status for limitation checks
|
|
||||||
launch {
|
|
||||||
println("🔄 [LookupsRepository] Fetching subscription status...")
|
|
||||||
when (val result = subscriptionApi.getSubscriptionStatus(token)) {
|
|
||||||
is ApiResult.Success -> {
|
|
||||||
println("✅ [LookupsRepository] Subscription status loaded: limitationsEnabled=${result.data.limitationsEnabled}")
|
|
||||||
println(" Limits: ${result.data.limits}")
|
|
||||||
SubscriptionCache.updateSubscriptionStatus(result.data)
|
|
||||||
}
|
|
||||||
is ApiResult.Error -> {
|
|
||||||
println("❌ [LookupsRepository] Failed to fetch subscription status: ${result.message}")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
println("❌ [LookupsRepository] Unexpected subscription result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load upgrade triggers for subscription prompts
|
|
||||||
launch {
|
|
||||||
println("🔄 [LookupsRepository] Fetching upgrade triggers...")
|
|
||||||
when (val result = subscriptionApi.getUpgradeTriggers(token)) {
|
|
||||||
is ApiResult.Success -> {
|
|
||||||
println("✅ [LookupsRepository] Upgrade triggers loaded: ${result.data.size} triggers")
|
|
||||||
SubscriptionCache.updateUpgradeTriggers(result.data)
|
|
||||||
}
|
|
||||||
is ApiResult.Error -> {
|
|
||||||
println("❌ [LookupsRepository] Failed to fetch upgrade triggers: ${result.message}")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
println("❌ [LookupsRepository] Unexpected upgrade triggers result")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_isInitialized.value = true
|
|
||||||
_isLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all cached data.
|
* Clear all cached data via DataManager.
|
||||||
* This should be called when the user logs out.
|
* This should be called when the user logs out.
|
||||||
*/
|
*/
|
||||||
fun clear() {
|
fun clear() {
|
||||||
_residenceTypes.value = emptyList()
|
// DataManager.clear() is called by APILayer.logout()
|
||||||
_taskFrequencies.value = emptyList()
|
// This method is kept for backwards compatibility
|
||||||
_taskPriorities.value = emptyList()
|
|
||||||
_taskStatuses.value = emptyList()
|
|
||||||
_taskCategories.value = emptyList()
|
|
||||||
_contractorSpecialties.value = emptyList()
|
|
||||||
_allTasks.value = emptyList()
|
|
||||||
// Clear disk cache on logout
|
|
||||||
TaskCacheStorage.clearTasks()
|
|
||||||
// Clear subscription cache on logout
|
|
||||||
SubscriptionCache.clear()
|
|
||||||
_isInitialized.value = false
|
|
||||||
_isLoading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force refresh all lookups from the API.
|
* Force refresh all lookups from the API.
|
||||||
*/
|
*/
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
_isInitialized.value = false
|
scope.launch {
|
||||||
initialize()
|
APILayer.initializeLookups()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.example.casera.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.models.AppleSignInRequest
|
import com.example.casera.models.AppleSignInRequest
|
||||||
import com.example.casera.models.AppleSignInResponse
|
import com.example.casera.models.AppleSignInResponse
|
||||||
import com.example.casera.models.AuthResponse
|
import com.example.casera.models.AuthResponse
|
||||||
@@ -19,7 +20,6 @@ import com.example.casera.models.VerifyResetCodeRequest
|
|||||||
import com.example.casera.models.VerifyResetCodeResponse
|
import com.example.casera.models.VerifyResetCodeResponse
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.APILayer
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.storage.TokenStorage
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -57,12 +57,9 @@ class AuthViewModel : ViewModel() {
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_loginState.value = ApiResult.Loading
|
_loginState.value = ApiResult.Loading
|
||||||
val result = APILayer.login(LoginRequest(username, password))
|
val result = APILayer.login(LoginRequest(username, password))
|
||||||
|
// APILayer.login already stores token in DataManager
|
||||||
_loginState.value = when (result) {
|
_loginState.value = when (result) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> ApiResult.Success(result.data)
|
||||||
// Store token for future API calls
|
|
||||||
TokenStorage.saveToken(result.data.token)
|
|
||||||
ApiResult.Success(result.data)
|
|
||||||
}
|
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
else -> ApiResult.Error("Unknown error")
|
else -> ApiResult.Error("Unknown error")
|
||||||
}
|
}
|
||||||
@@ -81,8 +78,9 @@ class AuthViewModel : ViewModel() {
|
|||||||
)
|
)
|
||||||
_registerState.value = when (result) {
|
_registerState.value = when (result) {
|
||||||
is ApiResult.Success -> {
|
is ApiResult.Success -> {
|
||||||
// Store token for future API calls
|
// Store token in DataManager for future API calls
|
||||||
TokenStorage.saveToken(result.data.token)
|
DataManager.setAuthToken(result.data.token)
|
||||||
|
DataManager.setCurrentUser(result.data.user)
|
||||||
ApiResult.Success(result.data)
|
ApiResult.Success(result.data)
|
||||||
}
|
}
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
@@ -98,7 +96,7 @@ class AuthViewModel : ViewModel() {
|
|||||||
fun verifyEmail(code: String) {
|
fun verifyEmail(code: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_verifyEmailState.value = ApiResult.Loading
|
_verifyEmailState.value = ApiResult.Loading
|
||||||
val token = TokenStorage.getToken() ?: run {
|
val token = DataManager.authToken.value ?: run {
|
||||||
_verifyEmailState.value = ApiResult.Error("Not authenticated")
|
_verifyEmailState.value = ApiResult.Error("Not authenticated")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -118,7 +116,7 @@ class AuthViewModel : ViewModel() {
|
|||||||
fun updateProfile(firstName: String?, lastName: String?, email: String?) {
|
fun updateProfile(firstName: String?, lastName: String?, email: String?) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_updateProfileState.value = ApiResult.Loading
|
_updateProfileState.value = ApiResult.Loading
|
||||||
val token = TokenStorage.getToken() ?: run {
|
val token = DataManager.authToken.value ?: run {
|
||||||
_updateProfileState.value = ApiResult.Error("Not authenticated")
|
_updateProfileState.value = ApiResult.Error("Not authenticated")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -230,6 +228,7 @@ class AuthViewModel : ViewModel() {
|
|||||||
lastName = lastName
|
lastName = lastName
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
// APILayer.appleSignIn already stores token in DataManager
|
||||||
_appleSignInState.value = when (result) {
|
_appleSignInState.value = when (result) {
|
||||||
is ApiResult.Success -> ApiResult.Success(result.data)
|
is ApiResult.Success -> ApiResult.Success(result.data)
|
||||||
is ApiResult.Error -> result
|
is ApiResult.Error -> result
|
||||||
@@ -244,8 +243,8 @@ class AuthViewModel : ViewModel() {
|
|||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// APILayer.logout clears DataManager
|
||||||
APILayer.logout()
|
APILayer.logout()
|
||||||
TokenStorage.clearToken()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,30 @@ package com.example.casera.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.models.*
|
import com.example.casera.models.*
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.LookupsApi
|
import com.example.casera.network.APILayer
|
||||||
import com.example.casera.storage.TokenStorage
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ViewModel for lookup data.
|
||||||
|
* Now uses DataManager as the single source of truth for all lookups.
|
||||||
|
* Lookups are loaded once via APILayer.initializeLookups() after login.
|
||||||
|
*/
|
||||||
class LookupsViewModel : ViewModel() {
|
class LookupsViewModel : ViewModel() {
|
||||||
private val lookupsApi = LookupsApi()
|
|
||||||
|
|
||||||
|
// Expose DataManager's lookup StateFlows directly
|
||||||
|
val residenceTypes: StateFlow<List<ResidenceType>> = DataManager.residenceTypes
|
||||||
|
val taskFrequencies: StateFlow<List<TaskFrequency>> = DataManager.taskFrequencies
|
||||||
|
val taskPriorities: StateFlow<List<TaskPriority>> = DataManager.taskPriorities
|
||||||
|
val taskStatuses: StateFlow<List<TaskStatus>> = DataManager.taskStatuses
|
||||||
|
val taskCategories: StateFlow<List<TaskCategory>> = DataManager.taskCategories
|
||||||
|
val contractorSpecialties: StateFlow<List<ContractorSpecialty>> = DataManager.contractorSpecialties
|
||||||
|
|
||||||
|
// Keep legacy state flows for compatibility during migration
|
||||||
private val _residenceTypesState = MutableStateFlow<ApiResult<List<ResidenceType>>>(ApiResult.Idle)
|
private val _residenceTypesState = MutableStateFlow<ApiResult<List<ResidenceType>>>(ApiResult.Idle)
|
||||||
val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> = _residenceTypesState
|
val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> = _residenceTypesState
|
||||||
|
|
||||||
@@ -28,100 +41,68 @@ class LookupsViewModel : ViewModel() {
|
|||||||
private val _taskCategoriesState = MutableStateFlow<ApiResult<List<TaskCategory>>>(ApiResult.Idle)
|
private val _taskCategoriesState = MutableStateFlow<ApiResult<List<TaskCategory>>>(ApiResult.Idle)
|
||||||
val taskCategoriesState: StateFlow<ApiResult<List<TaskCategory>>> = _taskCategoriesState
|
val taskCategoriesState: StateFlow<ApiResult<List<TaskCategory>>> = _taskCategoriesState
|
||||||
|
|
||||||
// Cache flags to avoid refetching
|
|
||||||
private var residenceTypesFetched = false
|
|
||||||
private var taskFrequenciesFetched = false
|
|
||||||
private var taskPrioritiesFetched = false
|
|
||||||
private var taskStatusesFetched = false
|
|
||||||
private var taskCategoriesFetched = false
|
|
||||||
|
|
||||||
fun loadResidenceTypes() {
|
fun loadResidenceTypes() {
|
||||||
if (residenceTypesFetched && _residenceTypesState.value is ApiResult.Success) {
|
|
||||||
return // Already loaded
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_residenceTypesState.value = ApiResult.Loading
|
val cached = DataManager.residenceTypes.value
|
||||||
val token = TokenStorage.getToken()
|
if (cached.isNotEmpty()) {
|
||||||
if (token != null) {
|
_residenceTypesState.value = ApiResult.Success(cached)
|
||||||
_residenceTypesState.value = lookupsApi.getResidenceTypes(token)
|
return@launch
|
||||||
if (_residenceTypesState.value is ApiResult.Success) {
|
|
||||||
residenceTypesFetched = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_residenceTypesState.value = ApiResult.Error("Not authenticated", 401)
|
|
||||||
}
|
}
|
||||||
|
_residenceTypesState.value = ApiResult.Loading
|
||||||
|
val result = APILayer.getResidenceTypes()
|
||||||
|
_residenceTypesState.value = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTaskFrequencies() {
|
fun loadTaskFrequencies() {
|
||||||
if (taskFrequenciesFetched && _taskFrequenciesState.value is ApiResult.Success) {
|
|
||||||
return // Already loaded
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_taskFrequenciesState.value = ApiResult.Loading
|
val cached = DataManager.taskFrequencies.value
|
||||||
val token = TokenStorage.getToken()
|
if (cached.isNotEmpty()) {
|
||||||
if (token != null) {
|
_taskFrequenciesState.value = ApiResult.Success(cached)
|
||||||
_taskFrequenciesState.value = lookupsApi.getTaskFrequencies(token)
|
return@launch
|
||||||
if (_taskFrequenciesState.value is ApiResult.Success) {
|
|
||||||
taskFrequenciesFetched = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_taskFrequenciesState.value = ApiResult.Error("Not authenticated", 401)
|
|
||||||
}
|
}
|
||||||
|
_taskFrequenciesState.value = ApiResult.Loading
|
||||||
|
val result = APILayer.getTaskFrequencies()
|
||||||
|
_taskFrequenciesState.value = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTaskPriorities() {
|
fun loadTaskPriorities() {
|
||||||
if (taskPrioritiesFetched && _taskPrioritiesState.value is ApiResult.Success) {
|
|
||||||
return // Already loaded
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_taskPrioritiesState.value = ApiResult.Loading
|
val cached = DataManager.taskPriorities.value
|
||||||
val token = TokenStorage.getToken()
|
if (cached.isNotEmpty()) {
|
||||||
if (token != null) {
|
_taskPrioritiesState.value = ApiResult.Success(cached)
|
||||||
_taskPrioritiesState.value = lookupsApi.getTaskPriorities(token)
|
return@launch
|
||||||
if (_taskPrioritiesState.value is ApiResult.Success) {
|
|
||||||
taskPrioritiesFetched = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_taskPrioritiesState.value = ApiResult.Error("Not authenticated", 401)
|
|
||||||
}
|
}
|
||||||
|
_taskPrioritiesState.value = ApiResult.Loading
|
||||||
|
val result = APILayer.getTaskPriorities()
|
||||||
|
_taskPrioritiesState.value = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTaskStatuses() {
|
fun loadTaskStatuses() {
|
||||||
if (taskStatusesFetched && _taskStatusesState.value is ApiResult.Success) {
|
|
||||||
return // Already loaded
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_taskStatusesState.value = ApiResult.Loading
|
val cached = DataManager.taskStatuses.value
|
||||||
val token = TokenStorage.getToken()
|
if (cached.isNotEmpty()) {
|
||||||
if (token != null) {
|
_taskStatusesState.value = ApiResult.Success(cached)
|
||||||
_taskStatusesState.value = lookupsApi.getTaskStatuses(token)
|
return@launch
|
||||||
if (_taskStatusesState.value is ApiResult.Success) {
|
|
||||||
taskStatusesFetched = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_taskStatusesState.value = ApiResult.Error("Not authenticated", 401)
|
|
||||||
}
|
}
|
||||||
|
_taskStatusesState.value = ApiResult.Loading
|
||||||
|
val result = APILayer.getTaskStatuses()
|
||||||
|
_taskStatusesState.value = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadTaskCategories() {
|
fun loadTaskCategories() {
|
||||||
if (taskCategoriesFetched && _taskCategoriesState.value is ApiResult.Success) {
|
|
||||||
return // Already loaded
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_taskCategoriesState.value = ApiResult.Loading
|
val cached = DataManager.taskCategories.value
|
||||||
val token = TokenStorage.getToken()
|
if (cached.isNotEmpty()) {
|
||||||
if (token != null) {
|
_taskCategoriesState.value = ApiResult.Success(cached)
|
||||||
_taskCategoriesState.value = lookupsApi.getTaskCategories(token)
|
return@launch
|
||||||
if (_taskCategoriesState.value is ApiResult.Success) {
|
|
||||||
taskCategoriesFetched = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_taskCategoriesState.value = ApiResult.Error("Not authenticated", 401)
|
|
||||||
}
|
}
|
||||||
|
_taskCategoriesState.value = ApiResult.Loading
|
||||||
|
val result = APILayer.getTaskCategories()
|
||||||
|
_taskCategoriesState.value = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ package com.example.casera.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.example.casera.data.DataManager
|
||||||
import com.example.casera.models.TaskCompletion
|
import com.example.casera.models.TaskCompletion
|
||||||
import com.example.casera.models.TaskCompletionCreateRequest
|
import com.example.casera.models.TaskCompletionCreateRequest
|
||||||
import com.example.casera.network.ApiResult
|
import com.example.casera.network.ApiResult
|
||||||
import com.example.casera.network.TaskCompletionApi
|
import com.example.casera.network.TaskCompletionApi
|
||||||
import com.example.casera.storage.TokenStorage
|
|
||||||
import com.example.casera.util.ImageCompressor
|
import com.example.casera.util.ImageCompressor
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -21,7 +21,7 @@ class TaskCompletionViewModel : ViewModel() {
|
|||||||
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
|
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_createCompletionState.value = ApiResult.Loading
|
_createCompletionState.value = ApiResult.Loading
|
||||||
val token = TokenStorage.getToken()
|
val token = DataManager.authToken.value
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
|
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
|
||||||
} else {
|
} else {
|
||||||
@@ -42,7 +42,7 @@ class TaskCompletionViewModel : ViewModel() {
|
|||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_createCompletionState.value = ApiResult.Loading
|
_createCompletionState.value = ApiResult.Loading
|
||||||
val token = TokenStorage.getToken()
|
val token = DataManager.authToken.value
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
// Compress images and prepare for upload
|
// Compress images and prepare for upload
|
||||||
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
val compressedImages = images.map { ImageCompressor.compressImage(it) }
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.example.casera.data
|
||||||
|
|
||||||
|
import platform.Foundation.NSUserDefaults
|
||||||
|
|
||||||
|
/**
|
||||||
|
* iOS implementation of PersistenceManager using NSUserDefaults.
|
||||||
|
*/
|
||||||
|
actual class PersistenceManager {
|
||||||
|
private val defaults = NSUserDefaults.standardUserDefaults
|
||||||
|
|
||||||
|
actual fun save(key: String, value: String) {
|
||||||
|
defaults.setObject(value, forKey = key)
|
||||||
|
defaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun load(key: String): String? {
|
||||||
|
return defaults.stringForKey(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun remove(key: String) {
|
||||||
|
defaults.removeObjectForKey(key)
|
||||||
|
defaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clear() {
|
||||||
|
// Get all keys with our prefix and remove them
|
||||||
|
val dict = defaults.dictionaryRepresentation()
|
||||||
|
dict.keys.forEach { key ->
|
||||||
|
val keyStr = key as? String ?: return@forEach
|
||||||
|
if (keyStr.startsWith("dm_")) {
|
||||||
|
defaults.removeObjectForKey(keyStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defaults.synchronize()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val instance by lazy { PersistenceManager() }
|
||||||
|
|
||||||
|
fun getInstance(): PersistenceManager = instance
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.example.casera.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JVM/Desktop implementation of PersistenceManager using Properties file.
|
||||||
|
*/
|
||||||
|
actual class PersistenceManager {
|
||||||
|
private val properties = Properties()
|
||||||
|
private val storageFile: File
|
||||||
|
|
||||||
|
init {
|
||||||
|
val userHome = System.getProperty("user.home")
|
||||||
|
val appDir = File(userHome, ".casera")
|
||||||
|
if (!appDir.exists()) {
|
||||||
|
appDir.mkdirs()
|
||||||
|
}
|
||||||
|
storageFile = File(appDir, "data.properties")
|
||||||
|
loadProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProperties() {
|
||||||
|
if (storageFile.exists()) {
|
||||||
|
try {
|
||||||
|
storageFile.inputStream().use { properties.load(it) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore load errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveProperties() {
|
||||||
|
try {
|
||||||
|
storageFile.outputStream().use { properties.store(it, "Casera Data Manager") }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Ignore save errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun save(key: String, value: String) {
|
||||||
|
properties.setProperty(key, value)
|
||||||
|
saveProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun load(key: String): String? {
|
||||||
|
return properties.getProperty(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun remove(key: String) {
|
||||||
|
properties.remove(key)
|
||||||
|
saveProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clear() {
|
||||||
|
properties.clear()
|
||||||
|
saveProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val instance by lazy { PersistenceManager() }
|
||||||
|
|
||||||
|
fun getInstance(): PersistenceManager = instance
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.example.casera.data
|
||||||
|
|
||||||
|
import kotlinx.browser.localStorage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WasmJS implementation of PersistenceManager using browser localStorage.
|
||||||
|
*/
|
||||||
|
actual class PersistenceManager {
|
||||||
|
actual fun save(key: String, value: String) {
|
||||||
|
localStorage.setItem(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun load(key: String): String? {
|
||||||
|
return localStorage.getItem(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun remove(key: String) {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun clear() {
|
||||||
|
// Remove all items with our prefix
|
||||||
|
val keysToRemove = mutableListOf<String>()
|
||||||
|
for (i in 0 until localStorage.length) {
|
||||||
|
val key = localStorage.key(i) ?: continue
|
||||||
|
if (key.startsWith("dm_")) {
|
||||||
|
keysToRemove.add(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
keysToRemove.forEach { localStorage.removeItem(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val instance by lazy { PersistenceManager() }
|
||||||
|
|
||||||
|
fun getInstance(): PersistenceManager = instance
|
||||||
|
}
|
||||||
|
}
|
||||||
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() {
|
func checkAuthenticationStatus() {
|
||||||
isCheckingAuth = true
|
isCheckingAuth = true
|
||||||
|
|
||||||
// Check if token exists
|
// Check if token exists via DataManager (single source of truth)
|
||||||
guard let token = TokenStorage.shared.getToken(), !token.isEmpty else {
|
guard DataManager.shared.isAuthenticated() else {
|
||||||
isAuthenticated = false
|
isAuthenticated = false
|
||||||
isVerified = false
|
isVerified = false
|
||||||
isCheckingAuth = false
|
isCheckingAuth = false
|
||||||
@@ -45,15 +45,15 @@ class AuthenticationManager: ObservableObject {
|
|||||||
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
await StoreKitManager.shared.verifyEntitlementsOnLaunch()
|
||||||
}
|
}
|
||||||
} else if result is ApiResultError {
|
} else if result is ApiResultError {
|
||||||
// Token is invalid, clear it
|
// Token is invalid, clear all data via DataManager
|
||||||
TokenStorage.shared.clearToken()
|
DataManager.shared.clear()
|
||||||
self.isAuthenticated = false
|
self.isAuthenticated = false
|
||||||
self.isVerified = false
|
self.isVerified = false
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ Failed to check auth status: \(error)")
|
print("❌ Failed to check auth status: \(error)")
|
||||||
// On error, assume token is invalid
|
// On error, assume token is invalid
|
||||||
TokenStorage.shared.clearToken()
|
DataManager.shared.clear()
|
||||||
self.isAuthenticated = false
|
self.isAuthenticated = false
|
||||||
self.isVerified = false
|
self.isVerified = false
|
||||||
}
|
}
|
||||||
@@ -85,18 +85,9 @@ class AuthenticationManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logout() {
|
||||||
// Call shared ViewModel logout
|
// Call shared ViewModel logout which clears DataManager
|
||||||
sharedViewModel.logout()
|
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
|
// Clear widget task data
|
||||||
WidgetDataManager.shared.clearCache()
|
WidgetDataManager.shared.clearCache()
|
||||||
|
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ class TaskViewModel: ObservableObject {
|
|||||||
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
|
/// - residenceId: Optional residence ID to filter by. If nil, loads all tasks.
|
||||||
/// - forceRefresh: Whether to bypass cache
|
/// - forceRefresh: Whether to bypass cache
|
||||||
func loadTasks(residenceId: Int32? = nil, forceRefresh: Bool = false) {
|
func loadTasks(residenceId: Int32? = nil, forceRefresh: Bool = false) {
|
||||||
guard TokenStorage.shared.getToken() != nil else { return }
|
guard DataManager.shared.isAuthenticated() else { return }
|
||||||
|
|
||||||
currentResidenceId = residenceId
|
currentResidenceId = residenceId
|
||||||
isLoadingTasks = true
|
isLoadingTasks = true
|
||||||
|
|||||||
@@ -8,8 +8,16 @@ struct iOSApp: App {
|
|||||||
@State private var deepLinkResetToken: String?
|
@State private var deepLinkResetToken: String?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize TokenStorage once at app startup
|
// Initialize DataManager with platform-specific managers
|
||||||
TokenStorage.shared.initialize(manager: TokenManager())
|
// 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 {
|
var body: some Scene {
|
||||||
|
|||||||
Reference in New Issue
Block a user