Add unified DataManager as single source of truth for all app data

- Create DataManager.kt with StateFlows for all cached data:
  - Authentication (token, user)
  - Residences, tasks, documents, contractors
  - Subscription status and upgrade triggers
  - All lookup data (residence types, task categories, etc.)
  - Theme preferences and state metadata

- Add PersistenceManager with platform-specific implementations:
  - Android: SharedPreferences
  - iOS: NSUserDefaults
  - JVM: Properties file
  - WasmJS: localStorage

- Migrate APILayer to update DataManager on every API response
- Update Kotlin ViewModels to use DataManager for token access
- Deprecate LookupsRepository (delegates to DataManager)
- Create iOS DataManagerObservable Swift wrapper for SwiftUI
- Update iOS auth flow to use DataManager.isAuthenticated()

Data flow: User Action → API Call → DataManager Updated → All Screens React

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-03 00:21:24 -06:00
parent b79fda8aee
commit cf0cd1cda2
17 changed files with 1721 additions and 489 deletions

View File

@@ -0,0 +1,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 }
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,429 @@
import Foundation
import ComposeApp
import Combine
/// SwiftUI-compatible wrapper for Kotlin DataManager.
/// Observes all DataManager StateFlows and publishes changes via @Published properties.
///
/// Usage in SwiftUI views:
/// ```swift
/// @StateObject private var dataManager = DataManagerObservable.shared
/// // or
/// @EnvironmentObject var dataManager: DataManagerObservable
/// ```
///
/// This is the Swift-side Single Source of Truth that mirrors Kotlin's DataManager.
/// All screens should observe this instead of making duplicate API calls.
@MainActor
class DataManagerObservable: ObservableObject {
// MARK: - Singleton
static let shared = DataManagerObservable()
// MARK: - Authentication
@Published var authToken: String?
@Published var currentUser: User?
@Published var isAuthenticated: Bool = false
// MARK: - App Preferences
@Published var themeId: String = "default"
// MARK: - Residences
@Published var residences: [ResidenceResponse] = []
@Published var myResidences: MyResidencesResponse?
@Published var residenceSummaries: [Int32: ResidenceSummaryResponse] = [:]
// MARK: - Tasks
@Published var allTasks: TaskColumnsResponse?
@Published var tasksByResidence: [Int32: TaskColumnsResponse] = [:]
// MARK: - Documents
@Published var documents: [Document] = []
@Published var documentsByResidence: [Int32: [Document]] = [:]
// MARK: - Contractors
@Published var contractors: [Contractor] = []
// MARK: - Subscription
@Published var subscription: SubscriptionStatus?
@Published var upgradeTriggers: [String: UpgradeTriggerData] = [:]
@Published var featureBenefits: [FeatureBenefit] = []
@Published var promotions: [Promotion] = []
// MARK: - Lookups (Reference Data)
@Published var residenceTypes: [ResidenceType] = []
@Published var taskFrequencies: [TaskFrequency] = []
@Published var taskPriorities: [TaskPriority] = []
@Published var taskStatuses: [TaskStatus] = []
@Published var taskCategories: [TaskCategory] = []
@Published var contractorSpecialties: [ContractorSpecialty] = []
// MARK: - State Metadata
@Published var isInitialized: Bool = false
@Published var lookupsInitialized: Bool = false
@Published var lastSyncTime: Int64 = 0
// MARK: - Private Properties
private var observationTasks: [Task<Void, Never>] = []
// MARK: - Initialization
private init() {
startObserving()
}
// MARK: - Observation Setup
/// Start observing all DataManager StateFlows
private func startObserving() {
// Authentication - authToken
let authTokenTask = Task {
for await token in DataManager.shared.authToken {
await MainActor.run {
self.authToken = token
self.isAuthenticated = token != nil
}
}
}
observationTasks.append(authTokenTask)
// Authentication - currentUser
let currentUserTask = Task {
for await user in DataManager.shared.currentUser {
await MainActor.run {
self.currentUser = user
}
}
}
observationTasks.append(currentUserTask)
// Theme
let themeIdTask = Task {
for await id in DataManager.shared.themeId {
await MainActor.run {
self.themeId = id
}
}
}
observationTasks.append(themeIdTask)
// Residences
let residencesTask = Task {
for await list in DataManager.shared.residences {
await MainActor.run {
self.residences = list
}
}
}
observationTasks.append(residencesTask)
// MyResidences
let myResidencesTask = Task {
for await response in DataManager.shared.myResidences {
await MainActor.run {
self.myResidences = response
}
}
}
observationTasks.append(myResidencesTask)
// ResidenceSummaries
let residenceSummariesTask = Task {
for await summaries in DataManager.shared.residenceSummaries {
await MainActor.run {
self.residenceSummaries = self.convertIntMap(summaries)
}
}
}
observationTasks.append(residenceSummariesTask)
// AllTasks
let allTasksTask = Task {
for await tasks in DataManager.shared.allTasks {
await MainActor.run {
self.allTasks = tasks
}
}
}
observationTasks.append(allTasksTask)
// TasksByResidence
let tasksByResidenceTask = Task {
for await tasks in DataManager.shared.tasksByResidence {
await MainActor.run {
self.tasksByResidence = self.convertIntMap(tasks)
}
}
}
observationTasks.append(tasksByResidenceTask)
// Documents
let documentsTask = Task {
for await docs in DataManager.shared.documents {
await MainActor.run {
self.documents = docs
}
}
}
observationTasks.append(documentsTask)
// DocumentsByResidence
let documentsByResidenceTask = Task {
for await docs in DataManager.shared.documentsByResidence {
await MainActor.run {
self.documentsByResidence = self.convertIntArrayMap(docs)
}
}
}
observationTasks.append(documentsByResidenceTask)
// Contractors
let contractorsTask = Task {
for await list in DataManager.shared.contractors {
await MainActor.run {
self.contractors = list
}
}
}
observationTasks.append(contractorsTask)
// Subscription
let subscriptionTask = Task {
for await sub in DataManager.shared.subscription {
await MainActor.run {
self.subscription = sub
}
}
}
observationTasks.append(subscriptionTask)
// UpgradeTriggers
let upgradeTriggersTask = Task {
for await triggers in DataManager.shared.upgradeTriggers {
await MainActor.run {
self.upgradeTriggers = self.convertStringMap(triggers)
}
}
}
observationTasks.append(upgradeTriggersTask)
// FeatureBenefits
let featureBenefitsTask = Task {
for await benefits in DataManager.shared.featureBenefits {
await MainActor.run {
self.featureBenefits = benefits
}
}
}
observationTasks.append(featureBenefitsTask)
// Promotions
let promotionsTask = Task {
for await promos in DataManager.shared.promotions {
await MainActor.run {
self.promotions = promos
}
}
}
observationTasks.append(promotionsTask)
// Lookups - ResidenceTypes
let residenceTypesTask = Task {
for await types in DataManager.shared.residenceTypes {
await MainActor.run {
self.residenceTypes = types
}
}
}
observationTasks.append(residenceTypesTask)
// Lookups - TaskFrequencies
let taskFrequenciesTask = Task {
for await items in DataManager.shared.taskFrequencies {
await MainActor.run {
self.taskFrequencies = items
}
}
}
observationTasks.append(taskFrequenciesTask)
// Lookups - TaskPriorities
let taskPrioritiesTask = Task {
for await items in DataManager.shared.taskPriorities {
await MainActor.run {
self.taskPriorities = items
}
}
}
observationTasks.append(taskPrioritiesTask)
// Lookups - TaskStatuses
let taskStatusesTask = Task {
for await items in DataManager.shared.taskStatuses {
await MainActor.run {
self.taskStatuses = items
}
}
}
observationTasks.append(taskStatusesTask)
// Lookups - TaskCategories
let taskCategoriesTask = Task {
for await items in DataManager.shared.taskCategories {
await MainActor.run {
self.taskCategories = items
}
}
}
observationTasks.append(taskCategoriesTask)
// Lookups - ContractorSpecialties
let contractorSpecialtiesTask = Task {
for await items in DataManager.shared.contractorSpecialties {
await MainActor.run {
self.contractorSpecialties = items
}
}
}
observationTasks.append(contractorSpecialtiesTask)
// Metadata - isInitialized
let isInitializedTask = Task {
for await initialized in DataManager.shared.isInitialized {
await MainActor.run {
self.isInitialized = initialized.boolValue
}
}
}
observationTasks.append(isInitializedTask)
// Metadata - lookupsInitialized
let lookupsInitializedTask = Task {
for await initialized in DataManager.shared.lookupsInitialized {
await MainActor.run {
self.lookupsInitialized = initialized.boolValue
}
}
}
observationTasks.append(lookupsInitializedTask)
// Metadata - lastSyncTime
let lastSyncTimeTask = Task {
for await time in DataManager.shared.lastSyncTime {
await MainActor.run {
self.lastSyncTime = time.int64Value
}
}
}
observationTasks.append(lastSyncTimeTask)
}
/// Stop all observations
func stopObserving() {
observationTasks.forEach { $0.cancel() }
observationTasks.removeAll()
}
// MARK: - Map Conversion Helpers
/// Convert Kotlin Map<Int, V> to Swift [Int32: V]
private func convertIntMap<V>(_ kotlinMap: [KotlinInt: V]) -> [Int32: V] {
var result: [Int32: V] = [:]
for (key, value) in kotlinMap {
result[key.int32Value] = value
}
return result
}
/// Convert Kotlin Map<Int, List<V>> to Swift [Int32: [V]]
private func convertIntArrayMap<V>(_ kotlinMap: [KotlinInt: [V]]) -> [Int32: [V]] {
var result: [Int32: [V]] = [:]
for (key, value) in kotlinMap {
result[key.int32Value] = value
}
return result
}
/// Convert Kotlin Map<String, V> to Swift [String: V]
private func convertStringMap<V>(_ kotlinMap: [String: V]) -> [String: V] {
return kotlinMap
}
// MARK: - Convenience Lookup Methods
/// Get residence type by ID
func getResidenceType(id: Int32?) -> ResidenceType? {
guard let id = id else { return nil }
return residenceTypes.first { $0.id == id }
}
/// Get task frequency by ID
func getTaskFrequency(id: Int32?) -> TaskFrequency? {
guard let id = id else { return nil }
return taskFrequencies.first { $0.id == id }
}
/// Get task priority by ID
func getTaskPriority(id: Int32?) -> TaskPriority? {
guard let id = id else { return nil }
return taskPriorities.first { $0.id == id }
}
/// Get task status by ID
func getTaskStatus(id: Int32?) -> TaskStatus? {
guard let id = id else { return nil }
return taskStatuses.first { $0.id == id }
}
/// Get task category by ID
func getTaskCategory(id: Int32?) -> TaskCategory? {
guard let id = id else { return nil }
return taskCategories.first { $0.id == id }
}
/// Get contractor specialty by ID
func getContractorSpecialty(id: Int32?) -> ContractorSpecialty? {
guard let id = id else { return nil }
return contractorSpecialties.first { $0.id == id }
}
// MARK: - Task Helpers
/// Get tasks for a specific residence
func tasks(for residenceId: Int32) -> TaskColumnsResponse? {
return tasksByResidence[residenceId]
}
/// Get documents for a specific residence
func documents(for residenceId: Int32) -> [Document] {
return documentsByResidence[residenceId] ?? []
}
/// Get residence summary
func summary(for residenceId: Int32) -> ResidenceSummaryResponse? {
return residenceSummaries[residenceId]
}
/// Total task count across all columns
var totalTaskCount: Int {
guard let response = allTasks else { return 0 }
return response.columns.reduce(0) { $0 + Int($1.count) }
}
/// Check if there are no tasks
var hasNoTasks: Bool {
guard let response = allTasks else { return true }
return response.columns.allSatisfy { $0.tasks.isEmpty }
}
}

View File

@@ -18,8 +18,8 @@ class AuthenticationManager: ObservableObject {
func checkAuthenticationStatus() { 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()

View File

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

View File

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