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