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:
@@ -2,6 +2,7 @@
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".CalendarrApplication"
|
||||
@@ -21,5 +22,9 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".notifications.ReminderReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user