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 privateEventVisibility: String = "busy" // 'hidden' | 'busy'
|
||||||
var groupVisibleCalendarId: Int? = nil
|
var groupVisibleCalendarId: Int? = nil
|
||||||
var defaultReminderMinutes: Int? = nil // minutes before start; nil = off
|
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 {
|
enum CodingKeys: String, CodingKey {
|
||||||
case defaultView = "default_view"
|
case defaultView = "default_view"
|
||||||
@@ -39,6 +40,7 @@ struct AppSettings: Codable {
|
|||||||
case privateEventVisibility = "private_event_visibility"
|
case privateEventVisibility = "private_event_visibility"
|
||||||
case groupVisibleCalendarId = "group_visible_calendar_id"
|
case groupVisibleCalendarId = "group_visible_calendar_id"
|
||||||
case defaultReminderMinutes = "default_reminder_minutes"
|
case defaultReminderMinutes = "default_reminder_minutes"
|
||||||
|
case defaultEventDurationMinutes = "default_event_duration_minutes"
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
@@ -69,6 +71,7 @@ struct AppSettings: Codable {
|
|||||||
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
privateEventVisibility = try c.decodeIfPresent(String.self, forKey: .privateEventVisibility) ?? d.privateEventVisibility
|
||||||
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
groupVisibleCalendarId = try c.decodeIfPresent(Int.self, forKey: .groupVisibleCalendarId)
|
||||||
defaultReminderMinutes = try c.decodeIfPresent(Int.self, forKey: .defaultReminderMinutes)
|
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.monday": "Montag",
|
||||||
"settings.sunday": "Sonntag",
|
"settings.sunday": "Sonntag",
|
||||||
"settings.dimpast": "Vergangene Termine ausgrauen",
|
"settings.dimpast": "Vergangene Termine ausgrauen",
|
||||||
|
"settings.default_duration": "Standard-Termindauer",
|
||||||
"settings.nav.profile": "Profil",
|
"settings.nav.profile": "Profil",
|
||||||
"settings.privacy": "Privatsphäre",
|
"settings.privacy": "Privatsphäre",
|
||||||
"settings.private_visibility": "Private Termine für Gruppen",
|
"settings.private_visibility": "Private Termine für Gruppen",
|
||||||
@@ -455,6 +456,7 @@ private let strings: [String: [String: String]] = [
|
|||||||
"settings.monday": "Monday",
|
"settings.monday": "Monday",
|
||||||
"settings.sunday": "Sunday",
|
"settings.sunday": "Sunday",
|
||||||
"settings.dimpast": "Dim past events",
|
"settings.dimpast": "Dim past events",
|
||||||
|
"settings.default_duration": "Default event duration",
|
||||||
"settings.nav.profile": "Profile",
|
"settings.nav.profile": "Profile",
|
||||||
"settings.privacy": "Privacy",
|
"settings.privacy": "Privacy",
|
||||||
"settings.private_visibility": "Private events for groups",
|
"settings.private_visibility": "Private events for groups",
|
||||||
|
|||||||
@@ -7,6 +7,63 @@ enum ReminderOptions {
|
|||||||
/// Selectable offsets in minutes-before-start.
|
/// Selectable offsets in minutes-before-start.
|
||||||
static let all: [Int] = [0, 5, 15, 30, 60, 1440, 10080]
|
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 {
|
private static func isEnglish(_ appLang: String) -> Bool {
|
||||||
if appLang == "en" { return true }
|
if appLang == "en" { return true }
|
||||||
if appLang == "de" { return false }
|
if appLang == "de" { return false }
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ enum SettingsSync {
|
|||||||
static let weekStartDay = "weekStartDay"
|
static let weekStartDay = "weekStartDay"
|
||||||
static let dimPastEvents = "dimPastEvents"
|
static let dimPastEvents = "dimPastEvents"
|
||||||
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
static let defaultReminder = "defaultReminderMinutes" // Int, -1 = off
|
||||||
|
static let defaultEventDuration = "defaultEventDurationMinutes" // Int minutes
|
||||||
// master switch
|
// master switch
|
||||||
static let enabled = "settingsSync"
|
static let enabled = "settingsSync"
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,7 @@ enum SettingsSync {
|
|||||||
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
s.dimPastEvents = UserDefaults.standard.bool(forKey: Key.dimPastEvents)
|
||||||
let rem = int(Key.defaultReminder, -1)
|
let rem = int(Key.defaultReminder, -1)
|
||||||
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
s.defaultReminderMinutes = rem < 0 ? nil : rem
|
||||||
|
s.defaultEventDurationMinutes = int(Key.defaultEventDuration, 60)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +90,7 @@ enum SettingsSync {
|
|||||||
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
d.set(s.weekStartDay, forKey: Key.weekStartDay)
|
||||||
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
d.set(s.dimPastEvents, forKey: Key.dimPastEvents)
|
||||||
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
d.set(s.defaultReminderMinutes ?? -1, forKey: Key.defaultReminder)
|
||||||
|
d.set(s.defaultEventDurationMinutes, forKey: Key.defaultEventDuration)
|
||||||
guard includeOptional else { return }
|
guard includeOptional else { return }
|
||||||
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
// NOTE: textColor / backgroundColor / lineColor are intentionally NOT
|
||||||
// synced – the server has no columns for them (iOS-only). Writing the
|
// synced – the server has no columns for them (iOS-only). Writing the
|
||||||
@@ -139,6 +142,7 @@ enum SettingsSync {
|
|||||||
merged.weekStartDay = local.weekStartDay
|
merged.weekStartDay = local.weekStartDay
|
||||||
merged.dimPastEvents = local.dimPastEvents
|
merged.dimPastEvents = local.dimPastEvents
|
||||||
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
merged.defaultReminderMinutes = local.defaultReminderMinutes
|
||||||
|
merged.defaultEventDurationMinutes = local.defaultEventDurationMinutes
|
||||||
if isEnabled {
|
if isEnabled {
|
||||||
merged.primaryColor = local.primaryColor
|
merged.primaryColor = local.primaryColor
|
||||||
merged.accentColor = local.accentColor
|
merged.accentColor = local.accentColor
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct EventEditorSheet: View {
|
|||||||
@Environment(\.dismiss) var dismiss
|
@Environment(\.dismiss) var dismiss
|
||||||
@AppStorage("appLanguage") private var appLang = "system"
|
@AppStorage("appLanguage") private var appLang = "system"
|
||||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
|
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
||||||
@State private var title = ""
|
@State private var title = ""
|
||||||
@State private var isAllDay = false
|
@State private var isAllDay = false
|
||||||
@State private var startDate = Date()
|
@State private var startDate = Date()
|
||||||
@@ -31,6 +32,15 @@ struct EventEditorSheet: View {
|
|||||||
store.writableCalendars.first { $0.id == selectedCalendarId }
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
Form {
|
Form {
|
||||||
@@ -84,27 +94,27 @@ struct EventEditorSheet: View {
|
|||||||
.tint(Color.accentColor)
|
.tint(Color.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(ReminderOptions.sectionTitle(appLang)) {
|
Section {
|
||||||
ForEach(Array(reminders.enumerated()), id: \.offset) { idx, _ in
|
ForEach(reminders.indices, id: \.self) { idx in
|
||||||
Picker(ReminderOptions.sectionTitle(appLang), selection: Binding(
|
ReminderEditRow(minutes: $reminders[idx], appLang: appLang)
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
.onDelete { reminders.remove(atOffsets: $0) }
|
.onDelete { reminders.remove(atOffsets: $0) }
|
||||||
Button {
|
Button {
|
||||||
let next = ReminderOptions.all.first { !reminders.contains($0) } ?? 15
|
let next = ReminderOptions.presets.first { !reminders.contains($0) }
|
||||||
|
?? ReminderOptions.customDefault
|
||||||
reminders.append(next)
|
reminders.append(next)
|
||||||
} label: {
|
} label: {
|
||||||
Label(ReminderOptions.addLabel(appLang), systemImage: "bell.badge.plus")
|
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)) {
|
Section(L10n.t("event.color_section", appLang)) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -197,7 +207,8 @@ struct EventEditorSheet: View {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
startDate = cal.date(bySettingHour: cal.component(.hour, from: initialDate),
|
||||||
minute: 0, second: 0, of: initialDate) ?? 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 ?? ""
|
selectedCalendarId = store.writableCalendars.first?.id ?? ""
|
||||||
// New events inherit the user's default reminder (editable).
|
// New events inherit the user's default reminder (editable).
|
||||||
if defaultReminderMinutes >= 0 { reminders = [defaultReminderMinutes] }
|
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("weekStartDay") private var weekStartDay = "monday"
|
||||||
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
@AppStorage("dimPastEvents") private var dimPastEvents = false
|
||||||
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
@AppStorage("defaultReminderMinutes") private var defaultReminderMinutes = -1
|
||||||
|
@AppStorage("defaultEventDurationMinutes") private var defaultEventDurationMinutes = 60
|
||||||
|
|
||||||
// Profile chapter (server-backed; loaded on appear).
|
// Profile chapter (server-backed; loaded on appear).
|
||||||
@State private var displayName = ""
|
@State private var displayName = ""
|
||||||
@@ -360,6 +361,12 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
Toggle(L10n.t("settings.dimpast", appLang), isOn: $dimPastEvents)
|
||||||
.tint(Color.accentColor)
|
.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