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