Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Willpower/Utilities/FormatUtilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Willpower/ViewModels/WillpowerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions Willpower/Views/Blocklists/BlocklistDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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"

Expand Down
147 changes: 102 additions & 45 deletions Willpower/Views/Schedules/ScheduleDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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) {
Expand Down
35 changes: 21 additions & 14 deletions Willpower/Views/Schedules/ScheduleListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions Willpower/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
8 changes: 1 addition & 7 deletions Willpower/Views/Triggers/TriggerDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down