From 6cbcff116f241e0ac449b2c47e672f7eee44085c Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 7 Dec 2025 22:51:50 -0600 Subject: [PATCH] Add Daily Digest notification preferences with custom time support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dailyDigest and dailyDigestHour fields to Kotlin NotificationPreference model - Update NotificationPreferencesViewModel to support new fields - Add Daily Summary toggle with time picker to Android NotificationPreferencesScreen - Add Daily Summary toggle with time picker to iOS NotificationPreferencesView - Add localized strings for Daily Summary in all 10 languages 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../composeResources/values/strings.xml | 2 + .../com/example/casera/models/Notification.kt | 12 +- .../screens/NotificationPreferencesScreen.kt | 53 +++++++ .../NotificationPreferencesViewModel.kt | 8 +- iosApp/iosApp/Helpers/L10n.swift | 2 + iosApp/iosApp/Localizable.xcstrings | 130 ++++++++++++++++++ .../Profile/NotificationPreferencesView.swift | 60 +++++++- 7 files changed, 261 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d8b834a..c1e5f3d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -399,6 +399,8 @@ When someone shares a property with you Warranty Expiring Reminders for expiring warranties + Daily Summary + Daily overview of tasks due and overdue Email Notifications diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt index 3d1d842..ef4e0f5 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/Notification.kt @@ -41,6 +41,8 @@ data class NotificationPreference( val residenceShared: Boolean = true, @SerialName("warranty_expiring") val warrantyExpiring: Boolean = true, + @SerialName("daily_digest") + val dailyDigest: Boolean = true, // Email preferences @SerialName("email_task_completed") val emailTaskCompleted: Boolean = true, @@ -50,7 +52,9 @@ data class NotificationPreference( @SerialName("task_overdue_hour") val taskOverdueHour: Int? = null, @SerialName("warranty_expiring_hour") - val warrantyExpiringHour: Int? = null + val warrantyExpiringHour: Int? = null, + @SerialName("daily_digest_hour") + val dailyDigestHour: Int? = null ) @Serializable @@ -67,6 +71,8 @@ data class UpdateNotificationPreferencesRequest( val residenceShared: Boolean? = null, @SerialName("warranty_expiring") val warrantyExpiring: Boolean? = null, + @SerialName("daily_digest") + val dailyDigest: Boolean? = null, // Email preferences @SerialName("email_task_completed") val emailTaskCompleted: Boolean? = null, @@ -76,7 +82,9 @@ data class UpdateNotificationPreferencesRequest( @SerialName("task_overdue_hour") val taskOverdueHour: Int? = null, @SerialName("warranty_expiring_hour") - val warrantyExpiringHour: Int? = null + val warrantyExpiringHour: Int? = null, + @SerialName("daily_digest_hour") + val dailyDigestHour: Int? = null ) @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt index 689146e..484a19a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/NotificationPreferencesScreen.kt @@ -37,20 +37,24 @@ fun NotificationPreferencesScreen( var taskAssigned by remember { mutableStateOf(true) } var residenceShared by remember { mutableStateOf(true) } var warrantyExpiring by remember { mutableStateOf(true) } + var dailyDigest by remember { mutableStateOf(true) } var emailTaskCompleted by remember { mutableStateOf(true) } // Custom notification times (local hours) var taskDueSoonHour by remember { mutableStateOf(null) } var taskOverdueHour by remember { mutableStateOf(null) } var warrantyExpiringHour by remember { mutableStateOf(null) } + var dailyDigestHour by remember { mutableStateOf(null) } // Time picker dialog states var showTaskDueSoonTimePicker by remember { mutableStateOf(false) } var showTaskOverdueTimePicker by remember { mutableStateOf(false) } + var showDailyDigestTimePicker by remember { mutableStateOf(false) } // Default local hours when user first enables custom time val defaultTaskDueSoonLocalHour = 14 // 2 PM local val defaultTaskOverdueLocalHour = 9 // 9 AM local + val defaultDailyDigestLocalHour = 8 // 8 AM local // Load preferences on first render LaunchedEffect(Unit) { @@ -67,6 +71,7 @@ fun NotificationPreferencesScreen( taskAssigned = prefs.taskAssigned residenceShared = prefs.residenceShared warrantyExpiring = prefs.warrantyExpiring + dailyDigest = prefs.dailyDigest emailTaskCompleted = prefs.emailTaskCompleted // Load custom notification times (convert from UTC to local) @@ -79,6 +84,9 @@ fun NotificationPreferencesScreen( prefs.warrantyExpiringHour?.let { utcHour -> warrantyExpiringHour = DateUtils.utcHourToLocal(utcHour) } + prefs.dailyDigestHour?.let { utcHour -> + dailyDigestHour = DateUtils.utcHourToLocal(utcHour) + } } } @@ -374,9 +382,54 @@ fun NotificationPreferencesScreen( viewModel.updatePreference(warrantyExpiring = it) } ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = AppSpacing.lg), + color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + ) + + NotificationToggle( + title = stringResource(Res.string.notifications_daily_digest), + description = stringResource(Res.string.notifications_daily_digest_desc), + icon = Icons.Default.Summarize, + iconTint = MaterialTheme.colorScheme.secondary, + checked = dailyDigest, + onCheckedChange = { + dailyDigest = it + viewModel.updatePreference(dailyDigest = it) + } + ) + + // Time picker for Daily Digest + if (dailyDigest) { + NotificationTimePickerRow( + currentHour = dailyDigestHour, + onSetCustomTime = { + val localHour = defaultDailyDigestLocalHour + dailyDigestHour = localHour + val utcHour = DateUtils.localHourToUtc(localHour) + viewModel.updatePreference(dailyDigestHour = utcHour) + }, + onChangeTime = { showDailyDigestTimePicker = true } + ) + } } } + // Daily Digest time picker dialog + if (showDailyDigestTimePicker) { + HourPickerDialog( + currentHour = dailyDigestHour ?: defaultDailyDigestLocalHour, + onHourSelected = { hour -> + dailyDigestHour = hour + val utcHour = DateUtils.localHourToUtc(hour) + viewModel.updatePreference(dailyDigestHour = utcHour) + showDailyDigestTimePicker = false + }, + onDismiss = { showDailyDigestTimePicker = false } + ) + } + // Email Notifications Section Text( stringResource(Res.string.notifications_email_section), diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt index 8c99e88..78f7172 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/NotificationPreferencesViewModel.kt @@ -37,10 +37,12 @@ class NotificationPreferencesViewModel : ViewModel() { taskAssigned: Boolean? = null, residenceShared: Boolean? = null, warrantyExpiring: Boolean? = null, + dailyDigest: Boolean? = null, emailTaskCompleted: Boolean? = null, taskDueSoonHour: Int? = null, taskOverdueHour: Int? = null, - warrantyExpiringHour: Int? = null + warrantyExpiringHour: Int? = null, + dailyDigestHour: Int? = null ) { viewModelScope.launch { _updateState.value = ApiResult.Loading @@ -51,10 +53,12 @@ class NotificationPreferencesViewModel : ViewModel() { taskAssigned = taskAssigned, residenceShared = residenceShared, warrantyExpiring = warrantyExpiring, + dailyDigest = dailyDigest, emailTaskCompleted = emailTaskCompleted, taskDueSoonHour = taskDueSoonHour, taskOverdueHour = taskOverdueHour, - warrantyExpiringHour = warrantyExpiringHour + warrantyExpiringHour = warrantyExpiringHour, + dailyDigestHour = dailyDigestHour ) val result = APILayer.updateNotificationPreferences(request) _updateState.value = when (result) { diff --git a/iosApp/iosApp/Helpers/L10n.swift b/iosApp/iosApp/Helpers/L10n.swift index b1906d3..0a536be 100644 --- a/iosApp/iosApp/Helpers/L10n.swift +++ b/iosApp/iosApp/Helpers/L10n.swift @@ -547,6 +547,8 @@ enum L10n { static var propertySharedDescription: String { String(localized: "profile_property_shared_description") } static var warrantyExpiring: String { String(localized: "profile_warranty_expiring") } static var warrantyExpiringDescription: String { String(localized: "profile_warranty_expiring_description") } + static var dailyDigest: String { String(localized: "profile_daily_digest") } + static var dailyDigestDescription: String { String(localized: "profile_daily_digest_description") } static var otherNotifications: String { String(localized: "profile_other_notifications") } // Email Notifications diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index dc4b16d..3ddb004 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -20543,6 +20543,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" : "即将到期和逾期任务的每日概览" + } + } + } + }, "properties_add_button" : { "extractionState" : "manual", "localizations" : { diff --git a/iosApp/iosApp/Profile/NotificationPreferencesView.swift b/iosApp/iosApp/Profile/NotificationPreferencesView.swift index 7b05755..1303bfe 100644 --- a/iosApp/iosApp/Profile/NotificationPreferencesView.swift +++ b/iosApp/iosApp/Profile/NotificationPreferencesView.swift @@ -217,6 +217,40 @@ struct NotificationPreferencesView: View { .onChange(of: viewModel.warrantyExpiring) { _, newValue in viewModel.updatePreference(warrantyExpiring: newValue) } + + Toggle(isOn: $viewModel.dailyDigest) { + Label { + VStack(alignment: .leading, spacing: 2) { + Text(L10n.Profile.dailyDigest) + .foregroundColor(Color.appTextPrimary) + Text(L10n.Profile.dailyDigestDescription) + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } icon: { + Image(systemName: "list.bullet.clipboard.fill") + .foregroundColor(Color.appSecondary) + } + } + .tint(Color.appPrimary) + .onChange(of: viewModel.dailyDigest) { _, newValue in + viewModel.updatePreference(dailyDigest: newValue) + } + + // Time picker for Daily Digest + if viewModel.dailyDigest { + NotificationTimePickerRow( + isEnabled: $viewModel.dailyDigestTimeEnabled, + selectedHour: $viewModel.dailyDigestHour, + onEnableCustomTime: { + viewModel.enableCustomTime(for: .dailyDigest) + }, + onTimeChange: { hour in + viewModel.updateCustomTime(hour, for: .dailyDigest) + }, + formatHour: viewModel.formatHour + ) + } } header: { Text(L10n.Profile.otherNotifications) } @@ -278,17 +312,20 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { @Published var taskAssigned: Bool = true @Published var residenceShared: Bool = true @Published var warrantyExpiring: Bool = true + @Published var dailyDigest: Bool = true @Published var emailTaskCompleted: Bool = true // Custom notification times (local hours, 0-23) @Published var taskDueSoonHour: Int? = nil @Published var taskOverdueHour: Int? = nil @Published var warrantyExpiringHour: Int? = nil + @Published var dailyDigestHour: Int? = nil // Track if user has enabled custom times @Published var taskDueSoonTimeEnabled: Bool = false @Published var taskOverdueTimeEnabled: Bool = false @Published var warrantyExpiringTimeEnabled: Bool = false + @Published var dailyDigestTimeEnabled: Bool = false @Published var isLoading: Bool = false @Published var errorMessage: String? @@ -298,6 +335,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { private let defaultTaskDueSoonLocalHour = 14 // 2 PM local private let defaultTaskOverdueLocalHour = 9 // 9 AM local private let defaultWarrantyExpiringLocalHour = 10 // 10 AM local + private let defaultDailyDigestLocalHour = 8 // 8 AM local func loadPreferences() { isLoading = true @@ -314,6 +352,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { self.taskAssigned = prefs.taskAssigned self.residenceShared = prefs.residenceShared self.warrantyExpiring = prefs.warrantyExpiring + self.dailyDigest = prefs.dailyDigest self.emailTaskCompleted = prefs.emailTaskCompleted // Load custom notification times (convert from UTC to local) @@ -329,6 +368,10 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { self.warrantyExpiringHour = DateUtils.utcHourToLocal(Int(utcHour)) self.warrantyExpiringTimeEnabled = true } + if let utcHour = prefs.dailyDigestHour?.intValue { + self.dailyDigestHour = DateUtils.utcHourToLocal(Int(utcHour)) + self.dailyDigestTimeEnabled = true + } self.isLoading = false self.errorMessage = nil @@ -350,10 +393,12 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { taskAssigned: Bool? = nil, residenceShared: Bool? = nil, warrantyExpiring: Bool? = nil, + dailyDigest: Bool? = nil, emailTaskCompleted: Bool? = nil, taskDueSoonHour: Int? = nil, taskOverdueHour: Int? = nil, - warrantyExpiringHour: Int? = nil + warrantyExpiringHour: Int? = nil, + dailyDigestHour: Int? = nil ) { isSaving = true @@ -363,6 +408,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { let taskDueSoonUtc = taskDueSoonHour.map { DateUtils.localHourToUtc($0) } let taskOverdueUtc = taskOverdueHour.map { DateUtils.localHourToUtc($0) } let warrantyExpiringUtc = warrantyExpiringHour.map { DateUtils.localHourToUtc($0) } + let dailyDigestUtc = dailyDigestHour.map { DateUtils.localHourToUtc($0) } let request = UpdateNotificationPreferencesRequest( taskDueSoon: taskDueSoon.map { KotlinBoolean(bool: $0) }, @@ -371,10 +417,12 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { taskAssigned: taskAssigned.map { KotlinBoolean(bool: $0) }, residenceShared: residenceShared.map { KotlinBoolean(bool: $0) }, warrantyExpiring: warrantyExpiring.map { KotlinBoolean(bool: $0) }, + dailyDigest: dailyDigest.map { KotlinBoolean(bool: $0) }, emailTaskCompleted: emailTaskCompleted.map { KotlinBoolean(bool: $0) }, taskDueSoonHour: taskDueSoonUtc.map { KotlinInt(int: Int32($0)) }, taskOverdueHour: taskOverdueUtc.map { KotlinInt(int: Int32($0)) }, - warrantyExpiringHour: warrantyExpiringUtc.map { KotlinInt(int: Int32($0)) } + warrantyExpiringHour: warrantyExpiringUtc.map { KotlinInt(int: Int32($0)) }, + dailyDigestHour: dailyDigestUtc.map { KotlinInt(int: Int32($0)) } ) let result = try await APILayer.shared.updateNotificationPreferences(request: request) @@ -406,6 +454,10 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { warrantyExpiringHour = defaultWarrantyExpiringLocalHour warrantyExpiringTimeEnabled = true updatePreference(warrantyExpiringHour: defaultWarrantyExpiringLocalHour) + case .dailyDigest: + dailyDigestHour = defaultDailyDigestLocalHour + dailyDigestTimeEnabled = true + updatePreference(dailyDigestHour: defaultDailyDigestLocalHour) } } @@ -421,6 +473,9 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { case .warrantyExpiring: warrantyExpiringHour = hour updatePreference(warrantyExpiringHour: hour) + case .dailyDigest: + dailyDigestHour = hour + updatePreference(dailyDigestHour: hour) } } @@ -428,6 +483,7 @@ class NotificationPreferencesViewModelWrapper: ObservableObject { case taskDueSoon case taskOverdue case warrantyExpiring + case dailyDigest } // Format hour to display string