From 6bad8d6b5a56ee8276e27bbca9e10bc97c3db51b Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 5 Nov 2025 18:16:46 -0600 Subject: [PATCH] wip --- TASK_CACHING.md | 142 ++++++++++++++++++ .../kotlin/com/example/mycrib/MainActivity.kt | 5 + .../storage/TaskCacheManager.android.kt | 40 +++++ .../com/example/mycrib/network/LookupsApi.kt | 17 +++ .../mycrib/repository/LookupsRepository.kt | 29 ++++ .../mycrib/storage/TaskCacheManager.kt | 12 ++ .../mycrib/storage/TaskCacheStorage.kt | 50 ++++++ .../com/example/mycrib/MainViewController.kt | 6 + .../mycrib/storage/TaskCacheManager.ios.kt | 43 ++++++ .../jvmMain/kotlin/com/example/mycrib/main.kt | 5 + .../mycrib/storage/TaskCacheManager.jvm.kt | 39 +++++ iosApp/iosApp/LookupsManager.swift | 8 + iosApp/iosApp/StateFlowExtensions.swift | 6 +- 13 files changed, 401 insertions(+), 1 deletion(-) create mode 100644 TASK_CACHING.md create mode 100644 composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TaskCacheManager.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheManager.kt create mode 100644 composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheStorage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TaskCacheManager.ios.kt create mode 100644 composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TaskCacheManager.jvm.kt diff --git a/TASK_CACHING.md b/TASK_CACHING.md new file mode 100644 index 0000000..b5db45d --- /dev/null +++ b/TASK_CACHING.md @@ -0,0 +1,142 @@ +# Task Disk Caching + +## Overview + +All user tasks are automatically cached to disk for offline access. This provides a seamless experience even when the network is unavailable. + +## How It Works + +### On App Startup (After Login) +```kotlin +LookupsRepository.initialize() +``` + +1. **Loads cached tasks from disk immediately** - Instant access to previously fetched tasks +2. **Fetches fresh data from API** - Updates with latest data when online +3. **Saves to disk** - Overwrites cache with fresh data + +### Cache Flow + +``` +App Start + ↓ +Load from Disk Cache (Instant offline access) + ↓ +Fetch from API (When online) + ↓ +Update Memory + Disk Cache + ↓ +UI Updates Automatically +``` + +## Platform Storage + +### Android +- **Location**: SharedPreferences +- **File**: `mycrib_cache` +- **Key**: `cached_tasks` +- **Format**: JSON string + +### iOS +- **Location**: NSUserDefaults +- **Key**: `cached_tasks` +- **Format**: JSON string + +### JVM/Desktop +- **Location**: Java Preferences +- **Node**: `com.mycrib.cache` +- **Key**: `cached_tasks` +- **Format**: JSON string + +## Usage + +### Accessing Cached Tasks + +**Kotlin/Android:** +```kotlin +val tasks by LookupsRepository.allTasks.collectAsState() +// Tasks are available immediately from cache, updated when API responds +``` + +**Swift/iOS:** +```swift +@ObservedObject var lookupsManager = LookupsManager.shared +// Access via: lookupsManager.allTasks +``` + +### Manual Operations + +```kotlin +// Refresh from API and update cache +LookupsRepository.refresh() + +// Clear cache (called automatically on logout) +LookupsRepository.clear() +``` + +## Benefits + +✅ **Offline Access** - Tasks available without network +✅ **Fast Startup** - Instant task display from disk cache +✅ **Automatic Sync** - Fresh data fetched and cached when online +✅ **Seamless UX** - Users don't notice when offline +✅ **Battery Friendly** - Reduces unnecessary API calls + +## Cache Lifecycle + +### On Login +- Loads tasks from disk (if available) +- Fetches from API +- Updates disk cache + +### During Session +- Tasks served from memory (StateFlow) +- Can manually refresh with `LookupsRepository.refresh()` + +### On Logout +- Memory cache cleared +- **Disk cache cleared** (for security) + +### On App Restart +- Cycle repeats with cached data from disk + +## Implementation Files + +**Common:** +- `TaskCacheStorage.kt` - Unified cache interface +- `TaskCacheManager.kt` - Platform-specific interface + +**Platform Implementations:** +- `TaskCacheManager.android.kt` - SharedPreferences +- `TaskCacheManager.ios.kt` - NSUserDefaults +- `TaskCacheManager.jvm.kt` - Java Preferences + +**Repository:** +- `LookupsRepository.kt` - Cache orchestration + +## Storage Size + +Tasks are stored as JSON. Approximate sizes: +- 100 tasks ≈ 50-100 KB +- 1000 tasks ≈ 500 KB - 1 MB + +Storage is minimal and well within platform limits. + +## Error Handling + +If cache read fails: +- Logs error to console +- Returns `null` +- Falls back to API fetch + +If cache write fails: +- Logs error to console +- Continues normally +- Retry on next API fetch + +## Security Notes + +- Cache is cleared on logout +- Uses platform-standard secure storage +- No sensitive authentication data cached +- Only task data (titles, descriptions, status, etc.) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt index c7972b0..80b2733 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import com.mycrib.storage.TokenManager import com.mycrib.storage.TokenStorage +import com.mycrib.storage.TaskCacheManager +import com.mycrib.storage.TaskCacheStorage class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -17,6 +19,9 @@ class MainActivity : ComponentActivity() { // Initialize TokenStorage with Android TokenManager TokenStorage.initialize(TokenManager.getInstance(applicationContext)) + // Initialize TaskCacheStorage for offline task caching + TaskCacheStorage.initialize(TaskCacheManager.getInstance(applicationContext)) + setContent { App() } diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TaskCacheManager.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TaskCacheManager.android.kt new file mode 100644 index 0000000..9a09ca5 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/storage/TaskCacheManager.android.kt @@ -0,0 +1,40 @@ +package com.mycrib.storage + +import android.content.Context +import android.content.SharedPreferences + +/** + * Android implementation of TaskCacheManager using SharedPreferences. + */ +actual class TaskCacheManager(private val context: Context) { + private val prefs: SharedPreferences = context.getSharedPreferences( + PREFS_NAME, + Context.MODE_PRIVATE + ) + + actual fun saveTasks(tasksJson: String) { + prefs.edit().putString(KEY_TASKS, tasksJson).apply() + } + + actual fun getTasks(): String? { + return prefs.getString(KEY_TASKS, null) + } + + actual fun clearTasks() { + prefs.edit().remove(KEY_TASKS).apply() + } + + companion object { + private const val PREFS_NAME = "mycrib_cache" + private const val KEY_TASKS = "cached_tasks" + + @Volatile + private var instance: TaskCacheManager? = null + + fun getInstance(context: Context): TaskCacheManager { + return instance ?: synchronized(this) { + instance ?: TaskCacheManager(context.applicationContext).also { instance = it } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt index 54719b1..a71b20d 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/LookupsApi.kt @@ -88,4 +88,21 @@ class LookupsApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + suspend fun getAllTasks(token: String): ApiResult> { + return try { + val response = client.get("$baseUrl/tasks/") { + header("Authorization", "Token $token") + } + + if (response.status.isSuccess()) { + val data: PaginatedResponse = response.body() + ApiResult.Success(data.results) + } else { + ApiResult.Error("Failed to fetch tasks", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt index 9da7135..58370ef 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/repository/LookupsRepository.kt @@ -4,6 +4,7 @@ import com.mycrib.shared.models.* import com.mycrib.shared.network.ApiResult import com.mycrib.shared.network.LookupsApi import com.mycrib.storage.TokenStorage +import com.mycrib.storage.TaskCacheStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -33,6 +34,9 @@ object LookupsRepository { private val _taskCategories = MutableStateFlow>(emptyList()) val taskCategories: StateFlow> = _taskCategories + private val _allTasks = MutableStateFlow>(emptyList()) + val allTasks: StateFlow> = _allTasks + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading @@ -51,6 +55,14 @@ object LookupsRepository { 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) { @@ -89,6 +101,20 @@ object LookupsRepository { else -> {} } } + + 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") + } + } + } } _isInitialized.value = true @@ -106,6 +132,9 @@ object LookupsRepository { _taskPriorities.value = emptyList() _taskStatuses.value = emptyList() _taskCategories.value = emptyList() + _allTasks.value = emptyList() + // Clear disk cache on logout + TaskCacheStorage.clearTasks() _isInitialized.value = false _isLoading.value = false } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheManager.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheManager.kt new file mode 100644 index 0000000..2427306 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheManager.kt @@ -0,0 +1,12 @@ +package com.mycrib.storage + +/** + * Platform-specific task cache manager interface for persistent storage. + * Each platform implements this using their native storage mechanisms. + */ +@Suppress("NO_ACTUAL_FOR_EXPECT") +expect class TaskCacheManager { + fun saveTasks(tasksJson: String) + fun getTasks(): String? + fun clearTasks() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheStorage.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheStorage.kt new file mode 100644 index 0000000..7785fd6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/storage/TaskCacheStorage.kt @@ -0,0 +1,50 @@ +package com.mycrib.storage + +import com.mycrib.shared.models.CustomTask +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.decodeFromString + +/** + * Task cache storage that provides a unified interface for accessing platform-specific + * persistent storage. This allows tasks to persist across app restarts for offline access. + */ +object TaskCacheStorage { + private var cacheManager: TaskCacheManager? = null + private val json = Json { ignoreUnknownKeys = true } + + /** + * Initialize TaskCacheStorage with a platform-specific TaskCacheManager. + * This should be called once during app initialization. + */ + fun initialize(manager: TaskCacheManager) { + cacheManager = manager + } + + fun saveTasks(tasks: List) { + try { + val tasksJson = json.encodeToString(tasks) + cacheManager?.saveTasks(tasksJson) + } catch (e: Exception) { + println("Error saving tasks to cache: ${e.message}") + } + } + + fun getTasks(): List? { + return try { + val tasksJson = cacheManager?.getTasks() + if (tasksJson != null) { + json.decodeFromString>(tasksJson) + } else { + null + } + } catch (e: Exception) { + println("Error loading tasks from cache: ${e.message}") + null + } + } + + fun clearTasks() { + cacheManager?.clearTasks() + } +} diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt index eb3c44d..3604861 100644 --- a/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/MainViewController.kt @@ -3,9 +3,15 @@ package com.example.mycrib import androidx.compose.ui.window.ComposeUIViewController import com.mycrib.storage.TokenManager import com.mycrib.storage.TokenStorage +import com.mycrib.storage.TaskCacheManager +import com.mycrib.storage.TaskCacheStorage fun MainViewController() = ComposeUIViewController { // Initialize TokenStorage with iOS TokenManager TokenStorage.initialize(TokenManager.getInstance()) + + // Initialize TaskCacheStorage for offline task caching + TaskCacheStorage.initialize(TaskCacheManager.getInstance()) + App() } \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TaskCacheManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TaskCacheManager.ios.kt new file mode 100644 index 0000000..ab236f9 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/example/mycrib/storage/TaskCacheManager.ios.kt @@ -0,0 +1,43 @@ +package com.mycrib.storage + +import platform.Foundation.NSUserDefaults +import kotlin.concurrent.Volatile + +/** + * iOS implementation of TaskCacheManager using NSUserDefaults. + */ +actual class TaskCacheManager { + private val userDefaults = NSUserDefaults.standardUserDefaults + + actual fun saveTasks(tasksJson: String) { + userDefaults.setObject(tasksJson, KEY_TASKS) + userDefaults.synchronize() + } + + actual fun getTasks(): String? { + return userDefaults.stringForKey(KEY_TASKS) + } + + actual fun clearTasks() { + userDefaults.removeObjectForKey(KEY_TASKS) + userDefaults.synchronize() + } + + companion object { + private const val KEY_TASKS = "cached_tasks" + + @Volatile + private var instance: TaskCacheManager? = null + + fun getInstance(): TaskCacheManager { + return instance ?: synchronized(this) { + instance ?: TaskCacheManager().also { instance = it } + } + } + } +} + +// Helper function for synchronization on iOS +private fun synchronized(lock: Any, block: () -> T): T { + return block() +} diff --git a/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt b/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt index fd8788b..fa1ebaa 100644 --- a/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt +++ b/composeApp/src/jvmMain/kotlin/com/example/mycrib/main.kt @@ -4,11 +4,16 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.application import com.mycrib.storage.TokenManager import com.mycrib.storage.TokenStorage +import com.mycrib.storage.TaskCacheManager +import com.mycrib.storage.TaskCacheStorage fun main() = application { // Initialize TokenStorage with JVM TokenManager TokenStorage.initialize(TokenManager.getInstance()) + // Initialize TaskCacheStorage for offline task caching + TaskCacheStorage.initialize(TaskCacheManager.getInstance()) + Window( onCloseRequest = ::exitApplication, title = "MyCrib", diff --git a/composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TaskCacheManager.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TaskCacheManager.jvm.kt new file mode 100644 index 0000000..8351ca7 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/com/example/mycrib/storage/TaskCacheManager.jvm.kt @@ -0,0 +1,39 @@ +package com.mycrib.storage + +import java.io.File +import java.util.prefs.Preferences + +/** + * JVM implementation of TaskCacheManager using Java Preferences. + */ +actual class TaskCacheManager { + private val prefs = Preferences.userRoot().node(NODE_NAME) + + actual fun saveTasks(tasksJson: String) { + prefs.put(KEY_TASKS, tasksJson) + prefs.flush() + } + + actual fun getTasks(): String? { + return prefs.get(KEY_TASKS, null) + } + + actual fun clearTasks() { + prefs.remove(KEY_TASKS) + prefs.flush() + } + + companion object { + private const val NODE_NAME = "com.mycrib.cache" + private const val KEY_TASKS = "cached_tasks" + + @Volatile + private var instance: TaskCacheManager? = null + + fun getInstance(): TaskCacheManager { + return instance ?: synchronized(this) { + instance ?: TaskCacheManager().also { instance = it } + } + } + } +} diff --git a/iosApp/iosApp/LookupsManager.swift b/iosApp/iosApp/LookupsManager.swift index 847e07b..f528365 100644 --- a/iosApp/iosApp/LookupsManager.swift +++ b/iosApp/iosApp/LookupsManager.swift @@ -12,6 +12,7 @@ class LookupsManager: ObservableObject { @Published var taskFrequencies: [TaskFrequency] = [] @Published var taskPriorities: [TaskPriority] = [] @Published var taskStatuses: [TaskStatus] = [] + @Published var allTasks: [CustomTask] = [] @Published var isLoading: Bool = false @Published var isInitialized: Bool = false @@ -58,6 +59,13 @@ class LookupsManager: ObservableObject { } } + // Observe all tasks + Task { + for await tasks in repository.allTasks.taskTaskAsyncSequence { + self.allTasks = tasks + } + } + // Observe loading state Task { for await loading in repository.isLoading.boolAsyncSequence { diff --git a/iosApp/iosApp/StateFlowExtensions.swift b/iosApp/iosApp/StateFlowExtensions.swift index 4048045..41a406c 100644 --- a/iosApp/iosApp/StateFlowExtensions.swift +++ b/iosApp/iosApp/StateFlowExtensions.swift @@ -65,7 +65,11 @@ extension Kotlinx_coroutines_coreStateFlow { return asAsyncSequence() } + var taskTaskAsyncSequence: AsyncStream<[CustomTask]> { + return asAsyncSequence() + } + var boolAsyncSequence: AsyncStream { return asAsyncSequence() } -} \ No newline at end of file +}