diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt index 13d08fd..2a4f3b5 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/MainActivity.kt @@ -219,6 +219,14 @@ class MainActivity : ComponentActivity(), SingletonImageLoader.Factory { lifecycleScope.launch { Log.d("MainActivity", "🔄 App resumed, checking for lookup updates...") APILayer.refreshLookupsIfChanged() + + // Check if widget completed a task - refresh data if dirty + if (TaskCacheStorage.areTasksDirty()) { + Log.d("MainActivity", "🔄 Tasks marked dirty by widget, refreshing...") + TaskCacheStorage.clearDirtyFlag() + // Force refresh tasks from API + APILayer.getTasks(forceRefresh = true) + } } } diff --git a/composeApp/src/androidMain/kotlin/com/example/casera/storage/TaskCacheManager.android.kt b/composeApp/src/androidMain/kotlin/com/example/casera/storage/TaskCacheManager.android.kt index 95c72a7..f396e13 100644 --- a/composeApp/src/androidMain/kotlin/com/example/casera/storage/TaskCacheManager.android.kt +++ b/composeApp/src/androidMain/kotlin/com/example/casera/storage/TaskCacheManager.android.kt @@ -24,9 +24,32 @@ actual class TaskCacheManager(private val context: Context) { prefs.edit().remove(KEY_TASKS).apply() } + /** + * Check if tasks need refresh due to widget interactions. + */ + actual fun areTasksDirty(): Boolean { + return prefs.getBoolean(KEY_DIRTY_FLAG, false) + } + + /** + * Mark tasks as dirty (needs refresh). + * Called when widget modifies task data. + */ + actual fun markTasksDirty() { + prefs.edit().putBoolean(KEY_DIRTY_FLAG, true).apply() + } + + /** + * Clear the dirty flag after tasks have been refreshed. + */ + actual fun clearDirtyFlag() { + prefs.edit().putBoolean(KEY_DIRTY_FLAG, false).apply() + } + companion object { private const val PREFS_NAME = "mycrib_cache" private const val KEY_TASKS = "cached_tasks" + private const val KEY_DIRTY_FLAG = "tasks_dirty" @Volatile private var instance: TaskCacheManager? = null diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c746dcc..3079b01 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -195,8 +195,9 @@ Browse Task Templates %1$d common tasks Category is required - Interval Days (optional) + Interval Days Override default frequency interval + Number of days between each occurrence Due date is required (format: YYYY-MM-DD) Format: YYYY-MM-DD Create Task diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index add9612..afc324b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -173,9 +173,7 @@ fun App( hasCompletedOnboarding = true isLoggedIn = true isVerified = true - - // Initialize lookups after onboarding - LookupsRepository.initialize() + // Note: Lookups are already initialized by APILayer during login/register // Navigate to main screen navController.navigate(MainRoute) { @@ -188,9 +186,7 @@ fun App( hasCompletedOnboarding = true isLoggedIn = true isVerified = verified - - // Initialize lookups after login - LookupsRepository.initialize() + // Note: Lookups are already initialized by APILayer.login() if (verified) { navController.navigate(MainRoute) { @@ -210,8 +206,7 @@ fun App( onLoginSuccess = { user -> isLoggedIn = true isVerified = user.verified - // Initialize lookups after successful login - LookupsRepository.initialize() + // Note: Lookups are already initialized by APILayer.login() // Check if user is verified if (user.verified) { @@ -238,8 +233,7 @@ fun App( onRegisterSuccess = { isLoggedIn = true isVerified = false - // Initialize lookups after successful registration - LookupsRepository.initialize() + // Note: Lookups are already initialized by APILayer.register() navController.navigate(VerifyEmailRoute) { popUpTo { inclusive = true } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt index 26fc633..2c290f8 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/data/DataManager.kt @@ -351,6 +351,30 @@ object DataManager { persistToDisk() } + /** + * Filter cached allTasks by residence ID to avoid separate API call. + * Returns null if allTasks not cached. + * This enables client-side filtering when we already have all tasks loaded. + */ + fun getTasksForResidence(residenceId: Int): TaskColumnsResponse? { + val allTasksData = _allTasks.value ?: return null + + // Filter each column's tasks by residence ID + val filteredColumns = allTasksData.columns.map { column -> + column.copy( + tasks = column.tasks.filter { it.residenceId == residenceId }, + count = column.tasks.count { it.residenceId == residenceId } + ) + } + + return TaskColumnsResponse( + columns = filteredColumns, + daysThreshold = allTasksData.daysThreshold, + residenceId = residenceId.toString(), + summary = null // Summary is global; residence-specific not available client-side + ) + } + /** * 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. diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt index 780db19..0d66e67 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/CustomTask.kt @@ -43,6 +43,7 @@ data class TaskResponse( @SerialName("in_progress") val inProgress: Boolean = false, @SerialName("frequency_id") val frequencyId: Int? = null, val frequency: TaskFrequency? = null, + @SerialName("custom_interval_days") val customIntervalDays: Int? = null, // For "Custom" frequency, user-specified days @SerialName("due_date") val dueDate: String? = null, @SerialName("next_due_date") val nextDueDate: String? = null, // For recurring tasks, updated after each completion @SerialName("estimated_cost") val estimatedCost: Double? = null, @@ -123,6 +124,7 @@ data class TaskCreateRequest( @SerialName("priority_id") val priorityId: Int? = null, @SerialName("in_progress") val inProgress: Boolean = false, @SerialName("frequency_id") val frequencyId: Int? = null, + @SerialName("custom_interval_days") val customIntervalDays: Int? = null, // For "Custom" frequency @SerialName("assigned_to_id") val assignedToId: Int? = null, @SerialName("due_date") val dueDate: String? = null, @SerialName("estimated_cost") val estimatedCost: Double? = null, @@ -141,6 +143,7 @@ data class TaskUpdateRequest( @SerialName("priority_id") val priorityId: Int? = null, @SerialName("in_progress") val inProgress: Boolean? = null, @SerialName("frequency_id") val frequencyId: Int? = null, + @SerialName("custom_interval_days") val customIntervalDays: Int? = null, // For "Custom" frequency @SerialName("assigned_to_id") val assignedToId: Int? = null, @SerialName("due_date") val dueDate: String? = null, @SerialName("estimated_cost") val estimatedCost: Double? = null, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 4007c11..391fb62 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -27,6 +27,20 @@ object APILayer { private val subscriptionApi = SubscriptionApi() private val taskTemplateApi = TaskTemplateApi() + // ==================== Initialization Guards ==================== + + /** + * Guard to prevent concurrent initialization calls. + * This prevents multiple initializeLookups() calls from running simultaneously. + */ + private var isInitializingLookups = false + + /** + * Guard to prevent concurrent prefetch calls. + * This prevents multiple prefetchAllData() calls from running simultaneously. + */ + private var isPrefetchingData = false + // ==================== Authentication Helper ==================== private fun getToken(): String? = DataManager.authToken.value @@ -69,6 +83,12 @@ object APILayer { * - /subscription/status/ requires auth and is only called if user is authenticated */ suspend fun initializeLookups(): ApiResult { + // Guard: prevent concurrent initialization + if (isInitializingLookups) { + println("📋 [APILayer] Lookups initialization already in progress, skipping...") + return ApiResult.Success(Unit) + } + val token = getToken() val currentETag = DataManager.seededDataETag.value @@ -78,6 +98,7 @@ object APILayer { return refreshLookupsIfChanged() } + isInitializingLookups = true try { // Use seeded data endpoint with ETag support (PUBLIC - no auth required) // Only send ETag if lookups are already in memory - otherwise we need full data @@ -138,6 +159,8 @@ object APILayer { return ApiResult.Success(Unit) } catch (e: Exception) { return ApiResult.Error("Failed to initialize lookups: ${e.message}") + } finally { + isInitializingLookups = false } } @@ -517,7 +540,7 @@ object APILayer { } suspend fun getTasksByResidence(residenceId: Int, forceRefresh: Boolean = false): ApiResult { - // Check DataManager first - return cached if valid and not forcing refresh + // 1. Check residence-specific cache first if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksByResidenceCacheTime[residenceId] ?: 0L)) { val cached = DataManager.tasksByResidence.value[residenceId] if (cached != null) { @@ -525,7 +548,18 @@ object APILayer { } } - // Fetch from API + // 2. Try filtering from allTasks cache before hitting API (optimization) + // This avoids a redundant API call when we already have all tasks loaded + if (!forceRefresh && DataManager.isCacheValid(DataManager.tasksCacheTime)) { + val filtered = DataManager.getTasksForResidence(residenceId) + if (filtered != null) { + // Cache the filtered result for future use + DataManager.setTasksForResidence(residenceId, filtered) + return ApiResult.Success(filtered) + } + } + + // 3. Fallback: Fetch from API val token = getToken() ?: return ApiResult.Error("Not authenticated", 401) val result = taskApi.getTasksByResidence(token, residenceId) @@ -1112,6 +1146,8 @@ object APILayer { DataManager.setCurrentUser(result.data.user) // Initialize lookups after successful registration initializeLookups() + // Prefetch all data (user may have shared residences) + prefetchAllData() } return result @@ -1285,15 +1321,40 @@ object APILayer { // ==================== Helper Methods ==================== /** - * Prefetch all data after login + * Prefetch all data after login. + * Uses guards to prevent concurrent calls and skips if data is already cached. */ private suspend fun prefetchAllData() { + // Guard: prevent concurrent prefetch calls + if (isPrefetchingData) { + println("📋 [APILayer] Data prefetch already in progress, skipping...") + return + } + + // Skip if data is already cached (within cache validity period) + val residencesCached = DataManager.isCacheValid(DataManager.myResidencesCacheTime) && + DataManager.myResidences.value != null + val tasksCached = DataManager.isCacheValid(DataManager.tasksCacheTime) && + DataManager.allTasks.value != null + + if (residencesCached && tasksCached) { + println("📋 [APILayer] Data already cached, skipping prefetch...") + return + } + + isPrefetchingData = true try { - // Fetch key data in parallel - these all update DataManager - getMyResidences(forceRefresh = true) - getTasks(forceRefresh = true) + // Fetch key data - these all update DataManager + if (!residencesCached) { + getMyResidences(forceRefresh = true) + } + if (!tasksCached) { + getTasks(forceRefresh = true) + } } catch (e: Exception) { println("Error prefetching data: ${e.message}") + } finally { + isPrefetchingData = false } } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt index 0829e7c..cd5e298 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/ApiConfig.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheManager.kt b/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheManager.kt index 852a442..6047db6 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheManager.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheManager.kt @@ -9,4 +9,21 @@ expect class TaskCacheManager { fun saveTasks(tasksJson: String) fun getTasks(): String? fun clearTasks() + + /** + * Check if tasks need refresh due to widget interactions. + * Returns true if data was modified externally (e.g., by a widget). + */ + fun areTasksDirty(): Boolean + + /** + * Mark tasks as dirty (needs refresh). + * Called when widget modifies task data. + */ + fun markTasksDirty() + + /** + * Clear the dirty flag after tasks have been refreshed. + */ + fun clearDirtyFlag() } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheStorage.kt b/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheStorage.kt index c42f569..4da859b 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/storage/TaskCacheStorage.kt @@ -56,6 +56,31 @@ object TaskCacheStorage { ensureInitialized() cacheManager?.clearTasks() } + + /** + * Check if tasks need refresh due to widget interactions. + */ + fun areTasksDirty(): Boolean { + ensureInitialized() + return cacheManager?.areTasksDirty() ?: false + } + + /** + * Mark tasks as dirty (needs refresh). + * Called when widget modifies task data. + */ + fun markTasksDirty() { + ensureInitialized() + cacheManager?.markTasksDirty() + } + + /** + * Clear the dirty flag after tasks have been refreshed. + */ + fun clearDirtyFlag() { + ensureInitialized() + cacheManager?.clearDirtyFlag() + } } /** diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt index d762ee4..bb51454 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/components/AddTaskDialog.kt @@ -326,8 +326,8 @@ fun AddTaskDialog( onClick = { frequency = freq showFrequencyDropdown = false - // Clear interval days if frequency is "once" - if (freq.name == "once") { + // Clear interval days if frequency is not "Custom" + if (!freq.name.equals("Custom", ignoreCase = true)) { intervalDays = "" } } @@ -336,15 +336,15 @@ fun AddTaskDialog( } } - // Interval Days (only for recurring tasks) - if (frequency.name != "once") { + // Custom Interval Days (only for "Custom" frequency) + if (frequency.name.equals("Custom", ignoreCase = true)) { OutlinedTextField( value = intervalDays, onValueChange = { intervalDays = it.filter { char -> char.isDigit() } }, label = { Text(stringResource(Res.string.tasks_interval_days)) }, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - supportingText = { Text(stringResource(Res.string.tasks_interval_override)) }, + supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) }, singleLine = true ) } @@ -450,6 +450,9 @@ fun AddTaskDialog( description = description.ifBlank { null }, categoryId = if (category.id > 0) category.id else null, frequencyId = if (frequency.id > 0) frequency.id else null, + customIntervalDays = if (frequency.name.equals("Custom", ignoreCase = true) && intervalDays.isNotBlank()) { + intervalDays.toIntOrNull() + } else null, priorityId = if (priority.id > 0) priority.id else null, inProgress = false, dueDate = dueDate, diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/EditTaskScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/EditTaskScreen.kt index 13fc7a3..3cec129 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/EditTaskScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/EditTaskScreen.kt @@ -36,6 +36,7 @@ fun EditTaskScreen( var inProgress by remember { mutableStateOf(task.inProgress) } var dueDate by remember { mutableStateOf(task.dueDate ?: "") } var estimatedCost by remember { mutableStateOf(task.estimatedCost?.toString() ?: "") } + var customIntervalDays by remember { mutableStateOf(task.customIntervalDays?.toString() ?: "") } var categoryExpanded by remember { mutableStateOf(false) } var frequencyExpanded by remember { mutableStateOf(false) } @@ -195,12 +196,29 @@ fun EditTaskScreen( onClick = { selectedFrequency = frequency frequencyExpanded = false + // Clear custom interval if not Custom frequency + if (!frequency.name.equals("Custom", ignoreCase = true)) { + customIntervalDays = "" + } } ) } } } + // Custom Interval Days (only for "Custom" frequency) + if (selectedFrequency?.name?.equals("Custom", ignoreCase = true) == true) { + OutlinedTextField( + value = customIntervalDays, + onValueChange = { customIntervalDays = it.filter { char -> char.isDigit() } }, + label = { Text(stringResource(Res.string.tasks_interval_days)) }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + supportingText = { Text(stringResource(Res.string.tasks_custom_interval_help)) }, + singleLine = true + ) + } + // Priority dropdown ExposedDropdownMenuBox( expanded = priorityExpanded, @@ -292,6 +310,9 @@ fun EditTaskScreen( description = description.ifBlank { null }, categoryId = selectedCategory!!.id, frequencyId = selectedFrequency!!.id, + customIntervalDays = if (selectedFrequency?.name?.equals("Custom", ignoreCase = true) == true && customIntervalDays.isNotBlank()) { + customIntervalDays.toIntOrNull() + } else null, priorityId = selectedPriority!!.id, inProgress = inProgress, dueDate = dueDate, diff --git a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TaskCacheManager.ios.kt b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TaskCacheManager.ios.kt index 190ebe2..6adda17 100644 --- a/composeApp/src/iosMain/kotlin/com/example/casera/storage/TaskCacheManager.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/example/casera/storage/TaskCacheManager.ios.kt @@ -5,6 +5,8 @@ import kotlin.concurrent.Volatile /** * iOS implementation of TaskCacheManager using NSUserDefaults. + * Note: iOS widget dirty flag is handled by native Swift WidgetDataManager + * using App Groups shared storage, but we provide these methods for KMM compatibility. */ actual class TaskCacheManager { private val userDefaults = NSUserDefaults.standardUserDefaults @@ -23,8 +25,33 @@ actual class TaskCacheManager { userDefaults.synchronize() } + /** + * Check if tasks need refresh due to widget interactions. + * Note: iOS primarily uses native Swift WidgetDataManager for this. + */ + actual fun areTasksDirty(): Boolean { + return userDefaults.boolForKey(KEY_DIRTY_FLAG) + } + + /** + * Mark tasks as dirty (needs refresh). + */ + actual fun markTasksDirty() { + userDefaults.setBool(true, KEY_DIRTY_FLAG) + userDefaults.synchronize() + } + + /** + * Clear the dirty flag after tasks have been refreshed. + */ + actual fun clearDirtyFlag() { + userDefaults.setBool(false, KEY_DIRTY_FLAG) + userDefaults.synchronize() + } + companion object { private const val KEY_TASKS = "cached_tasks" + private const val KEY_DIRTY_FLAG = "tasks_dirty" @Volatile private var instance: TaskCacheManager? = null diff --git a/composeApp/src/jvmMain/kotlin/com/example/casera/storage/TaskCacheManager.jvm.kt b/composeApp/src/jvmMain/kotlin/com/example/casera/storage/TaskCacheManager.jvm.kt index 88c71b7..e16e5f2 100644 --- a/composeApp/src/jvmMain/kotlin/com/example/casera/storage/TaskCacheManager.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/example/casera/storage/TaskCacheManager.jvm.kt @@ -1,10 +1,10 @@ -package com.casera.storage +package com.example.casera.storage -import java.io.File import java.util.prefs.Preferences /** * JVM implementation of TaskCacheManager using Java Preferences. + * Note: JVM/Desktop doesn't have widgets, so dirty flag methods are no-ops. */ actual class TaskCacheManager { private val prefs = Preferences.userRoot().node(NODE_NAME) @@ -23,9 +23,33 @@ actual class TaskCacheManager { prefs.flush() } + /** + * Check if tasks need refresh. JVM doesn't have widgets, always returns false. + */ + actual fun areTasksDirty(): Boolean { + return prefs.getBoolean(KEY_DIRTY_FLAG, false) + } + + /** + * Mark tasks as dirty. JVM doesn't have widgets but supports the interface. + */ + actual fun markTasksDirty() { + prefs.putBoolean(KEY_DIRTY_FLAG, true) + prefs.flush() + } + + /** + * Clear the dirty flag. + */ + actual fun clearDirtyFlag() { + prefs.putBoolean(KEY_DIRTY_FLAG, false) + prefs.flush() + } + companion object { private const val NODE_NAME = "com.casera.cache" private const val KEY_TASKS = "cached_tasks" + private const val KEY_DIRTY_FLAG = "tasks_dirty" @Volatile private var instance: TaskCacheManager? = null diff --git a/composeApp/src/wasmJsMain/kotlin/com/example/casera/storage/TaskCacheManager.wasmJs.kt b/composeApp/src/wasmJsMain/kotlin/com/example/casera/storage/TaskCacheManager.wasmJs.kt index 7b1ec35..d552064 100644 --- a/composeApp/src/wasmJsMain/kotlin/com/example/casera/storage/TaskCacheManager.wasmJs.kt +++ b/composeApp/src/wasmJsMain/kotlin/com/example/casera/storage/TaskCacheManager.wasmJs.kt @@ -1,9 +1,10 @@ -package com.casera.storage +package com.example.casera.storage import kotlinx.browser.localStorage /** * WASM implementation of TaskCacheManager using browser's localStorage. + * Note: Web doesn't have widgets, so dirty flag methods use localStorage for consistency. */ actual class TaskCacheManager { actual fun saveTasks(tasksJson: String) { @@ -18,8 +19,30 @@ actual class TaskCacheManager { localStorage.removeItem(KEY_TASKS) } + /** + * Check if tasks need refresh. Web doesn't have widgets, always returns false. + */ + actual fun areTasksDirty(): Boolean { + return localStorage.getItem(KEY_DIRTY_FLAG) == "true" + } + + /** + * Mark tasks as dirty. Web doesn't have widgets but supports the interface. + */ + actual fun markTasksDirty() { + localStorage.setItem(KEY_DIRTY_FLAG, "true") + } + + /** + * Clear the dirty flag. + */ + actual fun clearDirtyFlag() { + localStorage.setItem(KEY_DIRTY_FLAG, "false") + } + companion object { private const val KEY_TASKS = "cached_tasks" + private const val KEY_DIRTY_FLAG = "tasks_dirty" private var instance: TaskCacheManager? = null diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 88d2dfc..7ba80eb 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -16977,6 +16977,9 @@ "Enter the 6-digit code from your email" : { "comment" : "A footer label explaining that users should enter the 6-digit code they received in their email.", "isCommentAutoGenerated" : true + }, + "Enter the number of days between each occurrence" : { + }, "Enter your email address and we'll send you a verification code" : { "comment" : "A description below the email input field, instructing the user to enter their email address to receive a password reset code.", @@ -17747,6 +17750,72 @@ } } }, + "profile_benefit_actionable_notifications" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actionable Notifications" + } + } + } + }, + "profile_benefit_contractor_sharing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contractor Sharing" + } + } + } + }, + "profile_benefit_document_vault" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Document & Warranty Storage" + } + } + } + }, + "profile_benefit_residence_sharing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Residence Sharing" + } + } + } + }, + "profile_benefit_unlimited_properties" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited Properties" + } + } + } + }, + "profile_benefit_widgets" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home Screen Widgets" + } + } + } + }, "profile_contact" : { "extractionState" : "manual", "localizations" : { @@ -17834,6 +17903,136 @@ } } }, + "profile_daily_digest" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tägliche Zusammenfassung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily Summary" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumen diario" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Résumé quotidien" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riepilogo giornaliero" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デイリーサマリー" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일일 요약" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagelijkse samenvatting" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumo diário" + } + }, + "zh" : { + "stringUnit" : { + "state" : "translated", + "value" : "每日摘要" + } + } + } + }, + "profile_daily_digest_description" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tägliche Übersicht über fällige und überfällige Aufgaben" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daily overview of tasks due and overdue" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumen diario de tareas pendientes y vencidas" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aperçu quotidien des tâches à faire et en retard" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Panoramica giornaliera delle attività in scadenza e scadute" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "期限が近いタスクと期限切れのタスクの毎日の概要" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "마감 예정 및 지연된 작업의 일일 개요" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dagelijks overzicht van taken die binnenkort verlopen en achterstallig zijn" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumo diário de tarefas a vencer e atrasadas" + } + }, + "zh" : { + "stringUnit" : { + "state" : "translated", + "value" : "即将到期和逾期任务的每日概览" + } + } + } + }, "profile_edit_profile" : { "extractionState" : "manual", "localizations" : { @@ -19557,83 +19756,6 @@ } } }, - "profile_unlock_premium_features" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unlock Premium Features" - } - } - } - }, - "profile_benefit_unlimited_properties" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Unlimited Properties" - } - } - } - }, - "profile_benefit_document_vault" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Document & Warranty Storage" - } - } - } - }, - "profile_benefit_residence_sharing" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Residence Sharing" - } - } - } - }, - "profile_benefit_contractor_sharing" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Contractor Sharing" - } - } - } - }, - "profile_benefit_actionable_notifications" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Actionable Notifications" - } - } - } - }, - "profile_benefit_widgets" : { - "extractionState" : "manual", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Home Screen Widgets" - } - } - } - }, "profile_support" : { "extractionState" : "manual", "localizations" : { @@ -20360,6 +20482,17 @@ } } }, + "profile_unlock_premium_features" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock Premium Features" + } + } + } + }, "profile_upgrade_to_pro" : { "extractionState" : "manual", "localizations" : { @@ -20620,136 +20753,6 @@ } } }, - "profile_daily_digest" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tägliche Zusammenfassung" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daily Summary" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumen diario" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Résumé quotidien" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Riepilogo giornaliero" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "デイリーサマリー" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "일일 요약" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dagelijkse samenvatting" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumo diário" - } - }, - "zh" : { - "stringUnit" : { - "state" : "translated", - "value" : "每日摘要" - } - } - } - }, - "profile_daily_digest_description" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tägliche Übersicht über fällige und überfällige Aufgaben" - } - }, - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Daily overview of tasks due and overdue" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumen diario de tareas pendientes y vencidas" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Aperçu quotidien des tâches à faire et en retard" - } - }, - "it" : { - "stringUnit" : { - "state" : "translated", - "value" : "Panoramica giornaliera delle attività in scadenza e scadute" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "期限が近いタスクと期限切れのタスクの毎日の概要" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "마감 예정 및 지연된 작업의 일일 개요" - } - }, - "nl" : { - "stringUnit" : { - "state" : "translated", - "value" : "Dagelijks overzicht van taken die binnenkort verlopen en achterstallig zijn" - } - }, - "pt" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumo diário de tarefas a vencer e atrasadas" - } - }, - "zh" : { - "stringUnit" : { - "state" : "translated", - "value" : "即将到期和逾期任务的每日概览" - } - } - } - }, "properties_add_button" : { "extractionState" : "manual", "localizations" : { diff --git a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift index 0b9b248..8a0979e 100644 --- a/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingFirstTaskView.swift @@ -363,6 +363,7 @@ struct OnboardingFirstTaskContent: View { priorityId: nil, inProgress: false, frequencyId: frequencyId.map { KotlinInt(int: $0) }, + customIntervalDays: nil, assignedToId: nil, dueDate: todayString, estimatedCost: nil, diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 94c356b..90619bd 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -261,6 +261,7 @@ struct TaskCard: View { inProgress: false, frequencyId: 1, frequency: TaskFrequency(id: 1, name: "monthly", days: 30, displayOrder: 0), + customIntervalDays: nil, dueDate: "2024-12-15", nextDueDate: nil, estimatedCost: 150.00, diff --git a/iosApp/iosApp/Subviews/Task/TasksSection.swift b/iosApp/iosApp/Subviews/Task/TasksSection.swift index fb05fa0..f4aafe6 100644 --- a/iosApp/iosApp/Subviews/Task/TasksSection.swift +++ b/iosApp/iosApp/Subviews/Task/TasksSection.swift @@ -95,6 +95,7 @@ struct TasksSection: View { inProgress: false, frequencyId: 1, frequency: TaskFrequency(id: 1, name: "monthly", days: 30, displayOrder: 0), + customIntervalDays: nil, dueDate: "2024-12-15", nextDueDate: nil, estimatedCost: 150.00, @@ -135,6 +136,7 @@ struct TasksSection: View { inProgress: false, frequencyId: 6, frequency: TaskFrequency(id: 6, name: "once", days: nil, displayOrder: 0), + customIntervalDays: nil, dueDate: "2024-11-01", nextDueDate: nil, estimatedCost: 200.00, diff --git a/iosApp/iosApp/Task/TaskFormView.swift b/iosApp/iosApp/Task/TaskFormView.swift index 2419a2b..d4575e9 100644 --- a/iosApp/iosApp/Task/TaskFormView.swift +++ b/iosApp/iosApp/Task/TaskFormView.swift @@ -71,7 +71,7 @@ struct TaskFormView: View { formatter.dateFormat = "yyyy-MM-dd" _dueDate = State(initialValue: formatter.date(from: task.effectiveDueDate ?? "") ?? Date()) - _intervalDays = State(initialValue: "") // No longer in API + _intervalDays = State(initialValue: task.customIntervalDays != nil ? String(task.customIntervalDays!.int32Value) : "") _estimatedCost = State(initialValue: task.estimatedCost != nil ? String(task.estimatedCost!.doubleValue) : "") } else { _title = State(initialValue: "") @@ -220,8 +220,15 @@ struct TaskFormView: View { Text(frequency.displayName).tag(frequency as TaskFrequency?) } } + .onChange(of: selectedFrequency) { newFrequency in + // Clear interval days if not Custom frequency + if newFrequency?.name.lowercased() != "custom" { + intervalDays = "" + } + } - if selectedFrequency?.name != "once" { + // Show custom interval field only for "Custom" frequency + if selectedFrequency?.name.lowercased() == "custom" { TextField(L10n.Tasks.customInterval, text: $intervalDays) .keyboardType(.numberPad) .focused($focusedField, equals: .intervalDays) @@ -231,9 +238,15 @@ struct TaskFormView: View { } header: { Text(L10n.Tasks.scheduling) } footer: { - Text(L10n.Tasks.required) - .font(.caption) - .foregroundColor(Color.appError) + if selectedFrequency?.name.lowercased() == "custom" { + Text("Enter the number of days between each occurrence") + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } else { + Text(L10n.Tasks.required) + .font(.caption) + .foregroundColor(Color.appError) + } } .listRowBackground(Color.appBackgroundSecondary) @@ -460,6 +473,11 @@ struct TaskFormView: View { if isEditMode, let task = existingTask { // UPDATE existing task + // Include customIntervalDays only for "Custom" frequency + let customInterval: KotlinInt? = frequency.name.lowercased() == "custom" && !intervalDays.isEmpty + ? KotlinInt(int: Int32(intervalDays) ?? 0) + : nil + let request = TaskCreateRequest( residenceId: task.residenceId, title: title, @@ -468,6 +486,7 @@ struct TaskFormView: View { priorityId: KotlinInt(int: Int32(priority.id)), inProgress: inProgress, frequencyId: KotlinInt(int: Int32(frequency.id)), + customIntervalDays: customInterval, assignedToId: nil, dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0), @@ -491,6 +510,11 @@ struct TaskFormView: View { return } + // Include customIntervalDays only for "Custom" frequency + let customIntervalCreate: KotlinInt? = frequency.name.lowercased() == "custom" && !intervalDays.isEmpty + ? KotlinInt(int: Int32(intervalDays) ?? 0) + : nil + let request = TaskCreateRequest( residenceId: actualResidenceId, title: title, @@ -499,6 +523,7 @@ struct TaskFormView: View { priorityId: KotlinInt(int: Int32(priority.id)), inProgress: inProgress, frequencyId: KotlinInt(int: Int32(frequency.id)), + customIntervalDays: customIntervalCreate, assignedToId: nil, dueDate: dueDateString, estimatedCost: estimatedCost.isEmpty ? nil : KotlinDouble(double: Double(estimatedCost) ?? 0.0),