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

View File

@@ -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" : {

View File

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

View File

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

View File

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

View File

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