From 9ced44193523abe1732400802cd7730f9979ea85 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Mon, 15 Jun 2026 10:02:46 +0200 Subject: [PATCH] feat: event reminders UI, per-calendar mute, notifications, default duration - Add reminder editor (presets + custom number+unit) wired through save - Per-calendar reminder mute: filter-sheet toggle + greyed-out editor hint; reminders are kept, never deleted - New AlarmManager-based NotificationScheduler + channel + POST_NOTIFICATIONS; skips muted calendars - New synced setting default_event_duration_minutes for new events Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 5 + .../calendarr/data/CalendarRepository.kt | 14 ++ .../calendarr/data/SettingsStore.kt | 13 ++ .../calendarr/domain/model/Accounts.kt | 5 + .../calendarr/domain/model/AppSettings.kt | 2 + .../calendarr/domain/model/ReminderOptions.kt | 29 +++++ .../notifications/NotificationScheduler.kt | 99 ++++++++++++++ .../notifications/ReminderReceiver.kt | 33 +++++ .../java/com/scarriffle/calendarr/ui/L10n.kt | 16 +++ .../ui/calendar/CalendarFilterSheet.kt | 15 +++ .../calendarr/ui/calendar/CalendarScreen.kt | 27 +++- .../ui/calendar/CalendarViewModel.kt | 22 ++++ .../calendarr/ui/event/EventEditorSheet.kt | 121 +++++++++++++++++- .../calendarr/ui/settings/SettingsScreen.kt | 11 ++ 14 files changed, 405 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b7fb240..2c0adeb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 7bd60b6..bf5a404 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -129,6 +129,7 @@ class CalendarRepository @Inject constructor( "private_event_visibility" to s.privateEventVisibility, // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. "default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL), + "default_event_duration_minutes" to s.defaultEventDurationMinutes, ) ).ensureSuccess() } @@ -246,6 +247,19 @@ class CalendarRepository @Inject constructor( } } + /** Toggle a calendar's server-side `reminders_enabled` flag (all sources). */ + suspend fun setCalendarRemindersEnabled(source: String, calendarId: Int, enabled: Boolean) = guarded { + val body = jsonBody("reminders_enabled" to enabled) + when (source) { + "caldav" -> api.updateCalDAVCalendar(calendarId, body).ensureSuccess() + "local" -> api.updateLocalCalendar(calendarId, body).ensureSuccess() + "ical" -> api.updateICalSubscription(calendarId, body).ensureSuccess() + "google" -> api.updateGoogleCalendar(calendarId, body).ensureSuccess() + "homeassistant" -> api.updateHACalendar(calendarId, body).ensureSuccess() + else -> Unit + } + } + /** Resolve all calendars the user can create events in. */ suspend fun getWritableCalendars(): List = withContext(Dispatchers.IO) { val result = mutableListOf() diff --git a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt index 1198309..d20f257 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/SettingsStore.kt @@ -35,6 +35,7 @@ class SettingsStore @Inject constructor( monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 }, + defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60), ) fun saveSettings(s: AppSettings) { @@ -52,6 +53,7 @@ class SettingsStore @Inject constructor( .putString(K_DIVIDER, s.monthDividerColor) .putString(K_LABEL, s.monthLabelColor) .putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1) + .putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes) .apply() } @@ -72,6 +74,15 @@ class SettingsStore @Inject constructor( get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet() set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply() + // --- Reminder-disabled calendars ("source:id") --- + // Mirrors the server's per-calendar `reminders_enabled` flag so the + // notification scheduler can skip muted calendars without deleting any + // event reminders. + + var reminderDisabledCalendarKeys: Set + get() = prefs.getStringSet(K_REMINDER_DISABLED, emptySet())?.toSet() ?: emptySet() + set(value) = prefs.edit().putStringSet(K_REMINDER_DISABLED, value).apply() + private companion object { const val K_DEFAULT_VIEW = "default_view" const val K_WEEK_START = "week_start_day" @@ -86,8 +97,10 @@ class SettingsStore @Inject constructor( const val K_DIVIDER = "month_divider_color" const val K_LABEL = "month_label_color" const val K_DEFAULT_REMINDER = "default_reminder_minutes" + const val K_DEFAULT_DURATION = "default_event_duration_minutes" const val K_CACHE_MONTHS = "cache_months" const val K_HIDDEN = "hidden_calendar_keys" const val K_BANISHED = "banished_calendar_keys" + const val K_REMINDER_DISABLED = "reminder_disabled_calendar_keys" } } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt index 07ad36f..fb10c17 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/Accounts.kt @@ -21,6 +21,7 @@ data class CalDAVCalendar( val color: String? = null, val enabled: Boolean = true, @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, ) @JsonClass(generateAdapter = false) @@ -34,6 +35,7 @@ data class LocalCalendar( @Json(name = "shared_by") val sharedBy: String? = null, val permission: String? = null, val group: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, ) @JsonClass(generateAdapter = false) @@ -45,6 +47,7 @@ data class ICalSubscription( val enabled: Boolean = true, @Json(name = "refresh_minutes") val refreshMinutes: Int = 60, @Json(name = "last_fetched") val lastFetched: String? = null, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, ) @JsonClass(generateAdapter = false) @@ -61,6 +64,7 @@ data class GoogleCalendar( val color: String? = null, val enabled: Boolean = true, @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, ) @JsonClass(generateAdapter = false) @@ -80,6 +84,7 @@ data class HACalendar( val color: String? = null, val enabled: Boolean = true, @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, + @Json(name = "reminders_enabled") val remindersEnabled: Boolean = true, ) @JsonClass(generateAdapter = false) diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt index c908688..d96a998 100644 --- a/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/AppSettings.kt @@ -27,6 +27,8 @@ data class AppSettings( @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, // Minutes-before-start applied to all events client-side; null = off. @Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null, + // Duration (minutes) applied to a newly created event's end time. + @Json(name = "default_event_duration_minutes") val defaultEventDurationMinutes: Int = 60, ) { val weekStartsOnMonday: Boolean get() = weekStartDay != "sunday" } diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt new file mode 100644 index 0000000..fd0a9f7 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/ReminderOptions.kt @@ -0,0 +1,29 @@ +package com.scarriffle.calendarr.domain.model + +/** + * Reminder offsets are stored as minutes-before-start integers (0 = at start). + * A few quick presets are offered; anything else is entered as a custom + * number + unit. Mirrors the iOS `ReminderOptions`. + */ +object ReminderOptions { + /** Quick presets: at start, 30 min, 1 day. */ + val presets = listOf(0, 30, 1440) + + /** Default for a freshly-switched custom row (deliberately not a preset). */ + const val customDefault = 120 + + enum class Unit(val mult: Int, val labelKey: String) { + MINUTES(1, "event.reminder_unit.minutes"), + HOURS(60, "event.reminder_unit.hours"), + DAYS(1440, "event.reminder_unit.days"), + WEEKS(10080, "event.reminder_unit.weeks"), + } + + /** Split a minutes value into the largest exact {value, unit} for the custom picker. */ + fun split(minutes: Int): Pair { + for (u in Unit.values().reversed()) { + if (minutes > 0 && minutes % u.mult == 0) return (minutes / u.mult) to u + } + return maxOf(1, minutes) to Unit.MINUTES + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt b/app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt new file mode 100644 index 0000000..065d207 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/notifications/NotificationScheduler.kt @@ -0,0 +1,99 @@ +package com.scarriffle.calendarr.notifications + +import android.app.AlarmManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.ui.calendar.calendarKey + +/** + * Schedules OS reminder notifications for upcoming events via AlarmManager. + * Per-event reminders take precedence; otherwise the user's default reminder + * applies. Calendars the user muted (`disabledKeys`) are skipped — their + * reminders are kept on the events, just never fired. Mirrors the iOS + * `NotificationScheduler`. + */ +object NotificationScheduler { + const val CHANNEL_ID = "calendarr_reminders" + const val EXTRA_TITLE = "title" + const val EXTRA_BODY = "body" + const val EXTRA_ID = "id" + + private const val PREFS = "calendarr_reminders_sched" + private const val KEY_COUNT = "scheduled_count" + private const val MAX = 50 // keep the alarm count bounded + + fun ensureChannel(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val mgr = context.getSystemService(NotificationManager::class.java) + if (mgr.getNotificationChannel(CHANNEL_ID) == null) { + mgr.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "Reminders", NotificationManager.IMPORTANCE_HIGH) + ) + } + } + } + + private data class Pending(val fire: Long, val title: String, val body: String) + + fun reschedule( + context: Context, + events: List, + disabledKeys: Set, + defaultMinutes: Int, + ) { + ensureChannel(context) + val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + val now = System.currentTimeMillis() + + val pending = mutableListOf() + for (ev in events) { + if (disabledKeys.contains(calendarKey(ev.source, ev.calendarId))) continue + val offsets = if (ev.reminders.isEmpty()) { + if (defaultMinutes >= 0) listOf(defaultMinutes) else emptyList() + } else ev.reminders + for (m in offsets) { + val fire = ev.startDate.toEpochMilli() - m * 60_000L + if (fire > now) pending.add(Pending(fire, ev.title, ev.location)) + } + } + pending.sortBy { it.fire } + val limited = pending.take(MAX) + + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val lastCount = prefs.getInt(KEY_COUNT, 0) + // Cancel every alarm from the previous run (extras are ignored when + // matching a PendingIntent, so a bare intent with the same code cancels). + for (i in 0 until maxOf(lastCount, limited.size)) { + am.cancel(intentFor(context, i, null)) + } + limited.forEachIndexed { i, p -> + val pi = intentFor(context, i, p) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, p.fire, pi) + } else { + am.set(AlarmManager.RTC_WAKEUP, p.fire, pi) + } + } + prefs.edit().putInt(KEY_COUNT, limited.size).apply() + } + + private fun intentFor(context: Context, code: Int, p: Pending?): PendingIntent { + val intent = Intent(context, ReminderReceiver::class.java).apply { + // Distinct action per code so PendingIntents don't collapse together. + action = "com.scarriffle.calendarr.REMINDER_$code" + if (p != null) { + putExtra(EXTRA_ID, code) + putExtra(EXTRA_TITLE, p.title) + putExtra(EXTRA_BODY, p.body) + } + } + var flags = PendingIntent.FLAG_UPDATE_CURRENT + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) flags = flags or PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(context, code, intent, flags) + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt b/app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt new file mode 100644 index 0000000..17b39ba --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/notifications/ReminderReceiver.kt @@ -0,0 +1,33 @@ +package com.scarriffle.calendarr.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.scarriffle.calendarr.R + +/** Posts the reminder notification when an AlarmManager alarm fires. */ +class ReminderReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + NotificationScheduler.ensureChannel(context) + val title = intent.getStringExtra(NotificationScheduler.EXTRA_TITLE) ?: return + val body = intent.getStringExtra(NotificationScheduler.EXTRA_BODY) ?: "" + val id = intent.getIntExtra(NotificationScheduler.EXTRA_ID, 0) + + val notification = NotificationCompat.Builder(context, NotificationScheduler.CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + // notify() is a no-op (and may throw on some OEMs) without the runtime + // POST_NOTIFICATIONS permission; ignore that case. + try { + NotificationManagerCompat.from(context).notify(id, notification) + } catch (_: SecurityException) { + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index df1d32e..2b17014 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -101,6 +101,14 @@ object L10n { "event.detail_title" to "Termin", "event.source" to "Quelle", "event.save" to "Sichern", "event.add" to "Hinzufügen", "event.delete_confirm" to "Diesen Termin löschen?", + "event.reminders" to "Benachrichtigungen", "event.reminder_add" to "Benachrichtigung hinzufügen", + "event.reminder_custom" to "Benutzerdefiniert…", "event.reminder_at_start" to "Zur Startzeit", + "event.reminder_before" to "vorher", + "event.reminder_unit.minutes" to "Minuten", "event.reminder_unit.hours" to "Stunden", + "event.reminder_unit.days" to "Tage", "event.reminder_unit.weeks" to "Wochen", + "event.reminders_disabled" to "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt.", + "settings.default_duration" to "Standard-Termindauer", + "filter.reminders_on" to "Benachrichtigungen aktivieren", "filter.reminders_off" to "Benachrichtigungen deaktivieren", "accounts.title" to "Konten", "accounts.loading" to "Lade Konten…", "accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-Konten", "accounts.caldav.add" to "CalDAV hinzufügen", "accounts.local.header" to "Lokale Kalender", @@ -231,6 +239,14 @@ object L10n { "event.detail_title" to "Event", "event.source" to "Source", "event.save" to "Save", "event.add" to "Add", "event.delete_confirm" to "Delete this event?", + "event.reminders" to "Reminders", "event.reminder_add" to "Add reminder", + "event.reminder_custom" to "Custom…", "event.reminder_at_start" to "At start time", + "event.reminder_before" to "before", + "event.reminder_unit.minutes" to "minutes", "event.reminder_unit.hours" to "hours", + "event.reminder_unit.days" to "days", "event.reminder_unit.weeks" to "weeks", + "event.reminders_disabled" to "Reminders are disabled for this calendar – they will not fire.", + "settings.default_duration" to "Default event duration", + "filter.reminders_on" to "Enable reminders", "filter.reminders_off" to "Disable reminders", "accounts.title" to "Accounts", "accounts.loading" to "Loading accounts…", "accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV accounts", "accounts.caldav.add" to "Add CalDAV", "accounts.local.header" to "Local calendars", diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt index 3a7df34..cc5b258 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarFilterSheet.kt @@ -9,7 +9,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Switch @@ -88,6 +93,16 @@ fun CalendarFilterSheet( modifier = Modifier.weight(1f).padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge, ) + if (!groupMode) { + val remDisabled = entry.key in state.reminderDisabledKeys + IconButton(onClick = { vm.setCalendarRemindersDisabled(entry.key, disabled = !remDisabled) }) { + Icon( + if (remDisabled) Icons.Filled.NotificationsOff else Icons.Filled.Notifications, + contentDescription = tr(if (remDisabled) "filter.reminders_on" else "filter.reminders_off"), + tint = if (remDisabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.primary, + ) + } + } Switch( checked = visible, onCheckedChange = { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt index 02b8b4a..d454618 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarScreen.kt @@ -88,6 +88,27 @@ fun CalendarScreen( ) { val state by vm.state.collectAsState() val lang = LocalLang.current + val context = androidx.compose.ui.platform.LocalContext.current + + // Ask once for notification permission (Android 13+), then keep the OS + // reminder alarms in sync with the visible events / muted-calendar set. + val notifPermLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + androidx.activity.result.contract.ActivityResultContracts.RequestPermission() + ) {} + androidx.compose.runtime.LaunchedEffect(Unit) { + if (android.os.Build.VERSION.SDK_INT >= 33 && + androidx.core.content.ContextCompat.checkSelfPermission( + context, android.Manifest.permission.POST_NOTIFICATIONS + ) != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } + } + androidx.compose.runtime.LaunchedEffect(state.events, state.reminderDisabledKeys) { + com.scarriffle.calendarr.notifications.NotificationScheduler.reschedule( + context, state.events, state.reminderDisabledKeys, vm.defaultReminderMinutes + ) + } var viewMenuOpen by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) } @@ -244,8 +265,10 @@ fun CalendarScreen( request = req, writableCalendars = state.writableCalendars, onDismiss = { editor = null }, - onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate -> - vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error -> + defaultDurationMinutes = vm.defaultEventDurationMinutes, + reminderDisabledKeys = state.reminderDisabledKeys, + onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate, reminders -> + vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate, reminders) { error -> if (error == null) editor = null } }, diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt index 3cb86a3..79c63b4 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/calendar/CalendarViewModel.kt @@ -40,6 +40,9 @@ data class CalendarUiState( val writableCalendars: List = emptyList(), val hiddenKeys: Set = emptySet(), val banishedKeys: Set = emptySet(), + // Calendars ("source:id") the user muted for reminders — events keep their + // reminders but the scheduler skips them. + val reminderDisabledKeys: Set = emptySet(), // Group overlay: when non-null the calendar shows the group's combined view. val groups: List = emptyList(), val activeGroup: Group? = null, @@ -107,9 +110,28 @@ class CalendarViewModel @Inject constructor( weekStartsOnMonday = s.weekStartsOnMonday, hiddenKeys = settingsStore.hiddenCalendarKeys, banishedKeys = settingsStore.banishedCalendarKeys, + reminderDisabledKeys = settingsStore.reminderDisabledCalendarKeys, ) } + /** Default duration (minutes) for a new event's end time. */ + val defaultEventDurationMinutes: Int get() = settingsStore.loadSettings().defaultEventDurationMinutes + + /** Default reminder offset (minutes before start), or -1 when off. */ + val defaultReminderMinutes: Int get() = settingsStore.loadSettings().defaultReminderMinutes ?: -1 + + /** Toggle a calendar's reminders without deleting any event reminders. */ + fun setCalendarRemindersDisabled(key: String, disabled: Boolean) { + val keys = settingsStore.reminderDisabledCalendarKeys.toMutableSet() + if (disabled) keys.add(key) else keys.remove(key) + settingsStore.reminderDisabledCalendarKeys = keys + _state.update { it.copy(reminderDisabledKeys = keys) } + val parts = key.split(":") + val source = parts.getOrNull(0) ?: return + val id = parts.getOrNull(1)?.toIntOrNull() ?: return + viewModelScope.launch { runCatching { repository.setCalendarRemindersEnabled(source, id, enabled = !disabled) } } + } + // ---- Navigation ---- fun setViewType(type: CalViewType) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt index 758e01c..61021d5 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/event/EventEditorSheet.kt @@ -13,13 +13,17 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.IconButton import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -43,8 +47,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.scarriffle.calendarr.domain.model.CalEvent +import com.scarriffle.calendarr.domain.model.ReminderOptions import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.ui.L10n import com.scarriffle.calendarr.ui.LocalLang @@ -69,7 +75,9 @@ fun EventEditorSheet( request: EditorRequest, writableCalendars: List, onDismiss: () -> Unit, - onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit, + defaultDurationMinutes: Int = 60, + reminderDisabledKeys: Set = emptySet(), + onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List) -> Unit, ) { val zone = ZoneId.systemDefault() val context = LocalContext.current @@ -95,17 +103,20 @@ fun EventEditorSheet( var startTime by remember { mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0)) } + // New events default to start (09:00) + the user's default duration. + val defaultEnd = request.date.atTime(9, 0).plusMinutes(defaultDurationMinutes.toLong()) var endDate by remember { mutableStateOf( initialEnd?.let { val d = LocalDate.ofInstant(it, zone) if (template?.isAllDay == true) d.minusDays(1) else d - } ?: request.date + } ?: defaultEnd.toLocalDate() ) } var endTime by remember { - mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(10, 0)) + mutableStateOf(initialEnd?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: defaultEnd.toLocalTime()) } + var reminders by remember { mutableStateOf(template?.reminders ?: emptyList()) } val preselected = template?.let { ev -> val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull() @@ -231,13 +242,42 @@ fun EventEditorSheet( } Spacer(Modifier.size(12.dp)) - // Private (local calendars only) + // Private + reminders (local calendars only) if (calendar?.source == "local") { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge) Switch(checked = isPrivate, onCheckedChange = { isPrivate = it }) } Spacer(Modifier.size(12.dp)) + + val remindersDisabled = calendar?.let { + reminderDisabledKeys.contains(calendarKey(it.source, it.numericId.toString())) + } ?: false + Text(tr("event.reminders"), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (remindersDisabled) { + Text( + tr("event.reminders_disabled"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 2.dp), + ) + } + reminders.forEachIndexed { idx, min -> + ReminderRow( + minutes = min, + enabled = !remindersDisabled, + onChange = { v -> reminders = reminders.toMutableList().also { it[idx] = v } }, + onRemove = { reminders = reminders.toMutableList().also { it.removeAt(idx) } }, + ) + } + androidx.compose.material3.TextButton( + enabled = !remindersDisabled, + onClick = { + val next = ReminderOptions.presets.firstOrNull { it !in reminders } ?: ReminderOptions.customDefault + reminders = reminders + next + }, + ) { Text(tr("event.reminder_add")) } + Spacer(Modifier.size(12.dp)) } // Color @@ -280,7 +320,8 @@ fun EventEditorSheet( start = startDate.atTime(startTime).atZone(zone).toInstant() end = endDate.atTime(endTime).atZone(zone).toInstant() } - onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local") + val rem = if (cal.source == "local") reminders else emptyList() + onSave(cal, title.trim(), start, end, allDay, location.trim(), description.trim(), color, isPrivate && cal.source == "local", rem) }, enabled = writableCalendars.isNotEmpty(), modifier = Modifier.fillMaxWidth(), @@ -290,3 +331,73 @@ fun EventEditorSheet( } } } + +/** One reminder row: a preset dropdown plus, in custom mode, a number field + + * unit dropdown. The value is always emitted as minutes-before-start. */ +@Composable +private fun ReminderRow( + minutes: Int, + enabled: Boolean, + onChange: (Int) -> Unit, + onRemove: () -> Unit, +) { + val isPreset = minutes in ReminderOptions.presets + var presetMenu by remember { mutableStateOf(false) } + var unitMenu by remember { mutableStateOf(false) } + + @Composable + fun label(min: Int): String = + if (min == 0) tr("event.reminder_at_start") + else ReminderOptions.split(min).let { (v, u) -> "$v ${tr(u.labelKey)} ${tr("event.reminder_before")}" } + + Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Box(Modifier.weight(1f)) { + OutlinedButton(onClick = { presetMenu = true }, enabled = enabled, modifier = Modifier.fillMaxWidth()) { + Text(if (isPreset) label(minutes) else tr("event.reminder_custom"), modifier = Modifier.weight(1f)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = presetMenu, onDismissRequest = { presetMenu = false }) { + ReminderOptions.presets.forEach { p -> + DropdownMenuItem(text = { Text(label(p)) }, onClick = { onChange(p); presetMenu = false }) + } + DropdownMenuItem(text = { Text(tr("event.reminder_custom")) }, onClick = { + if (minutes in ReminderOptions.presets) onChange(ReminderOptions.customDefault) + presetMenu = false + }) + } + } + IconButton(onClick = onRemove, enabled = enabled) { + Icon(Icons.Filled.Close, contentDescription = null) + } + } + if (!isPreset) { + val (value, unit) = ReminderOptions.split(minutes) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = value.toString(), + onValueChange = { txt -> + val n = txt.filter { it.isDigit() }.toIntOrNull()?.coerceAtLeast(1) ?: 1 + onChange(n * unit.mult) + }, + enabled = enabled, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.width(96.dp), + ) + Box { + OutlinedButton(onClick = { unitMenu = true }, enabled = enabled) { + Text(tr(unit.labelKey)) + Icon(Icons.Filled.ArrowDropDown, contentDescription = null) + } + DropdownMenu(expanded = unitMenu, onDismissRequest = { unitMenu = false }) { + ReminderOptions.Unit.values().forEach { u -> + DropdownMenuItem(text = { Text(tr(u.labelKey)) }, onClick = { onChange(value * u.mult); unitMenu = false }) + } + } + } + Text(tr("event.reminder_before"), color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt index cae525f..e2bfb8c 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/settings/SettingsScreen.kt @@ -114,6 +114,17 @@ fun SettingsScreen( Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge) Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) }) } + Spacer(Modifier.size(16.dp)) + + Section(tr("settings.default_duration")) + ChipRow( + options = listOf( + "15" to "15 min", "30" to "30 min", "45" to "45 min", + "60" to "1 h", "90" to "1.5 h", "120" to "2 h", + ), + selected = settings.defaultEventDurationMinutes.toString(), + onSelect = { update(settings.copy(defaultEventDurationMinutes = it.toInt())) }, + ) Divider(Modifier.padding(vertical = 16.dp)) Section(tr("settings.language"))