diff --git a/Willpower/Utilities/FormatUtilities.swift b/Willpower/Utilities/FormatUtilities.swift index 1568ab4..828f7ff 100644 --- a/Willpower/Utilities/FormatUtilities.swift +++ b/Willpower/Utilities/FormatUtilities.swift @@ -24,6 +24,20 @@ func formatDuration(_ seconds: Int) -> String { } } +// MARK: - Time Formatting + +/// Format an hour/minute pair respecting the user's time format preference. +/// - Returns: e.g. "14:05" (24h) or "2:05 PM" (12h) +func formatTime(hour: Int, minute: Int, use24Hour: Bool) -> String { + if use24Hour { + return String(format: "%02d:%02d", hour, minute) + } else { + let isAM = hour < 12 + let hour12 = hour % 12 == 0 ? 12 : hour % 12 + return String(format: "%d:%02d %@", hour12, minute, isAM ? "AM" : "PM") + } +} + // MARK: - Visit Count Color /// Get a color based on the ratio of current visits to max visits diff --git a/Willpower/ViewModels/WillpowerViewModel.swift b/Willpower/ViewModels/WillpowerViewModel.swift index a72868f..4b739c8 100644 --- a/Willpower/ViewModels/WillpowerViewModel.swift +++ b/Willpower/ViewModels/WillpowerViewModel.swift @@ -60,6 +60,9 @@ final class WillpowerViewModel { /// Global daily visit counter reset time (minute 0-59) var dailyResetMinute: Int = 0 + /// Whether to display times in 24-hour format (false = 12-hour AM/PM) + var use24HourTime: Bool = false + /// Daemon status var isDaemonRunning: Bool = false var lastDaemonHeartbeat: Date? @@ -115,6 +118,7 @@ final class WillpowerViewModel { /// Local storage keys for global daily reset time private let localDailyResetHourKey = "willpower.local.dailyResetHour" private let localDailyResetMinuteKey = "willpower.local.dailyResetMinute" + private let localUse24HourTimeKey = "willpower.local.use24HourTime" /// Tracks blocklist IDs with pending optimistic blocks (not yet confirmed by daemon) /// Used to prevent syncState() from overwriting optimistic updates before daemon processes them @@ -169,6 +173,9 @@ final class WillpowerViewModel { dailyResetHour = defaults.integer(forKey: localDailyResetHourKey) dailyResetMinute = defaults.integer(forKey: localDailyResetMinuteKey) } + if defaults.object(forKey: localUse24HourTimeKey) != nil { + use24HourTime = defaults.bool(forKey: localUse24HourTimeKey) + } } private func saveLocalState() { @@ -180,6 +187,7 @@ final class WillpowerViewModel { } UserDefaults.standard.set(dailyResetHour, forKey: localDailyResetHourKey) UserDefaults.standard.set(dailyResetMinute, forKey: localDailyResetMinuteKey) + UserDefaults.standard.set(use24HourTime, forKey: localUse24HourTimeKey) } // MARK: - State Synchronization @@ -642,6 +650,14 @@ final class WillpowerViewModel { } } + // MARK: - Time Format + + /// Update the time display format preference + func updateTimeFormat(use24Hour: Bool) { + use24HourTime = use24Hour + saveLocalState() + } + // MARK: - Computed Properties /// Regular blocklists (block mode only) diff --git a/Willpower/Views/Blocklists/BlocklistDetailView.swift b/Willpower/Views/Blocklists/BlocklistDetailView.swift index b8c18b2..491449d 100644 --- a/Willpower/Views/Blocklists/BlocklistDetailView.swift +++ b/Willpower/Views/Blocklists/BlocklistDetailView.swift @@ -522,7 +522,7 @@ struct TriggerSummaryRow: View { Text(trigger.type.displayName) .font(.subheadline) - Text(trigger.summary) + Text(trigger.summary(use24Hour: viewModel.use24HourTime)) .font(.caption) .foregroundStyle(.secondary) } @@ -588,7 +588,7 @@ extension TriggerType { // MARK: - TriggerConfig Summary extension TriggerConfig { - var summary: String { + func summary(use24Hour: Bool = false) -> String { switch type { case .timeBased: if let tb = timeBased { @@ -602,7 +602,7 @@ extension TriggerConfig { case .scheduleBased: if let sb = scheduleBased, let window = sb.windows.first { - return "\(window.timeRangeDescription) \(window.weekdaysDescription)" + return "\(window.timeRangeDescription(use24Hour: use24Hour)) \(window.weekdaysDescription)" } return "Not configured" diff --git a/Willpower/Views/Schedules/ScheduleDetailView.swift b/Willpower/Views/Schedules/ScheduleDetailView.swift index 3a8725a..fe64678 100644 --- a/Willpower/Views/Schedules/ScheduleDetailView.swift +++ b/Willpower/Views/Schedules/ScheduleDetailView.swift @@ -94,67 +94,118 @@ struct ScheduleDetailView: View { Section("Time Range") { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 0) { GridRow { - // Start time - HStack(spacing: 4) { - Picker("Hour", selection: $startDisplayHour) { - ForEach(1...12, id: \.self) { - Text("\($0)").tag($0) + if viewModel.use24HourTime { + // Start time — 24-hour + HStack(spacing: 4) { + Picker("Hour", selection: $startHour) { + ForEach(0...23, id: \.self) { + Text(String(format: "%02d", $0)).tag($0) + } } - } - .labelsHidden() - .frame(width: 50) + .labelsHidden() + .frame(width: 60) - Text(":") - .foregroundStyle(.secondary) + Text(":") + .foregroundStyle(.secondary) - Picker("Minute", selection: $startMinute) { - ForEach([0, 15, 30, 45], id: \.self) { - Text(String(format: "%02d", $0)).tag($0) + Picker("Minute", selection: $startMinute) { + ForEach([0, 15, 30, 45], id: \.self) { + Text(String(format: "%02d", $0)).tag($0) + } } + .labelsHidden() + .frame(width: 50) } - .labelsHidden() - .frame(width: 50) - Picker("AM/PM", selection: $startIsAM) { - Text("AM").tag(true) - Text("PM").tag(false) - } - .labelsHidden() - .frame(width: 55) - } + // Arrow + Image(systemName: "arrow.right") + .foregroundStyle(.tertiary) + .font(.title3) + + // End time — 24-hour + HStack(spacing: 4) { + Picker("Hour", selection: $endHour) { + ForEach(0...23, id: \.self) { + Text(String(format: "%02d", $0)).tag($0) + } + } + .labelsHidden() + .frame(width: 60) - // Arrow - Image(systemName: "arrow.right") - .foregroundStyle(.tertiary) - .font(.title3) + Text(":") + .foregroundStyle(.secondary) - // End time - HStack(spacing: 4) { - Picker("Hour", selection: $endDisplayHour) { - ForEach(1...12, id: \.self) { - Text("\($0)").tag($0) + Picker("Minute", selection: $endMinute) { + ForEach([0, 15, 30, 45], id: \.self) { + Text(String(format: "%02d", $0)).tag($0) + } } + .labelsHidden() + .frame(width: 50) } - .labelsHidden() - .frame(width: 50) + } else { + // Start time — 12-hour + HStack(spacing: 4) { + Picker("Hour", selection: $startDisplayHour) { + ForEach(1...12, id: \.self) { + Text("\($0)").tag($0) + } + } + .labelsHidden() + .frame(width: 50) - Text(":") - .foregroundStyle(.secondary) + Text(":") + .foregroundStyle(.secondary) - Picker("Minute", selection: $endMinute) { - ForEach([0, 15, 30, 45], id: \.self) { - Text(String(format: "%02d", $0)).tag($0) + Picker("Minute", selection: $startMinute) { + ForEach([0, 15, 30, 45], id: \.self) { + Text(String(format: "%02d", $0)).tag($0) + } } + .labelsHidden() + .frame(width: 50) + + Picker("AM/PM", selection: $startIsAM) { + Text("AM").tag(true) + Text("PM").tag(false) + } + .labelsHidden() + .frame(width: 55) } - .labelsHidden() - .frame(width: 50) - Picker("AM/PM", selection: $endIsAM) { - Text("AM").tag(true) - Text("PM").tag(false) + // Arrow + Image(systemName: "arrow.right") + .foregroundStyle(.tertiary) + .font(.title3) + + // End time — 12-hour + HStack(spacing: 4) { + Picker("Hour", selection: $endDisplayHour) { + ForEach(1...12, id: \.self) { + Text("\($0)").tag($0) + } + } + .labelsHidden() + .frame(width: 50) + + Text(":") + .foregroundStyle(.secondary) + + Picker("Minute", selection: $endMinute) { + ForEach([0, 15, 30, 45], id: \.self) { + Text(String(format: "%02d", $0)).tag($0) + } + } + .labelsHidden() + .frame(width: 50) + + Picker("AM/PM", selection: $endIsAM) { + Text("AM").tag(true) + Text("PM").tag(false) + } + .labelsHidden() + .frame(width: 55) } - .labelsHidden() - .frame(width: 55) } } } @@ -185,22 +236,28 @@ struct ScheduleDetailView: View { .formStyle(.grouped) .navigationTitle("Schedule") .onChange(of: startDisplayHour) { _, newValue in + guard !viewModel.use24HourTime else { return } startHour = to24Hour(newValue, isAM: startIsAM) saveIfValid() } .onChange(of: startIsAM) { _, newValue in + guard !viewModel.use24HourTime else { return } startHour = to24Hour(startDisplayHour, isAM: newValue) saveIfValid() } + .onChange(of: startHour) { _, _ in saveIfValid() } .onChange(of: startMinute) { _, _ in saveIfValid() } .onChange(of: endDisplayHour) { _, newValue in + guard !viewModel.use24HourTime else { return } endHour = to24Hour(newValue, isAM: endIsAM) saveIfValid() } .onChange(of: endIsAM) { _, newValue in + guard !viewModel.use24HourTime else { return } endHour = to24Hour(endDisplayHour, isAM: newValue) saveIfValid() } + .onChange(of: endHour) { _, _ in saveIfValid() } .onChange(of: endMinute) { _, _ in saveIfValid() } .onChange(of: selectedWeekdays) { _, _ in saveIfValid() } .alert("Delete Schedule?", isPresented: $isShowingDeleteConfirmation) { diff --git a/Willpower/Views/Schedules/ScheduleListView.swift b/Willpower/Views/Schedules/ScheduleListView.swift index c76bb4d..1171555 100644 --- a/Willpower/Views/Schedules/ScheduleListView.swift +++ b/Willpower/Views/Schedules/ScheduleListView.swift @@ -160,7 +160,7 @@ struct ScheduleRowView: View { if let schedule = trigger.scheduleBased { ForEach(schedule.windows) { window in HStack { - Text(window.timeRangeDescription) + Text(window.timeRangeDescription(use24Hour: viewModel.use24HourTime)) .font(.subheadline) Text(window.weekdaysDescription) .font(.caption) @@ -185,21 +185,28 @@ struct ScheduleRowView: View { // MARK: - ScheduleWindow Extensions extension ScheduleBasedTrigger.ScheduleWindow { - var timeRangeDescription: String { - func format12Hour(_ hour: Int, _ minute: Int) -> String { - let isAM = hour < 12 - var hour12 = hour % 12 - if hour12 == 0 { hour12 = 12 } - let period = isAM ? "AM" : "PM" - if minute == 0 { - return "\(hour12) \(period)" - } else { - return "\(hour12):\(String(format: "%02d", minute)) \(period)" + func timeRangeDescription(use24Hour: Bool = false) -> String { + if use24Hour { + func format24(_ hour: Int, _ minute: Int) -> String { + String(format: "%02d:%02d", hour, minute) } + return "\(format24(startHour, startMinute)) - \(format24(endHour, endMinute))" + } else { + func format12Hour(_ hour: Int, _ minute: Int) -> String { + let isAM = hour < 12 + var hour12 = hour % 12 + if hour12 == 0 { hour12 = 12 } + let period = isAM ? "AM" : "PM" + if minute == 0 { + return "\(hour12) \(period)" + } else { + return "\(hour12):\(String(format: "%02d", minute)) \(period)" + } + } + let startTime = format12Hour(startHour, startMinute) + let endTime = format12Hour(endHour, endMinute) + return "\(startTime) - \(endTime)" } - let startTime = format12Hour(startHour, startMinute) - let endTime = format12Hour(endHour, endMinute) - return "\(startTime) - \(endTime)" } var weekdaysDescription: String { diff --git a/Willpower/Views/Settings/SettingsView.swift b/Willpower/Views/Settings/SettingsView.swift index 14227dd..7c8f815 100644 --- a/Willpower/Views/Settings/SettingsView.swift +++ b/Willpower/Views/Settings/SettingsView.swift @@ -108,7 +108,15 @@ struct SettingsView: View { setLaunchAtLogin(newValue) } + Toggle(isOn: Binding( + get: { viewModel.use24HourTime }, + set: { viewModel.updateTimeFormat(use24Hour: $0) } + )) { + Label("Use 24-Hour Time", systemImage: "clock") + } + DatePicker("Daily Visit Reset", selection: $dailyResetTime, displayedComponents: .hourAndMinute) + .environment(\.locale, timeLocale) .onChange(of: dailyResetTime) { _, newValue in let hour = Calendar.current.component(.hour, from: newValue) let minute = Calendar.current.component(.minute, from: newValue) @@ -258,6 +266,12 @@ struct SettingsView: View { // MARK: - Computed Properties + private var timeLocale: Locale { + var components = Locale.Components(locale: .current) + components.hourCycle = viewModel.use24HourTime ? .zeroToTwentyThree : .oneToTwelve + return Locale(components: components) + } + private var blockingStatusColor: Color { if viewModel.isDaemonRunning { return viewModel.activeBlocks.isEmpty ? .green : .blue diff --git a/Willpower/Views/Triggers/TriggerDetailView.swift b/Willpower/Views/Triggers/TriggerDetailView.swift index 37dbabb..59591bd 100644 --- a/Willpower/Views/Triggers/TriggerDetailView.swift +++ b/Willpower/Views/Triggers/TriggerDetailView.swift @@ -74,13 +74,7 @@ struct TriggerDetailView: View { /// Formatted global daily reset time from Settings var globalResetTimeFormatted: String { - let date = Calendar.current.date( - bySettingHour: viewModel.dailyResetHour, - minute: viewModel.dailyResetMinute, - second: 0, - of: Date() - ) ?? Date() - return date.formatted(date: .omitted, time: .shortened) + formatTime(hour: viewModel.dailyResetHour, minute: viewModel.dailyResetMinute, use24Hour: viewModel.use24HourTime) } /// Formatted duration string for display