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:
Trey t
2025-12-13 19:05:59 -06:00
parent 3140c75815
commit 33ee445aea
20 changed files with 524 additions and 238 deletions

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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 }
}

View File

@@ -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.

View File

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

View File

@@ -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
}
}
}

View File

@@ -9,7 +9,7 @@ package com.example.casera.network
*/
object ApiConfig {
// ⚠️ CHANGE THIS TO TOGGLE ENVIRONMENT ⚠️
val CURRENT_ENV = Environment.LOCAL
val CURRENT_ENV = Environment.DEV
enum class Environment {
LOCAL,

View File

@@ -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()
}

View File

@@ -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()
}
}
/**

View File

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

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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