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.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>
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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",
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
Reference in New Issue
Block a user