feat: custom reminder picker, muted-calendar hint, synced default duration

- Reminder editor: presets + custom number+unit (minutes/hours/days/weeks)
- Grey out + footer hint when the selected calendar's reminders are muted;
  reminders are kept, scheduler already skips them
- New synced setting defaultEventDurationMinutes for new events

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-06-15 10:03:24 +02:00
parent 544e0d9265
commit cc3d16ddce
6 changed files with 148 additions and 13 deletions

View File

@@ -11,6 +11,7 @@ struct EventEditorSheet: View {
@Environment(\.dismiss) var dismiss
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
@State private var title = ""
@State private var isAllDay = false
@State private var startDate = Date()
@@ -31,6 +32,15 @@ struct EventEditorSheet: View {
store.writableCalendars.first { $0.id == selectedCalendarId }
}
/// True when the selected calendar has its reminders muted: we keep any
/// existing reminders on the event (never delete them) but grey out the
/// controls and explain that they won't fire.
private var remindersDisabled: Bool {
guard let cal = selectedCal else { return false }
let key = CalendarStore.calendarKey(source: cal.source, calendarId: String(cal.numericId))
return store.reminderDisabledKeys.contains(key)
}
var body: some View {
NavigationStack {
Form {
@@ -84,26 +94,26 @@ struct EventEditorSheet: View {
.tint(Color.accentColor)
}
Section(ReminderOptions.sectionTitle(appLang)) {
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
get: { reminders.indices.contains(idx) ? reminders[idx] : 0 },
set: { if reminders.indices.contains(idx) { reminders[idx] = $0 } }
)) {
ForEach(ReminderOptions.all, id: \.self) { opt in
Text(ReminderOptions.label(opt, appLang)).tag(opt)
}
}
.labelsHidden()
Section {
ForEach(reminders.indices, id: \.self) { idx in
ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
}
.onDelete { reminders.remove(atOffsets: $0) }
Button {
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
let next = ReminderOptions.presets.first { !reminders.contains($0) }
?? ReminderOptions.customDefault
reminders.append(next)
} label: {
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
}
} header: {
Text(ReminderOptions.sectionTitle(appLang))
} footer: {
if remindersDisabled {
Text(ReminderOptions.disabledNote(appLang)).foregroundStyle(.orange)
}
}
.disabled(remindersDisabled)
}
Section(L10n.t("event.color_section", appLang)) {
@@ -197,7 +207,8 @@ struct EventEditorSheet: View {
let cal = Calendar.current
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
minute: 0, second: 0, of: initialDate) ?? initialDate
endDate = startDate.addingTimeInterval(3600)
let durMin = defaultEventDurationMinutes > 0 ? defaultEventDurationMinutes : 60
endDate = startDate.addingTimeInterval(Double(durMin) * 60)
selectedCalendarId = store.writableCalendars.first?.id ?? ""
// New events inherit the user's default reminder (editable).
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
@@ -263,3 +274,54 @@ struct EventEditorSheet: View {
}
}
}
/// One reminder row: a preset picker plus, when "Custom" is chosen, a number
/// stepper and a unit picker. The value is always stored as minutes-before-start.
private struct ReminderEditRow: View {
@Binding var minutes: Int
let appLang: String
private let customTag = -1
private var isPreset: Bool { ReminderOptions.presets.contains(minutes) }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Picker("", selection: Binding(
get: { isPreset ? minutes : customTag },
set: { newVal in
if newVal == customTag {
if ReminderOptions.presets.contains(minutes) { minutes = ReminderOptions.customDefault }
} else {
minutes = newVal
}
}
)) {
ForEach(ReminderOptions.presets, id: \.self) { Text(ReminderOptions.label($0, appLang)).tag($0) }
Text(ReminderOptions.customLabel(appLang)).tag(customTag)
}
.labelsHidden()
if !isPreset {
HStack {
Stepper(value: Binding(
get: { ReminderOptions.split(minutes).value },
set: { minutes = max(1, $0) * ReminderOptions.split(minutes).unit.mult }
), in: 1...999) {
Text("\(ReminderOptions.split(minutes).value)")
.monospacedDigit()
}
Picker("", selection: Binding(
get: { ReminderOptions.split(minutes).unit },
set: { newUnit in minutes = ReminderOptions.split(minutes).value * newUnit.mult }
)) {
ForEach(ReminderOptions.Unit.allCases) { u in
Text(ReminderOptions.unitLabel(u, appLang)).tag(u)
}
}
.labelsHidden()
Text(ReminderOptions.beforeLabel(appLang)).foregroundStyle(.secondary)
}
}
}
}
}

View File

@@ -23,6 +23,7 @@ struct SettingsView: View {
@AppStorage("weekStartDay") private var weekStartDay = "monday"
@AppStorage("dimPastEvents") private var dimPastEvents = false
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
// Profile chapter (server-backed; loaded on appear).
@State private var displayName = ""
@@ -360,6 +361,12 @@ struct SettingsView: View {
}
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
.tint(Color.accentColor)
Picker(L10n.t("settings.default_duration", appLang), selection: $defaultEventDurationMinutes) {
ForEach([15, 30, 45, 60, 90, 120, 240], id: \.self) { m in
Text(ReminderOptions.durationLabel(m, appLang)).tag(m)
}
}
.onChange(of: defaultEventDurationMinutes) { _, _ in SettingsSync.push(api: api) }
}
}