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:
@@ -19,6 +19,7 @@ struct AppSettings: Codable {
|
||||
var privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||
var groupVisibleCalendarId: Int? = nil
|
||||
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
||||
var defaultEventDurationMinutes: Int = 60 // applied to a new event's end time
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case defaultView = "default_view"
|
||||
@@ -39,6 +40,7 @@ struct AppSettings: Codable {
|
||||
case privateEventVisibility = "private_event_visibility"
|
||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||
case defaultReminderMinutes = "default_reminder_minutes"
|
||||
case defaultEventDurationMinutes = "default_event_duration_minutes"
|
||||
}
|
||||
|
||||
init() {}
|
||||
@@ -69,6 +71,7 @@ struct AppSettings: Codable {
|
||||
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
||||
defaultEventDurationMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultEventDurationMinutes) ?? d.defaultEventDurationMinutes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.monday": "Montag",
|
||||
"settings.sunday": "Sonntag",
|
||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||
"settings.default_duration": "Standard-Termindauer",
|
||||
"settings.nav.profile": "Profil",
|
||||
"settings.privacy": "Privatsphäre",
|
||||
"settings.private_visibility": "Private Termine für Gruppen",
|
||||
@@ -455,6 +456,7 @@ private let strings: [String: [String: String]] = [
|
||||
"settings.monday": "Monday",
|
||||
"settings.sunday": "Sunday",
|
||||
"settings.dimpast": "Dim past events",
|
||||
"settings.default_duration": "Default event duration",
|
||||
"settings.nav.profile": "Profile",
|
||||
"settings.privacy": "Privacy",
|
||||
"settings.private_visibility": "Private events for groups",
|
||||
|
||||
@@ -7,6 +7,63 @@ enum ReminderOptions {
|
||||
/// Selectable offsets in minutes-before-start.
|
||||
static let all: [Int] = [0, 5, 15, 30, 60, 1440, 10080]
|
||||
|
||||
/// Quick presets shown in the picker; everything else is entered as a
|
||||
/// custom number + unit. Default for a freshly-switched custom row.
|
||||
static let presets: [Int] = [0, 30, 1440] // at start, 30 min, 1 day
|
||||
static let customDefault = 120 // 2 hours (deliberately not a preset)
|
||||
|
||||
/// Time units for the custom picker (value × mult = minutes-before-start).
|
||||
enum Unit: Int, CaseIterable, Identifiable {
|
||||
case minutes, hours, days, weeks
|
||||
var id: Int { rawValue }
|
||||
var mult: Int {
|
||||
switch self {
|
||||
case .minutes: return 1
|
||||
case .hours: return 60
|
||||
case .days: return 1440
|
||||
case .weeks: return 10080
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a minutes value into the largest exact {value, unit} for the custom picker.
|
||||
static func split(_ minutes: Int) -> (value: Int, unit: Unit) {
|
||||
for u in Unit.allCases.reversed() where minutes > 0 && minutes % u.mult == 0 {
|
||||
return (minutes / u.mult, u)
|
||||
}
|
||||
return (max(1, minutes), .minutes)
|
||||
}
|
||||
|
||||
static func unitLabel(_ u: Unit, _ l: String) -> String {
|
||||
let en = isEnglish(l)
|
||||
switch u {
|
||||
case .minutes: return en ? "minutes" : "Minuten"
|
||||
case .hours: return en ? "hours" : "Stunden"
|
||||
case .days: return en ? "days" : "Tage"
|
||||
case .weeks: return en ? "weeks" : "Wochen"
|
||||
}
|
||||
}
|
||||
/// Human label for a duration in minutes (no "before" suffix), e.g. "1 h", "30 min".
|
||||
static func durationLabel(_ minutes: Int, _ l: String) -> String {
|
||||
let en = isEnglish(l)
|
||||
if minutes % 60 == 0 {
|
||||
let h = minutes / 60
|
||||
return en ? "\(h) h" : "\(h) Std."
|
||||
}
|
||||
if minutes > 60 {
|
||||
let h = minutes / 60, m = minutes % 60
|
||||
return "\(h):\(String(format: "%02d", m)) h"
|
||||
}
|
||||
return en ? "\(minutes) min" : "\(minutes) Min."
|
||||
}
|
||||
static func customLabel(_ l: String) -> String { isEnglish(l) ? "Custom…" : "Benutzerdefiniert…" }
|
||||
static func beforeLabel(_ l: String) -> String { isEnglish(l) ? "before" : "vorher" }
|
||||
static func disabledNote(_ l: String) -> String {
|
||||
isEnglish(l)
|
||||
? "Reminders are disabled for this calendar – they will not fire."
|
||||
: "Für diesen Kalender sind Benachrichtigungen deaktiviert – Erinnerungen werden nicht ausgeführt."
|
||||
}
|
||||
|
||||
private static func isEnglish(_ appLang: String) -> Bool {
|
||||
if appLang == "en" { return true }
|
||||
if appLang == "de" { return false }
|
||||
|
||||
@@ -38,6 +38,7 @@ enum SettingsSync {
|
||||
static let weekStartDay = "weekStartDay"
|
||||
static let dimPastEvents = "dimPastEvents"
|
||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||
static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes
|
||||
// master switch
|
||||
static let enabled = "settingsSync"
|
||||
}
|
||||
@@ -74,6 +75,7 @@ enum SettingsSync {
|
||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||
let rem = int(Key.defaultReminder, -1)
|
||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||
s.defaultEventDurationMinutes = int(Key.defaultEventDuration, 60)
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -88,6 +90,7 @@ enum SettingsSync {
|
||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||
d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration)
|
||||
guard includeOptional else { return }
|
||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||
// synced – the server has no columns for them (iOS-only). Writing the
|
||||
@@ -139,6 +142,7 @@ enum SettingsSync {
|
||||
merged.weekStartDay = local.weekStartDay
|
||||
merged.dimPastEvents = local.dimPastEvents
|
||||
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||
merged.defaultEventDurationMinutes = local.defaultEventDurationMinutes
|
||||
if isEnabled {
|
||||
merged.primaryColor = local.primaryColor
|
||||
merged.accentColor = local.accentColor
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user