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

@@ -2,6 +2,7 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application <application
android:name=".CalendarrApplication" android:name=".CalendarrApplication"
@@ -21,5 +22,9 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<receiver
android:name=".notifications.ReminderReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@@ -129,6 +129,7 @@ class CalendarRepository @Inject constructor(
"private_event_visibility" to s.privateEventVisibility, "private_event_visibility" to s.privateEventVisibility,
// Explicit JSON null clears it (off); jsonBody drops Kotlin nulls. // Explicit JSON null clears it (off); jsonBody drops Kotlin nulls.
"default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL), "default_reminder_minutes" to (s.defaultReminderMinutes ?: org.json.JSONObject.NULL),
"default_event_duration_minutes" to s.defaultEventDurationMinutes,
) )
).ensureSuccess() ).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. */ /** Resolve all calendars the user can create events in. */
suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) { suspend fun getWritableCalendars(): List<WritableCalendar> = withContext(Dispatchers.IO) {
val result = mutableListOf<WritableCalendar>() val result = mutableListOf<WritableCalendar>()

View File

@@ -35,6 +35,7 @@ class SettingsStore @Inject constructor(
monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0", monthDividerColor = prefs.getString(K_DIVIDER, null) ?: "#7090c0",
monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0", monthLabelColor = prefs.getString(K_LABEL, null) ?: "#7090c0",
defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 }, defaultReminderMinutes = prefs.getInt(K_DEFAULT_REMINDER, -1).takeIf { it >= 0 },
defaultEventDurationMinutes = prefs.getInt(K_DEFAULT_DURATION, 60),
) )
fun saveSettings(s: AppSettings) { fun saveSettings(s: AppSettings) {
@@ -52,6 +53,7 @@ class SettingsStore @Inject constructor(
.putString(K_DIVIDER, s.monthDividerColor) .putString(K_DIVIDER, s.monthDividerColor)
.putString(K_LABEL, s.monthLabelColor) .putString(K_LABEL, s.monthLabelColor)
.putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1) .putInt(K_DEFAULT_REMINDER, s.defaultReminderMinutes ?: -1)
.putInt(K_DEFAULT_DURATION, s.defaultEventDurationMinutes)
.apply() .apply()
} }
@@ -72,6 +74,15 @@ class SettingsStore @Inject constructor(
get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet() get() = prefs.getStringSet(K_BANISHED, emptySet())?.toSet() ?: emptySet()
set(value) = prefs.edit().putStringSet(K_BANISHED, value).apply() 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 { private companion object {
const val K_DEFAULT_VIEW = "default_view" const val K_DEFAULT_VIEW = "default_view"
const val K_WEEK_START = "week_start_day" 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_DIVIDER = "month_divider_color"
const val K_LABEL = "month_label_color" const val K_LABEL = "month_label_color"
const val K_DEFAULT_REMINDER = "default_reminder_minutes" 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_CACHE_MONTHS = "cache_months"
const val K_HIDDEN = "hidden_calendar_keys" const val K_HIDDEN = "hidden_calendar_keys"
const val K_BANISHED = "banished_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 color: String? = null,
val enabled: Boolean = true, val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
@@ -34,6 +35,7 @@ data class LocalCalendar(
@Json(name = "shared_by") val sharedBy: String? = null, @Json(name = "shared_by") val sharedBy: String? = null,
val permission: String? = null, val permission: String? = null,
val group: Boolean = false, val group: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
@@ -45,6 +47,7 @@ data class ICalSubscription(
val enabled: Boolean = true, val enabled: Boolean = true,
@Json(name = "refresh_minutes") val refreshMinutes: Int = 60, @Json(name = "refresh_minutes") val refreshMinutes: Int = 60,
@Json(name = "last_fetched") val lastFetched: String? = null, @Json(name = "last_fetched") val lastFetched: String? = null,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
@@ -61,6 +64,7 @@ data class GoogleCalendar(
val color: String? = null, val color: String? = null,
val enabled: Boolean = true, val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)
@@ -80,6 +84,7 @@ data class HACalendar(
val color: String? = null, val color: String? = null,
val enabled: Boolean = true, val enabled: Boolean = true,
@Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false, @Json(name = "sidebar_hidden") val sidebarHidden: Boolean = false,
@Json(name = "reminders_enabled") val remindersEnabled: Boolean = true,
) )
@JsonClass(generateAdapter = false) @JsonClass(generateAdapter = false)

View File

@@ -27,6 +27,8 @@ data class AppSettings(
@Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null, @Json(name = "group_visible_calendar_id") val groupVisibleCalendarId: Int? = null,
// Minutes-before-start applied to all events client-side; null = off. // Minutes-before-start applied to all events client-side; null = off.
@Json(name = "default_reminder_minutes") val defaultReminderMinutes: Int? = null, @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" 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.detail_title" to "Termin", "event.source" to "Quelle",
"event.save" to "Sichern", "event.add" to "Hinzufügen", "event.save" to "Sichern", "event.add" to "Hinzufügen",
"event.delete_confirm" to "Diesen Termin löschen?", "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.title" to "Konten", "accounts.loading" to "Lade Konten…",
"accounts.caldav.header" to "CalDAV-Konten", "accounts.caldav.empty" to "Keine CalDAV-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", "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.detail_title" to "Event", "event.source" to "Source",
"event.save" to "Save", "event.add" to "Add", "event.save" to "Save", "event.add" to "Add",
"event.delete_confirm" to "Delete this event?", "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.title" to "Accounts", "accounts.loading" to "Loading accounts…",
"accounts.caldav.header" to "CalDAV accounts", "accounts.caldav.empty" to "No CalDAV 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", "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.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
@@ -88,6 +93,16 @@ fun CalendarFilterSheet(
modifier = Modifier.weight(1f).padding(start = 12.dp), modifier = Modifier.weight(1f).padding(start = 12.dp),
style = MaterialTheme.typography.bodyLarge, 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( Switch(
checked = visible, checked = visible,
onCheckedChange = { onCheckedChange = {

View File

@@ -88,6 +88,27 @@ fun CalendarScreen(
) { ) {
val state by vm.state.collectAsState() val state by vm.state.collectAsState()
val lang = LocalLang.current 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 viewMenuOpen by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
@@ -244,8 +265,10 @@ fun CalendarScreen(
request = req, request = req,
writableCalendars = state.writableCalendars, writableCalendars = state.writableCalendars,
onDismiss = { editor = null }, onDismiss = { editor = null },
onSave = { cal, title, start, end, allDay, location, desc, color, isPrivate -> defaultDurationMinutes = vm.defaultEventDurationMinutes,
vm.saveEvent(cal, req.existing, title, start, end, allDay, location, desc, color, isPrivate) { error -> 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 if (error == null) editor = null
} }
}, },

View File

@@ -40,6 +40,9 @@ data class CalendarUiState(
val writableCalendars: List<WritableCalendar> = emptyList(), val writableCalendars: List<WritableCalendar> = emptyList(),
val hiddenKeys: Set<String> = emptySet(), val hiddenKeys: Set<String> = emptySet(),
val banishedKeys: 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. // Group overlay: when non-null the calendar shows the group's combined view.
val groups: List<Group> = emptyList(), val groups: List<Group> = emptyList(),
val activeGroup: Group? = null, val activeGroup: Group? = null,
@@ -107,9 +110,28 @@ class CalendarViewModel @Inject constructor(
weekStartsOnMonday = s.weekStartsOnMonday, weekStartsOnMonday = s.weekStartsOnMonday,
hiddenKeys = settingsStore.hiddenCalendarKeys, hiddenKeys = settingsStore.hiddenCalendarKeys,
banishedKeys = settingsStore.banishedCalendarKeys, 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 ---- // ---- Navigation ----
fun setViewType(type: CalViewType) { 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check 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.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.scarriffle.calendarr.domain.model.CalEvent 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.domain.model.WritableCalendar
import com.scarriffle.calendarr.ui.L10n import com.scarriffle.calendarr.ui.L10n
import com.scarriffle.calendarr.ui.LocalLang import com.scarriffle.calendarr.ui.LocalLang
@@ -69,7 +75,9 @@ fun EventEditorSheet(
request: EditorRequest, request: EditorRequest,
writableCalendars: List<WritableCalendar>, writableCalendars: List<WritableCalendar>,
onDismiss: () -> Unit, 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 zone = ZoneId.systemDefault()
val context = LocalContext.current val context = LocalContext.current
@@ -95,17 +103,20 @@ fun EventEditorSheet(
var startTime by remember { var startTime by remember {
mutableStateOf(initialStart?.let { LocalTime.ofInstant(it, zone).withSecond(0).withNano(0) } ?: LocalTime.of(9, 0)) 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 { var endDate by remember {
mutableStateOf( mutableStateOf(
initialEnd?.let { initialEnd?.let {
val d = LocalDate.ofInstant(it, zone) val d = LocalDate.ofInstant(it, zone)
if (template?.isAllDay == true) d.minusDays(1) else d if (template?.isAllDay == true) d.minusDays(1) else d
} ?: request.date } ?: defaultEnd.toLocalDate()
) )
} }
var endTime by remember { 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 preselected = template?.let { ev ->
val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull() val id = calendarKey(ev.source, ev.calendarId).substringAfter(":").toIntOrNull()
@@ -231,13 +242,42 @@ fun EventEditorSheet(
} }
Spacer(Modifier.size(12.dp)) Spacer(Modifier.size(12.dp))
// Private (local calendars only) // Private + reminders (local calendars only)
if (calendar?.source == "local") { if (calendar?.source == "local") {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge) Text(tr("event.private"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = isPrivate, onCheckedChange = { isPrivate = it }) Switch(checked = isPrivate, onCheckedChange = { isPrivate = it })
} }
Spacer(Modifier.size(12.dp)) 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 // Color
@@ -280,7 +320,8 @@ fun EventEditorSheet(
start = startDate.atTime(startTime).atZone(zone).toInstant() start = startDate.atTime(startTime).atZone(zone).toInstant()
end = endDate.atTime(endTime).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(), enabled = writableCalendars.isNotEmpty(),
modifier = Modifier.fillMaxWidth(), 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) Text(tr("settings.dimpast"), style = MaterialTheme.typography.bodyLarge)
Switch(checked = settings.dimPastEvents, onCheckedChange = { update(settings.copy(dimPastEvents = it)) }) 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)) Divider(Modifier.padding(vertical = 16.dp))
Section(tr("settings.language")) Section(tr("settings.language"))