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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-15 10:02:46 +02:00
parent 701db45f8a
commit 9ced441935
14 changed files with 405 additions and 7 deletions

View File

@@ -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<WritableCalendar> = withContext(Dispatchers.IO) {
val result = mutableListOf<WritableCalendar>()

View File

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

View File

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

View File

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

View File

@@ -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<Int, Unit> {
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
}
}

View File

@@ -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<CalEvent>,
disabledKeys: Set<String>,
defaultMinutes: Int,
) {
ensureChannel(context)
val am = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
val now = System.currentTimeMillis()
val pending = mutableListOf<Pending>()
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)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,9 @@ data class CalendarUiState(
val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(),
val banishedKeys: Set<String> = emptySet(),
// Calendars ("source:id") the user muted for reminders — events keep their
// reminders but the scheduler skips them.
val reminderDisabledKeys: Set<String> = emptySet(),
// Group overlay: when non-null the calendar shows the group's combined view.
val groups: List<Group> = 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) {

View File

@@ -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<WritableCalendar>,
onDismiss: () -> Unit,
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean) -> Unit,
defaultDurationMinutes: Int = 60,
reminderDisabledKeys: Set<String> = emptySet(),
onSave: (WritableCalendar, String, Instant, Instant, Boolean, String, String, String?, Boolean, List<Int>) -> 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<Int>()) }
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)
}
}
}
}

View File

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