Add custom interval days support for task frequency
- Add customIntervalDays field to Kotlin models (TaskResponse, TaskCreateRequest, TaskUpdateRequest) - Update Android AddTaskDialog to show interval field only for "Custom" frequency - Update Android EditTaskScreen for custom frequency support - Update iOS TaskFormView for custom frequency support - Fix preview data in TaskCard and TasksSection to include new field - Add customIntervalDays to OnboardingFirstTaskView 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -195,8 +195,9 @@
|
||||
<string name="tasks_browse_templates">Browse Task Templates</string>
|
||||
<string name="tasks_common_tasks">%1$d common tasks</string>
|
||||
<string name="tasks_category_error">Category is required</string>
|
||||
<string name="tasks_interval_days">Interval Days (optional)</string>
|
||||
<string name="tasks_interval_days">Interval Days</string>
|
||||
<string name="tasks_interval_override">Override default frequency interval</string>
|
||||
<string name="tasks_custom_interval_help">Number of days between each occurrence</string>
|
||||
<string name="tasks_due_date_format_error">Due date is required (format: YYYY-MM-DD)</string>
|
||||
<string name="tasks_due_date_format">Format: YYYY-MM-DD</string>
|
||||
<string name="tasks_create">Create Task</string>
|
||||
|
||||
@@ -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<RegisterRoute> { inclusive = true }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Unit> {
|
||||
// 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<TaskColumnsResponse> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user