diff --git a/Calendarr iOS/Models/AppSettings.swift b/Calendarr iOS/Models/AppSettings.swift index d392685..a90560f 100644 --- a/Calendarr iOS/Models/AppSettings.swift +++ b/Calendarr iOS/Models/AppSettings.swift @@ -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 } } diff --git a/Calendarr iOS/Models/Localization.swift b/Calendarr iOS/Models/Localization.swift index 1f16d87..d488375 100644 --- a/Calendarr iOS/Models/Localization.swift +++ b/Calendarr iOS/Models/Localization.swift @@ -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", diff --git a/Calendarr iOS/Models/ReminderOptions.swift b/Calendarr iOS/Models/ReminderOptions.swift index 38cb32f..fed8370 100644 --- a/Calendarr iOS/Models/ReminderOptions.swift +++ b/Calendarr iOS/Models/ReminderOptions.swift @@ -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 } diff --git a/Calendarr iOS/Services/SettingsSync.swift b/Calendarr iOS/Services/SettingsSync.swift index 223b119..e324a6b 100644 --- a/Calendarr iOS/Services/SettingsSync.swift +++ b/Calendarr iOS/Services/SettingsSync.swift @@ -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 diff --git a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift index 87d7f28..e067a83 100644 --- a/Calendarr iOS/Views/Calendar/EventEditorSheet.swift +++ b/Calendarr iOS/Views/Calendar/EventEditorSheet.swift @@ -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) + } + } + } + } +} diff --git a/Calendarr iOS/Views/SettingsView.swift b/Calendarr iOS/Views/SettingsView.swift index f098bde..c6c1605 100644 --- a/Calendarr iOS/Views/SettingsView.swift +++ b/Calendarr iOS/Views/SettingsView.swift @@ -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) } } }