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.network.ApiResult
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.compose_multiplatform
@@ -64,32 +65,27 @@ fun App(
deepLinkResetToken: String? = null,
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 isCheckingAuth by remember { mutableStateOf(true) }
val navController = rememberNavController()
// Check for stored token and verification status on app start
LaunchedEffect(Unit) {
val hasToken = TokenStorage.hasToken()
val hasToken = DataManager.authToken.value != null
isLoggedIn = hasToken
if (hasToken) {
// Fetch current user to check verification status
val authApi = AuthApi()
val token = TokenStorage.getToken()
if (token != null) {
when (val result = authApi.getCurrentUser(token)) {
is ApiResult.Success -> {
isVerified = result.data.verified
LookupsRepository.initialize()
}
else -> {
// If fetching user fails, clear token and logout
TokenStorage.clearToken()
isLoggedIn = false
}
when (val result = APILayer.getCurrentUser(forceRefresh = true)) {
is ApiResult.Success -> {
isVerified = result.data.verified
APILayer.initializeLookups()
}
else -> {
// If fetching user fails, clear DataManager and logout
DataManager.clear()
isLoggedIn = false
}
}
}
@@ -251,8 +247,7 @@ fun App(
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
DataManager.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
@@ -266,8 +261,7 @@ fun App(
MainScreen(
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
DataManager.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
@@ -346,8 +340,7 @@ fun App(
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
DataManager.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
@@ -374,8 +367,7 @@ fun App(
shouldRefresh = shouldRefresh,
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
DataManager.clear()
isLoggedIn = false
isVerified = false
navController.navigate(LoginRoute) {
@@ -541,8 +533,7 @@ fun App(
},
onLogout = {
// Clear token and lookups on logout
TokenStorage.clearToken()
LookupsRepository.clear()
DataManager.clear()
isLoggedIn = false
isVerified = false
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 {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.LOCAL
val CURRENT_ENV = Environment.DEV
enum class Environment {
LOCAL,

View File

@@ -1,175 +1,62 @@
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.network.ApiResult
import com.example.casera.network.LookupsApi
import com.example.casera.network.SubscriptionApi
import com.example.casera.storage.TokenStorage
import com.example.casera.storage.TaskCacheStorage
import com.example.casera.network.APILayer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
/**
* 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 {
private val lookupsApi = LookupsApi()
private val subscriptionApi = SubscriptionApi()
private val scope = CoroutineScope(Dispatchers.Default)
private val _residenceTypes = MutableStateFlow<List<ResidenceType>>(emptyList())
val residenceTypes: StateFlow<List<ResidenceType>> = _residenceTypes
private val _taskFrequencies = MutableStateFlow<List<TaskFrequency>>(emptyList())
val taskFrequencies: StateFlow<List<TaskFrequency>> = _taskFrequencies
private val _taskPriorities = MutableStateFlow<List<TaskPriority>>(emptyList())
val taskPriorities: StateFlow<List<TaskPriority>> = _taskPriorities
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
// Delegate to DataManager
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
val isInitialized: StateFlow<Boolean> = DataManager.lookupsInitialized
/**
* Load all lookups from the API.
* Load all lookups from the API via DataManager.
* This should be called once when the user logs in.
*/
fun initialize() {
// Only initialize once per app session
if (_isInitialized.value) {
// DataManager handles initialization via APILayer.initializeLookups()
if (DataManager.lookupsInitialized.value) {
return
}
scope.launch {
_isLoading.value = true
// 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
APILayer.initializeLookups()
}
}
/**
* Clear all cached data.
* Clear all cached data via DataManager.
* This should be called when the user logs out.
*/
fun clear() {
_residenceTypes.value = emptyList()
_taskFrequencies.value = emptyList()
_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
// DataManager.clear() is called by APILayer.logout()
// This method is kept for backwards compatibility
}
/**
* Force refresh all lookups from the API.
*/
fun refresh() {
_isInitialized.value = false
initialize()
scope.launch {
APILayer.initializeLookups()
}
}
}

View File

@@ -2,6 +2,7 @@ package com.example.casera.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.casera.data.DataManager
import com.example.casera.models.AppleSignInRequest
import com.example.casera.models.AppleSignInResponse
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.network.ApiResult
import com.example.casera.network.APILayer
import com.example.casera.storage.TokenStorage
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -57,12 +57,9 @@ class AuthViewModel : ViewModel() {
viewModelScope.launch {
_loginState.value = ApiResult.Loading
val result = APILayer.login(LoginRequest(username, password))
// APILayer.login already stores token in DataManager
_loginState.value = when (result) {
is ApiResult.Success -> {
// Store token for future API calls
TokenStorage.saveToken(result.data.token)
ApiResult.Success(result.data)
}
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
else -> ApiResult.Error("Unknown error")
}
@@ -81,8 +78,9 @@ class AuthViewModel : ViewModel() {
)
_registerState.value = when (result) {
is ApiResult.Success -> {
// Store token for future API calls
TokenStorage.saveToken(result.data.token)
// Store token in DataManager for future API calls
DataManager.setAuthToken(result.data.token)
DataManager.setCurrentUser(result.data.user)
ApiResult.Success(result.data)
}
is ApiResult.Error -> result
@@ -98,7 +96,7 @@ class AuthViewModel : ViewModel() {
fun verifyEmail(code: String) {
viewModelScope.launch {
_verifyEmailState.value = ApiResult.Loading
val token = TokenStorage.getToken() ?: run {
val token = DataManager.authToken.value ?: run {
_verifyEmailState.value = ApiResult.Error("Not authenticated")
return@launch
}
@@ -118,7 +116,7 @@ class AuthViewModel : ViewModel() {
fun updateProfile(firstName: String?, lastName: String?, email: String?) {
viewModelScope.launch {
_updateProfileState.value = ApiResult.Loading
val token = TokenStorage.getToken() ?: run {
val token = DataManager.authToken.value ?: run {
_updateProfileState.value = ApiResult.Error("Not authenticated")
return@launch
}
@@ -230,6 +228,7 @@ class AuthViewModel : ViewModel() {
lastName = lastName
)
)
// APILayer.appleSignIn already stores token in DataManager
_appleSignInState.value = when (result) {
is ApiResult.Success -> ApiResult.Success(result.data)
is ApiResult.Error -> result
@@ -244,8 +243,8 @@ class AuthViewModel : ViewModel() {
fun logout() {
viewModelScope.launch {
// APILayer.logout clears DataManager
APILayer.logout()
TokenStorage.clearToken()
}
}
}

View File

@@ -2,17 +2,30 @@ package com.example.casera.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.casera.data.DataManager
import com.example.casera.models.*
import com.example.casera.network.ApiResult
import com.example.casera.network.LookupsApi
import com.example.casera.storage.TokenStorage
import com.example.casera.network.APILayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
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() {
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)
val residenceTypesState: StateFlow<ApiResult<List<ResidenceType>>> = _residenceTypesState
@@ -28,100 +41,68 @@ class LookupsViewModel : ViewModel() {
private val _taskCategoriesState = MutableStateFlow<ApiResult<List<TaskCategory>>>(ApiResult.Idle)
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() {
if (residenceTypesFetched && _residenceTypesState.value is ApiResult.Success) {
return // Already loaded
}
viewModelScope.launch {
_residenceTypesState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_residenceTypesState.value = lookupsApi.getResidenceTypes(token)
if (_residenceTypesState.value is ApiResult.Success) {
residenceTypesFetched = true
}
} else {
_residenceTypesState.value = ApiResult.Error("Not authenticated", 401)
val cached = DataManager.residenceTypes.value
if (cached.isNotEmpty()) {
_residenceTypesState.value = ApiResult.Success(cached)
return@launch
}
_residenceTypesState.value = ApiResult.Loading
val result = APILayer.getResidenceTypes()
_residenceTypesState.value = result
}
}
fun loadTaskFrequencies() {
if (taskFrequenciesFetched && _taskFrequenciesState.value is ApiResult.Success) {
return // Already loaded
}
viewModelScope.launch {
_taskFrequenciesState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_taskFrequenciesState.value = lookupsApi.getTaskFrequencies(token)
if (_taskFrequenciesState.value is ApiResult.Success) {
taskFrequenciesFetched = true
}
} else {
_taskFrequenciesState.value = ApiResult.Error("Not authenticated", 401)
val cached = DataManager.taskFrequencies.value
if (cached.isNotEmpty()) {
_taskFrequenciesState.value = ApiResult.Success(cached)
return@launch
}
_taskFrequenciesState.value = ApiResult.Loading
val result = APILayer.getTaskFrequencies()
_taskFrequenciesState.value = result
}
}
fun loadTaskPriorities() {
if (taskPrioritiesFetched && _taskPrioritiesState.value is ApiResult.Success) {
return // Already loaded
}
viewModelScope.launch {
_taskPrioritiesState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_taskPrioritiesState.value = lookupsApi.getTaskPriorities(token)
if (_taskPrioritiesState.value is ApiResult.Success) {
taskPrioritiesFetched = true
}
} else {
_taskPrioritiesState.value = ApiResult.Error("Not authenticated", 401)
val cached = DataManager.taskPriorities.value
if (cached.isNotEmpty()) {
_taskPrioritiesState.value = ApiResult.Success(cached)
return@launch
}
_taskPrioritiesState.value = ApiResult.Loading
val result = APILayer.getTaskPriorities()
_taskPrioritiesState.value = result
}
}
fun loadTaskStatuses() {
if (taskStatusesFetched && _taskStatusesState.value is ApiResult.Success) {
return // Already loaded
}
viewModelScope.launch {
_taskStatusesState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_taskStatusesState.value = lookupsApi.getTaskStatuses(token)
if (_taskStatusesState.value is ApiResult.Success) {
taskStatusesFetched = true
}
} else {
_taskStatusesState.value = ApiResult.Error("Not authenticated", 401)
val cached = DataManager.taskStatuses.value
if (cached.isNotEmpty()) {
_taskStatusesState.value = ApiResult.Success(cached)
return@launch
}
_taskStatusesState.value = ApiResult.Loading
val result = APILayer.getTaskStatuses()
_taskStatusesState.value = result
}
}
fun loadTaskCategories() {
if (taskCategoriesFetched && _taskCategoriesState.value is ApiResult.Success) {
return // Already loaded
}
viewModelScope.launch {
_taskCategoriesState.value = ApiResult.Loading
val token = TokenStorage.getToken()
if (token != null) {
_taskCategoriesState.value = lookupsApi.getTaskCategories(token)
if (_taskCategoriesState.value is ApiResult.Success) {
taskCategoriesFetched = true
}
} else {
_taskCategoriesState.value = ApiResult.Error("Not authenticated", 401)
val cached = DataManager.taskCategories.value
if (cached.isNotEmpty()) {
_taskCategoriesState.value = ApiResult.Success(cached)
return@launch
}
_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.viewModelScope
import com.example.casera.data.DataManager
import com.example.casera.models.TaskCompletion
import com.example.casera.models.TaskCompletionCreateRequest
import com.example.casera.network.ApiResult
import com.example.casera.network.TaskCompletionApi
import com.example.casera.storage.TokenStorage
import com.example.casera.util.ImageCompressor
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -21,7 +21,7 @@ class TaskCompletionViewModel : ViewModel() {
fun createTaskCompletion(request: TaskCompletionCreateRequest) {
viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading
val token = TokenStorage.getToken()
val token = DataManager.authToken.value
if (token != null) {
_createCompletionState.value = taskCompletionApi.createCompletion(token, request)
} else {
@@ -42,7 +42,7 @@ class TaskCompletionViewModel : ViewModel() {
) {
viewModelScope.launch {
_createCompletionState.value = ApiResult.Loading
val token = TokenStorage.getToken()
val token = DataManager.authToken.value
if (token != null) {
// Compress images and prepare for upload
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
}
}