diff --git a/OpenWorktimeTracker.xcodeproj/project.pbxproj b/OpenWorktimeTracker.xcodeproj/project.pbxproj index 6cfc625..d0188a3 100644 --- a/OpenWorktimeTracker.xcodeproj/project.pbxproj +++ b/OpenWorktimeTracker.xcodeproj/project.pbxproj @@ -26,9 +26,12 @@ 3700A1C2970A97914BE1CAF3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8D50E67E11943961E87A65F6 /* Assets.xcassets */; }; 3753A83912DE9247D4CA46CC /* Color+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED914F58E92DF73CF1547A3 /* Color+Extensions.swift */; }; 3C023C1963125F860E660730 /* BreakCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C14FD2343077B9FC533EAC07 /* BreakCalculator.swift */; }; + 3CA50EE21B7AC6011D3894E4 /* WorkdayAppearanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F4BA3205F0AF90FC0891C71 /* WorkdayAppearanceTests.swift */; }; 3D60D0048DED858DE6BB52DA /* PersistenceManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB0E2224DEF41A69EC1E92AA /* PersistenceManagerTests.swift */; }; + 3F944373B6FF10C633D2D18C /* WorkdayAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */; }; 4352E3077EFA1D9BDA71E053 /* WorkdayManagerSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */; }; 44EFF479547F25448ADD540B /* WeekHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1089C3A7EF27586EB233E4B /* WeekHistoryView.swift */; }; + 45624526A8C5B075E6BD6398 /* TimeInterval+Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */; }; 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */; }; 4848E362C452EB5A63DB318E /* ThresholdLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */; }; 485EAA20FC6AEEBF349564EC /* SharedDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */; }; @@ -46,8 +49,10 @@ 7A4B038D2A8FCAF7D3990342 /* IdlePeriod.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F818FB696E0038F88C3B05 /* IdlePeriod.swift */; }; 7D1AA8C747743D0DBCF47233 /* WorkdayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D92EF9892D12D3D1DEA3418 /* WorkdayTests.swift */; }; 7F193BB5F12158FB4A228045 /* BreakCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3FBAF5148F3F5E30F02C437 /* BreakCalculatorTests.swift */; }; + 81EA84A23CCFA789493D462E /* WorkdayAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */; }; 8FDF3B4B103BB741A36E13A7 /* IdleDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 587CB7F059336B48B3DEE9CC /* IdleDetector.swift */; }; 956F97E8ED6A08E13330D6A2 /* IdlePromptWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65D365D24EC89B5F03E63743 /* IdlePromptWindowController.swift */; }; + 9884BB01691FD68AE126CC2E /* WorkdayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */; }; 993CFEE746E242DC4FE7A44E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09158E1920D76D6EF7B3585 /* AppDelegate.swift */; }; 9DD01273C074913E95147CA4 /* WorkdayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F597F7658C3A59EB200E8F1 /* WorkdayManager.swift */; }; A37E2A4BE6EA5DF1F0FBA60B /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC1763B590C81EB59DF0B300 /* ProgressBarView.swift */; }; @@ -56,6 +61,7 @@ ADE593C4C405609F5F1B3894 /* WorkdayManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */; }; AEE1E6715AC00AE314B9D854 /* OpenWorktimeTrackerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90A98A9F938927C616301EA5 /* OpenWorktimeTrackerApp.swift */; }; B2942C9DBBAD0D5F95307152 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58B2CA010B0A3CBEDC5FA6F9 /* MenuBarView.swift */; }; + B9D145B81FC0A69E5F5951C2 /* TimeInterval+Duration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */; }; BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */; }; BF24A802B731C7DB18835234 /* LogEditorWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EEEFF607EEBE5390F88EB0B /* LogEditorWindowController.swift */; }; BF6602F8D9B236C3229F2DF8 /* WorkdayManagerLifecycleAndMutationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63FDA92C95403622A35BE0F3 /* WorkdayManagerLifecycleAndMutationTests.swift */; }; @@ -68,6 +74,7 @@ E2776F062926941396F6798E /* DailyLogFormatTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0534E968BFC7EE74714A15B /* DailyLogFormatTests.swift */; }; E7DB5A1B699217F6B7749C2E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7D8E211502FB3F2B407C29 /* SettingsView.swift */; }; EC00E2796568DA67817EE450 /* ThresholdLevel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */; }; + F126499AA55A6EDAF3159528 /* WorkdayState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */; }; F7E3DD2397658080B1E1FAA2 /* ViewLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */; }; F9CEAFEDD47DBEF79E904301 /* PersistenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F26F3D087AB990B78FB7094 /* PersistenceManager.swift */; }; FAA1B14DC80293F0B721C180 /* DailyLogStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9C8B5661F461CF5F7351190 /* DailyLogStore.swift */; }; @@ -109,6 +116,7 @@ /* Begin PBXFileReference section */ 032AEE83355F68A71F71365C /* ActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionButton.swift; sourceTree = ""; }; 03F65852D76A2EC82ACFC07D /* OpenWorktimeTracker.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenWorktimeTracker.entitlements; sourceTree = ""; }; + 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayAppearance.swift; sourceTree = ""; }; 0CB576F2CF5F2605464A6FEC /* TimerDisplayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerDisplayView.swift; sourceTree = ""; }; 0E368E8701FFBF179A415BBF /* IdleDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdleDetectorTests.swift; sourceTree = ""; }; 0E7D73A4BB2ACE5955D1E84A /* OpenWorktimeTrackerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OpenWorktimeTrackerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -150,6 +158,7 @@ 92ECFCF3E46C5163E13E2DBE /* WorktimeWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.extensionkit-extension"; includeInIndex = 0; path = WorktimeWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 96852475F3CF42C080C27424 /* TimeEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeEntry.swift; sourceTree = ""; }; 99AEB2B7772C3768CB0B7B4B /* WorkdayManagerSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerSettingsTests.swift; sourceTree = ""; }; + 9F4BA3205F0AF90FC0891C71 /* WorkdayAppearanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayAppearanceTests.swift; sourceTree = ""; }; A3636564F038D5714D3F2E9D /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; A504CCBF9BE58EA9A65A32F7 /* InMemoryDailyLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryDailyLogStore.swift; sourceTree = ""; }; A95771E351BA49672B02C68D /* MetricCardsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricCardsView.swift; sourceTree = ""; }; @@ -163,6 +172,7 @@ B96BF05DC18B804752127B67 /* IdlePromptView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdlePromptView.swift; sourceTree = ""; }; BC1763B590C81EB59DF0B300 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; BE27F160308976C1903EB127 /* AutoBreakRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBreakRules.swift; sourceTree = ""; }; + BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayState.swift; sourceTree = ""; }; C14FD2343077B9FC533EAC07 /* BreakCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreakCalculator.swift; sourceTree = ""; }; C770EF5C2029076BF88AF474 /* WorkdayManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkdayManagerTests.swift; sourceTree = ""; }; C8CBF6F9629F77799CD434AF /* GlassContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlassContainer.swift; sourceTree = ""; }; @@ -174,6 +184,7 @@ E32C98F3FE145BECC4EC7022 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; EEB4DD34A3D82EECDD4CBFB1 /* OpenWorktimeTracker.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenWorktimeTracker.app; sourceTree = BUILT_PRODUCTS_DIR; }; F4023084E6F188BEE2079400 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Duration.swift"; sourceTree = ""; }; F66D750410EAFCCE00D0FB84 /* Date+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extensions.swift"; sourceTree = ""; }; FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedDefaults.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -206,6 +217,9 @@ 1ED914F58E92DF73CF1547A3 /* Color+Extensions.swift */, FFCA465B8ABE2B90B1C03DF6 /* SharedDefaults.swift */, 54E2CD75761D381C44EA86DA /* ThresholdLevel.swift */, + F5BBA97022E94E552EE4928D /* TimeInterval+Duration.swift */, + 095CE1EE123ADDDAF03A6250 /* WorkdayAppearance.swift */, + BEBF376EB97AAD16E97B1D48 /* WorkdayState.swift */, ); path = Shared; sourceTree = ""; @@ -291,6 +305,7 @@ 7C0158B151E8021863597395 /* TimeEntryTests.swift */, 2BE8B1A4B3C062AB86A0362C /* ViewLayoutTests.swift */, 33B9F7CAAC70B234FABD39A1 /* WidgetSnapshotTests.swift */, + 9F4BA3205F0AF90FC0891C71 /* WorkdayAppearanceTests.swift */, 92CC8582D1A82A4ED0B8BE3D /* WorkdayDetectorTests.swift */, 47E5CA659F0918F09E3A2B2E /* WorkdayManagerClockAndStoreTests.swift */, 5692F3320868828C600D08B4 /* WorkdayManagerIdleHandlingTests.swift */, @@ -515,6 +530,7 @@ 330D1906DCAC60438162015F /* TimeEntryTests.swift in Sources */, F7E3DD2397658080B1E1FAA2 /* ViewLayoutTests.swift in Sources */, 6444D4148F53E5FA721F2814 /* WidgetSnapshotTests.swift in Sources */, + 3CA50EE21B7AC6011D3894E4 /* WorkdayAppearanceTests.swift in Sources */, 2E50C9C91938F36E479FAB46 /* WorkdayDetectorTests.swift in Sources */, 474C7362331605093E10F5D8 /* WorkdayManagerClockAndStoreTests.swift in Sources */, BA8452319FEBD0BB5443C7D1 /* WorkdayManagerIdleHandlingTests.swift in Sources */, @@ -563,11 +579,14 @@ 15061C70D45A5BEF76336071 /* SummaryStatsView.swift in Sources */, 4848E362C452EB5A63DB318E /* ThresholdLevel.swift in Sources */, 253CF5BCF181B9CD0DF0734F /* TimeEntry.swift in Sources */, + 45624526A8C5B075E6BD6398 /* TimeInterval+Duration.swift in Sources */, 01C8C4A1D1D00C2934B8A4A9 /* TimerDisplayView.swift in Sources */, 44EFF479547F25448ADD540B /* WeekHistoryView.swift in Sources */, CB63BC5DE6E64B89F587BF8D /* Workday.swift in Sources */, + 3F944373B6FF10C633D2D18C /* WorkdayAppearance.swift in Sources */, D0BA8C03FE5E382A0104E788 /* WorkdayDetector.swift in Sources */, 9DD01273C074913E95147CA4 /* WorkdayManager.swift in Sources */, + 9884BB01691FD68AE126CC2E /* WorkdayState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -578,6 +597,9 @@ 6B04E7D7384A3DC2FD19B4BF /* Color+Extensions.swift in Sources */, 485EAA20FC6AEEBF349564EC /* SharedDefaults.swift in Sources */, EC00E2796568DA67817EE450 /* ThresholdLevel.swift in Sources */, + B9D145B81FC0A69E5F5951C2 /* TimeInterval+Duration.swift in Sources */, + 81EA84A23CCFA789493D462E /* WorkdayAppearance.swift in Sources */, + F126499AA55A6EDAF3159528 /* WorkdayState.swift in Sources */, 68ACEAD4CBCEF8E71A25AD6D /* WorktimeWidget.swift in Sources */, 2C62CB1A6DA628C4FB944F2C /* WorktimeWidgetBundle.swift in Sources */, ); diff --git a/OpenWorktimeTracker/App/AppDelegate.swift b/OpenWorktimeTracker/App/AppDelegate.swift index 48fc43e..1ac39cf 100644 --- a/OpenWorktimeTracker/App/AppDelegate.swift +++ b/OpenWorktimeTracker/App/AppDelegate.swift @@ -189,10 +189,12 @@ final class MenuBarController: NSObject { var environment = EnvironmentValues() environment.colorScheme = NSApp.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? .dark : .light - let resolved = manager.thresholdLevel.accent.resolve(in: environment) - let color: NSColor? = manager.thresholdLevel == .normal ? nil : NSColor( - srgbRed: CGFloat(resolved.red), green: CGFloat(resolved.green), - blue: CGFloat(resolved.blue), alpha: CGFloat(resolved.opacity)) + let color = manager.thresholdLevel.menuBarAccent.map { accent in + let resolved = accent.color.resolve(in: environment) + return NSColor( + srgbRed: CGFloat(resolved.red), green: CGFloat(resolved.green), + blue: CGFloat(resolved.blue), alpha: CGFloat(resolved.opacity)) + } button?.contentTintColor = nil if let color { // A non-template image preserves threshold colors through menu-bar compositing. diff --git a/OpenWorktimeTracker/Core/Models/TimeEntry.swift b/OpenWorktimeTracker/Core/Models/TimeEntry.swift index 9fa3c13..617622f 100644 --- a/OpenWorktimeTracker/Core/Models/TimeEntry.swift +++ b/OpenWorktimeTracker/Core/Models/TimeEntry.swift @@ -13,6 +13,7 @@ struct TimeEntry: Codable, Identifiable { var note: String var lastActivityTime: Date? + /// Maps onto `WorkdayState`, which adds `notStarted` for when no Daily Log exists. enum Status: String, Codable { case running case paused @@ -114,3 +115,13 @@ struct IdleDecision: Codable, Identifiable { self.decision = decision } } + +extension WorkdayState { + init(_ status: TimeEntry.Status) { + switch status { + case .running: self = .running + case .paused: self = .paused + case .ended: self = .ended + } + } +} diff --git a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift index 8752230..e3d4e1c 100644 --- a/OpenWorktimeTracker/Core/Services/WorkdayManager.swift +++ b/OpenWorktimeTracker/Core/Services/WorkdayManager.swift @@ -11,14 +11,7 @@ final class WorkdayManager { // MARK: - State - enum State: String { - case notStarted - case running - case paused - case ended - } - - private(set) var state: State = .notStarted + private(set) var state: WorkdayState = .notStarted private(set) var currentWorkday: Workday? private(set) var displayTime: TimeInterval = 0 private(set) var grossTime: TimeInterval = 0 @@ -252,11 +245,7 @@ final class WorkdayManager { private func activate(_ workday: Workday) { currentWorkday = workday - switch workday.status { - case .running: state = .running - case .paused: state = .paused - case .ended: state = .ended - } + state = WorkdayState(workday.status) startTimer() if state == .running { idleDetector.startMonitoring() @@ -461,14 +450,13 @@ extension WorkdayManager { let thresholds = currentWorkday?.thresholds ?? .resolved(from: defaults) let snapshot = WidgetSnapshot( measuredAt: instant, - state: state.rawValue, + state: state, netTime: netTime, grossTime: grossTime, startTime: currentWorkday?.startTime, workDate: currentWorkday?.date ?? "", targetHours: notificationThresholds.normalHours, - orangeThreshold: thresholds.elevatedHours, - redThreshold: thresholds.criticalHours + thresholdLadder: thresholds ) do { try widgetStore.publish(snapshot) @@ -749,16 +737,3 @@ extension WorkdayManager { startDay(at: max(detector.startOfEffectiveDay(for: clock.now), period.idleEnd)) } } - -// MARK: - State Localization - -extension WorkdayManager.State { - var localizedLabel: String { - switch self { - case .notStarted: return String(localized: "state.notStarted") - case .running: return String(localized: "state.running") - case .paused: return String(localized: "state.paused") - case .ended: return String(localized: "state.ended") - } - } -} diff --git a/OpenWorktimeTracker/Design/DesignTokens.swift b/OpenWorktimeTracker/Design/DesignTokens.swift index c27f280..8edfd07 100644 --- a/OpenWorktimeTracker/Design/DesignTokens.swift +++ b/OpenWorktimeTracker/Design/DesignTokens.swift @@ -12,11 +12,11 @@ enum DesignTokens { static let tertiary = Color("tertiary", bundle: nil) static let error = Color("error", bundle: nil) - // Adaptive fallbacks using system colors - static let accentBlue = Color(light: .init(hex: 0x0058BC), dark: .init(hex: 0x007AFF)) - static let accentGreen = Color(light: .init(hex: 0x006B27), dark: .init(hex: 0x34C759)) - static let accentOrange = Color(light: .init(hex: 0x995000), dark: .init(hex: 0xFF9500)) - static let accentRed = Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) + // Accents come from the palette shared with the widget + static let accentBlue = PaletteColor.blue.color + static let accentGreen = PaletteColor.green.color + static let accentOrange = PaletteColor.orange.color + static let accentRed = PaletteColor.red.color static let surface = Color(light: .init(hex: 0xF9F9FE), dark: .init(hex: 0x1A1C1F)) static let surfaceContainerLow = Color( @@ -29,7 +29,7 @@ enum DesignTokens { static let onSurface = Color(light: .init(hex: 0x1A1C1F), dark: .init(hex: 0xE2E2E7)) static let onAccent = Color.white - static let onSurfaceVariant = Color(light: .init(hex: 0x414755), dark: .init(hex: 0xC1C6D7)) + static let onSurfaceVariant = PaletteColor.secondary.color static let outlineVariant = Color(light: .init(hex: 0xC1C6D7), dark: .init(hex: 0x44474E)) // Glass effect @@ -82,14 +82,3 @@ enum DesignTokens { static let popoverMinHeight: CGFloat = 640 static let promptWidth: CGFloat = 380 } - -extension ThresholdLevel { - /// The accent a Workday is painted with at this level. - var accent: Color { - switch self { - case .normal: return DesignTokens.Colors.accentBlue - case .elevated: return DesignTokens.Colors.accentOrange - case .critical: return DesignTokens.Colors.accentRed - } - } -} diff --git a/OpenWorktimeTracker/Utilities/Date+Extensions.swift b/OpenWorktimeTracker/Utilities/Date+Extensions.swift index c45e553..93bb6e1 100644 --- a/OpenWorktimeTracker/Utilities/Date+Extensions.swift +++ b/OpenWorktimeTracker/Utilities/Date+Extensions.swift @@ -25,13 +25,6 @@ extension Date { } extension TimeInterval { - var hoursMinutesFormatted: String { - let totalMinutes = Int(self) / 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - return String(format: "%02d:%02d", hours, minutes) - } - var hoursComponent: Int { Int(self) / 3600 } diff --git a/OpenWorktimeTracker/Views/LogEditorView.swift b/OpenWorktimeTracker/Views/LogEditorView.swift index 919bc79..2decf7e 100644 --- a/OpenWorktimeTracker/Views/LogEditorView.swift +++ b/OpenWorktimeTracker/Views/LogEditorView.swift @@ -109,6 +109,6 @@ private struct LogEntryRow: View { } private var timeColor: Color { - workday.thresholdLevel.accent + workday.thresholdLevel.accent.color } } diff --git a/OpenWorktimeTracker/Views/LogEntryEditView.swift b/OpenWorktimeTracker/Views/LogEntryEditView.swift index c865489..be2f4f5 100644 --- a/OpenWorktimeTracker/Views/LogEntryEditView.swift +++ b/OpenWorktimeTracker/Views/LogEntryEditView.swift @@ -102,19 +102,11 @@ struct LogEntryEditView: View { } private var statusLabel: String { - switch entry.status { - case .running: return String(localized: "state.running") - case .paused: return String(localized: "state.paused") - case .ended: return String(localized: "state.ended") - } + WorkdayState(entry.status).localizedLabel } private var statusColor: Color { - switch entry.status { - case .running: return DesignTokens.Colors.accentGreen - case .paused: return DesignTokens.Colors.accentOrange - case .ended: return DesignTokens.Colors.accentBlue - } + WorkdayState(entry.status).accent.color } // MARK: - Time diff --git a/OpenWorktimeTracker/Views/MenuBarView.swift b/OpenWorktimeTracker/Views/MenuBarView.swift index db86511..6aa831c 100644 --- a/OpenWorktimeTracker/Views/MenuBarView.swift +++ b/OpenWorktimeTracker/Views/MenuBarView.swift @@ -207,11 +207,6 @@ struct MenuBarView: View { // MARK: - Helpers private var stateColor: Color { - switch manager.state { - case .notStarted: return DesignTokens.Colors.onSurfaceVariant - case .running: return DesignTokens.Colors.accentGreen - case .paused: return DesignTokens.Colors.accentOrange - case .ended: return DesignTokens.Colors.accentBlue - } + manager.state.accent.color } } diff --git a/OpenWorktimeTracker/Views/TimerDisplayView.swift b/OpenWorktimeTracker/Views/TimerDisplayView.swift index ebd4886..76313b5 100644 --- a/OpenWorktimeTracker/Views/TimerDisplayView.swift +++ b/OpenWorktimeTracker/Views/TimerDisplayView.swift @@ -76,6 +76,6 @@ struct TimerDisplayView: View { } private var thresholdColor: Color { - manager.thresholdLevel.accent + manager.thresholdLevel.accent.color } } diff --git a/OpenWorktimeTracker/Views/WeekHistoryView.swift b/OpenWorktimeTracker/Views/WeekHistoryView.swift index 5ab4896..cbaaa4b 100644 --- a/OpenWorktimeTracker/Views/WeekHistoryView.swift +++ b/OpenWorktimeTracker/Views/WeekHistoryView.swift @@ -107,7 +107,7 @@ private struct DayRow: View { } private var barColor: Color { - workday.thresholdLevel.accent + workday.thresholdLevel.accent.color } private var weekdayAbbr: String { diff --git a/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift b/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift index 4bec10a..3daa7dd 100644 --- a/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift +++ b/OpenWorktimeTrackerTests/WidgetSnapshotTests.swift @@ -23,11 +23,14 @@ final class WidgetSnapshotTests: XCTestCase { } } - private func snapshot(state: String = "running", netTime: TimeInterval = 3600) -> WidgetSnapshot { + private func snapshot( + state: WorkdayState = .running, netTime: TimeInterval = 3600, + thresholdLadder: ThresholdLadder = ThresholdLadder(elevatedHours: 7.5, criticalHours: 9) + ) -> WidgetSnapshot { WidgetSnapshot( measuredAt: measuredAt, state: state, netTime: netTime, grossTime: 4200, startTime: measuredAt.addingTimeInterval(-4200), workDate: "2027-01-15", - targetHours: 7, orangeThreshold: 7.5, redThreshold: 9) + targetHours: 7, thresholdLadder: thresholdLadder) } func testDefaultsRoundTripReplacesOneCompleteValue() throws { @@ -38,9 +41,9 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertEqual(try reader.readSnapshot(), first) let second = WidgetSnapshot( - measuredAt: measuredAt.addingTimeInterval(900), state: "ended", netTime: 4500, + measuredAt: measuredAt.addingTimeInterval(900), state: .ended, netTime: 4500, grossTime: 5100, startTime: first.startTime, workDate: first.workDate, - targetHours: 8, orangeThreshold: 8, redThreshold: 9.5) + targetHours: 8, thresholdLadder: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) try writer.publish(second) XCTAssertEqual(try reader.readSnapshot(), second) @@ -58,8 +61,8 @@ final class WidgetSnapshotTests: XCTestCase { try writer.publish(snapshot()) XCTAssertEqual(try reader.readSnapshot(), snapshot()) - try writer.publish(snapshot(state: "paused")) - XCTAssertEqual(try reader.readSnapshot(), snapshot(state: "paused")) + try writer.publish(snapshot(state: .paused)) + XCTAssertEqual(try reader.readSnapshot(), snapshot(state: .paused)) } func testLegacyKeysAreNotCombinedIntoAnUnmeasuredSnapshot() throws { @@ -68,8 +71,8 @@ final class WidgetSnapshotTests: XCTestCase { let store = SharedDefaults(defaults: defaults, fallbackURL: fileURL) XCTAssertNil(try store.readSnapshot()) - try store.publish(snapshot(state: "ended")) - XCTAssertEqual(try store.readSnapshot(), snapshot(state: "ended")) + try store.publish(snapshot(state: .ended)) + XCTAssertEqual(try store.readSnapshot(), snapshot(state: .ended)) } func testCorruptDefaultsReportsFailureRatherThanReadingLegacyFields() { @@ -104,7 +107,13 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertThrowsError(try store.publish(snapshot(netTime: value))) XCTAssertEqual(try store.readSnapshot(), snapshot()) } - XCTAssertThrowsError(try store.publish(snapshot(state: "unknown"))) + for ladder in [ + ThresholdLadder(elevatedHours: .nan, criticalHours: 9), + ThresholdLadder(elevatedHours: 8, criticalHours: -1) + ] { + XCTAssertThrowsError(try store.publish(snapshot(thresholdLadder: ladder))) + XCTAssertEqual(try store.readSnapshot(), snapshot()) + } } func testInvalidDecodedSnapshotIsRejected() throws { @@ -114,6 +123,46 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertThrowsError(try SharedDefaults(defaults: defaults).readSnapshot()) } + func testUnknownDecodedStateIsRejected() throws { + var object = try XCTUnwrap( + JSONSerialization.jsonObject(with: JSONEncoder().encode(snapshot())) as? [String: Any]) + object["state"] = "unknown" + defaults.set(try JSONSerialization.data(withJSONObject: object), forKey: SharedDefaults.snapshotKey) + + XCTAssertThrowsError(try SharedDefaults(defaults: defaults).readSnapshot()) + } + + /// The layout published by v0.7.0 and earlier, with colour-named thresholds. + private struct PreviousVersionSnapshot: Encodable { + let measuredAt: Date + let state: String + let netTime: TimeInterval + let grossTime: TimeInterval + let startTime: Date? + let workDate: String + let targetHours: Double + let orangeThreshold: Double + let redThreshold: Double + } + + /// Chosen behaviour: a snapshot left under the previous version's key is + /// ignored rather than migrated, without reporting a read failure. The widget + /// shows its "no current data" state until the updated app publishes, which + /// it does on launch and every tick, and publishing removes the stale value. + func testPreviousVersionSnapshotIsUnavailableUntilNextPublish() throws { + let previous = PreviousVersionSnapshot( + measuredAt: measuredAt, state: "running", netTime: 3600, grossTime: 4200, + startTime: measuredAt.addingTimeInterval(-4200), workDate: "2027-01-15", + targetHours: 7, orangeThreshold: 7.5, redThreshold: 9) + defaults.set(try JSONEncoder().encode(previous), forKey: "widget_snapshot_v1") + let store = SharedDefaults(defaults: defaults, fallbackURL: fileURL) + + XCTAssertNil(try store.readSnapshot()) + try store.publish(snapshot()) + XCTAssertEqual(try store.readSnapshot(), snapshot()) + XCTAssertNil(defaults.object(forKey: "widget_snapshot_v1")) + } + func testDelayedReadKeepsRunningTimerAnchoredToMeasurement() { let value = snapshot() let readAt = measuredAt.addingTimeInterval(900) @@ -125,7 +174,7 @@ final class WidgetSnapshotTests: XCTestCase { } func testPausedEndedAndNotStartedSnapshotsDoNotAccumulateTime() { - for state in ["paused", "ended", "notStarted"] { + for state in [WorkdayState.paused, .ended, .notStarted] { let value = snapshot(state: state) XCTAssertFalse(value.isRunning) XCTAssertEqual(value.netTime(at: measuredAt.addingTimeInterval(86_400)), value.netTime) @@ -143,7 +192,7 @@ final class WidgetSnapshotTests: XCTestCase { XCTAssertEqual(value.thresholdLevel(at: measuredAt.addingTimeInterval(1800)), .elevated) XCTAssertEqual(value.thresholdLevel(at: measuredAt.addingTimeInterval(7200)), .critical) XCTAssertEqual( - snapshot(state: "paused", netTime: 7 * 3600) + snapshot(state: .paused, netTime: 7 * 3600) .thresholdLevel(at: measuredAt.addingTimeInterval(7200)), .normal) } } diff --git a/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift new file mode 100644 index 0000000..402a9bd --- /dev/null +++ b/OpenWorktimeTrackerTests/WorkdayAppearanceTests.swift @@ -0,0 +1,123 @@ +import AppKit +import XCTest + +@testable import OpenWorktimeTracker + +final class WorkdayAppearanceTests: XCTestCase { + + /// Pins the app's look: these values are what both targets paint with. + func testPaletteKeepsTheAppsLightAndDarkValues() throws { + let expected: [PaletteColor: (light: UInt, dark: UInt)] = [ + .blue: (0x0058BC, 0x007AFF), + .green: (0x006B27, 0x34C759), + .orange: (0x995000, 0xFF9500), + .red: (0xBA1A1A, 0xFF453A), + .secondary: (0x414755, 0xC1C6D7) + ] + XCTAssertEqual(Set(expected.keys), Set(PaletteColor.allCases)) + for (paletteColor, values) in expected { + for (name, hex) in [(NSAppearance.Name.aqua, values.light), (.darkAqua, values.dark)] { + var resolved: NSColor? + try XCTUnwrap(NSAppearance(named: name)).performAsCurrentDrawingAppearance { + resolved = NSColor(paletteColor.color).usingColorSpace(.sRGB) + } + let color = try XCTUnwrap(resolved) + let actual = UInt((color.redComponent * 255).rounded()) << 16 + | UInt((color.greenComponent * 255).rounded()) << 8 + | UInt((color.blueComponent * 255).rounded()) + XCTAssertEqual( + String(actual, radix: 16), String(hex, radix: 16), "\(paletteColor) \(name.rawValue)") + } + } + } + + func testIndicatorFollowsStateAtNormalLevelAndThresholdLevelAbove() { + let expected: [WorkdayState: PaletteColor] = [ + .notStarted: .secondary, .running: .green, .paused: .orange, .ended: .blue + ] + for state in WorkdayState.allCases { + XCTAssertEqual( + WorkdayAppearance(state: state, level: .normal).indicator, expected[state], "\(state)") + XCTAssertEqual(WorkdayAppearance(state: state, level: .elevated).indicator, .orange, "\(state)") + XCTAssertEqual(WorkdayAppearance(state: state, level: .critical).indicator, .red, "\(state)") + } + } + + func testProgressIsGreenUntilTheThresholdLadderIsReached() { + let expected: [ThresholdLevel: PaletteColor] = [.normal: .green, .elevated: .orange, .critical: .red] + for state in WorkdayState.allCases { + for (level, color) in expected { + XCTAssertEqual(WorkdayAppearance(state: state, level: level).progress, color, "\(state) \(level)") + } + } + } + + func testLabelsFollowTheStateInEachTarget() { + let expected: [WorkdayState: (app: String, widget: String)] = [ + .notStarted: ("state.notStarted", "widget.state.idle"), + .running: ("state.running", "widget.state.running"), + .paused: ("state.paused", "widget.state.paused"), + .ended: ("state.ended", "widget.state.ended") + ] + for state in WorkdayState.allCases { + XCTAssertEqual(state.labelKey, expected[state]?.app) + XCTAssertEqual(state.widgetLabelKey, expected[state]?.widget) + } + } + + func testThresholdLevelAccentIsBlueUntilTheThresholdLadderIsReached() { + XCTAssertEqual(ThresholdLevel.normal.accent, .blue) + XCTAssertEqual(ThresholdLevel.elevated.accent, .orange) + XCTAssertEqual(ThresholdLevel.critical.accent, .red) + } + + func testMenuBarIsOnlyPaintedAboveTheNormalLevel() { + XCTAssertNil(ThresholdLevel.normal.menuBarAccent) + XCTAssertEqual(ThresholdLevel.elevated.menuBarAccent, .orange) + XCTAssertEqual(ThresholdLevel.critical.menuBarAccent, .red) + } + + func testDailyLogStatusMapsToTheSameTrackingState() { + XCTAssertEqual(WorkdayState(TimeEntry.Status.running), .running) + XCTAssertEqual(WorkdayState(TimeEntry.Status.paused), .paused) + XCTAssertEqual(WorkdayState(TimeEntry.Status.ended), .ended) + } + + func testAppLabelsResolveInTheAppBundle() { + for state in WorkdayState.allCases { + XCTAssertNotEqual(state.localizedLabel, state.labelKey, "\(state)") + } + } + + func testWidgetLabelsExistInEveryWidgetLocalization() throws { + let widgetDirectory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("WorktimeWidget", isDirectory: true) + for language in ["de", "en"] { + let url = widgetDirectory.appendingPathComponent("\(language).lproj/Localizable.strings") + let table = try XCTUnwrap(NSDictionary(contentsOf: url) as? [String: String], language) + for state in WorkdayState.allCases { + XCTAssertNotNil(table[state.widgetLabelKey], "\(language) \(state)") + } + } + } + + func testDurationUsesTwoDigitHours() { + XCTAssertEqual(TimeInterval(8 * 3600 + 5 * 60).hoursMinutesFormatted, "08:05") + XCTAssertEqual(TimeInterval(10 * 3600 + 30 * 60 + 59).hoursMinutesFormatted, "10:30") + XCTAssertEqual(TimeInterval(0).hoursMinutesFormatted, "00:00") + } + + func testSnapshotAppearanceUsesThresholdLevelAtReadTime() { + let measuredAt = Date(timeIntervalSince1970: 1_800_000_000) + let snapshot = WidgetSnapshot( + measuredAt: measuredAt, state: .running, netTime: 7.5 * 3600, grossTime: 8 * 3600, + startTime: nil, workDate: "2027-01-15", targetHours: 8, + thresholdLadder: ThresholdLadder(elevatedHours: 8, criticalHours: 9.5)) + + XCTAssertEqual(snapshot.appearance(at: measuredAt), WorkdayAppearance(state: .running, level: .normal)) + XCTAssertEqual( + snapshot.appearance(at: measuredAt.addingTimeInterval(1800)), + WorkdayAppearance(state: .running, level: .elevated)) + } +} diff --git a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift index 92553ef..ab59cf9 100644 --- a/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift +++ b/OpenWorktimeTrackerTests/WorkdayManagerClockAndStoreTests.swift @@ -50,7 +50,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { manager.startNewDay() let start = clock.now var snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "running") + XCTAssertEqual(snapshot.state, .running) XCTAssertEqual(snapshot.measuredAt, start) XCTAssertEqual(snapshot.startTime, start) XCTAssertEqual(snapshot.workDate, manager.currentEntry?.date) @@ -59,7 +59,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { clock.now = start.addingTimeInterval(3600) manager.pause() snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "paused") + XCTAssertEqual(snapshot.state, .paused) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertEqual(snapshot.netTime, 3600) XCTAssertEqual(snapshot.netTime(at: clock.now.addingTimeInterval(600)), 3600) @@ -67,7 +67,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { clock.now = clock.now.addingTimeInterval(600) manager.resume() snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "running") + XCTAssertEqual(snapshot.state, .running) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertEqual(snapshot.netTime, 3600) XCTAssertEqual(snapshot.grossTime, 4200) @@ -76,7 +76,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { clock.now = clock.now.addingTimeInterval(300) manager.endDay() snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "ended") + XCTAssertEqual(snapshot.state, .ended) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertEqual(snapshot.netTime, 3900) clock.now = clock.now.addingTimeInterval(900) @@ -93,8 +93,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) XCTAssertEqual(snapshot.targetHours, 7) - XCTAssertEqual(snapshot.orangeThreshold, 7.5) - XCTAssertEqual(snapshot.redThreshold, 8.5) + XCTAssertEqual(snapshot.thresholdLadder, ThresholdLadder(elevatedHours: 7.5, criticalHours: 8.5)) } func testDeletingCurrentLogPublishesEmptySnapshotWithoutStaleStartTime() throws { @@ -102,7 +101,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { XCTAssertTrue(manager.deleteLog(manager.currentEntry!)) let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "notStarted") + XCTAssertEqual(snapshot.state, .notStarted) XCTAssertEqual(snapshot.measuredAt, clock.now) XCTAssertNil(snapshot.startTime) XCTAssertEqual(snapshot.workDate, "") @@ -119,7 +118,7 @@ final class WorkdayManagerClockAndStoreTests: XCTestCase { manager.handleIdleDecisionAndEndDay() let snapshot = try XCTUnwrap(widgetStore.readSnapshot()) - XCTAssertEqual(snapshot.state, "ended") + XCTAssertEqual(snapshot.state, .ended) XCTAssertEqual(snapshot.measuredAt, end) XCTAssertEqual(snapshot.netTime, 3600) XCTAssertEqual(snapshot.netTime(at: clock.now), 3600) diff --git a/Shared/SharedDefaults.swift b/Shared/SharedDefaults.swift index 76aeefe..8831820 100644 --- a/Shared/SharedDefaults.swift +++ b/Shared/SharedDefaults.swift @@ -2,16 +2,15 @@ import Foundation struct WidgetSnapshot: Codable, Equatable { let measuredAt: Date - let state: String + let state: WorkdayState let netTime: TimeInterval let grossTime: TimeInterval let startTime: Date? let workDate: String let targetHours: Double - let orangeThreshold: Double - let redThreshold: Double + let thresholdLadder: ThresholdLadder - var isRunning: Bool { state == "running" } + var isRunning: Bool { state == .running } /// Anchored to the measurement, never to the widget's later read time. var liveNetStart: Date { measuredAt.addingTimeInterval(-netTime) } @@ -21,18 +20,21 @@ struct WidgetSnapshot: Codable, Equatable { } func thresholdLevel(at date: Date) -> ThresholdLevel { - ThresholdLadder(elevatedHours: orangeThreshold, criticalHours: redThreshold) - .level(for: netTime(at: date)) + thresholdLadder.level(for: netTime(at: date)) } + func appearance(at date: Date) -> WorkdayAppearance { + WorkdayAppearance(state: state, level: thresholdLevel(at: date)) + } + + /// The state is checked by decoding. fileprivate func validate() throws { - guard ["notStarted", "running", "paused", "ended"].contains(state), - measuredAt.timeIntervalSince1970.isFinite, + guard measuredAt.timeIntervalSince1970.isFinite, startTime?.timeIntervalSince1970.isFinite ?? true, netTime.isFinite, netTime >= 0, grossTime.isFinite, grossTime >= 0, targetHours.isFinite, targetHours > 0, - orangeThreshold.isFinite, orangeThreshold >= 0, - redThreshold.isFinite, redThreshold >= 0 else { + thresholdLadder.elevatedHours.isFinite, thresholdLadder.elevatedHours >= 0, + thresholdLadder.criticalHours.isFinite, thresholdLadder.criticalHours >= 0 else { throw CocoaError(.coderInvalidValue) } } @@ -41,7 +43,9 @@ struct WidgetSnapshot: Codable, Equatable { struct SharedDefaults { static let appGroupIdentifier = "group.com.openworktimetracker" static let shared = SharedDefaults() - static let snapshotKey = "widget_snapshot_v1" + /// Versioned with the stored format; a value under an earlier key is ignored. + static let snapshotKey = "widget_snapshot_v2" + private static let previousSnapshotKey = "widget_snapshot_v1" // AppSettingsKey/AppDefaults reference these shared setting definitions. static let normalHoursSettingKey = "normalNotificationHours" @@ -76,6 +80,7 @@ struct SharedDefaults { if let defaults { // One value replaces the entire measurement, including its settings. defaults.set(data, forKey: Self.snapshotKey) + defaults.removeObject(forKey: Self.previousSnapshotKey) } else { try FileManager.default.createDirectory( at: fallbackURL.deletingLastPathComponent(), withIntermediateDirectories: true) diff --git a/Shared/ThresholdLevel.swift b/Shared/ThresholdLevel.swift index ca5a04c..daf3090 100644 --- a/Shared/ThresholdLevel.swift +++ b/Shared/ThresholdLevel.swift @@ -14,7 +14,7 @@ enum ThresholdLevel: Equatable { /// /// Distinct from the notification Thresholds, which fire at their own points and /// are deliberately allowed to differ. -struct ThresholdLadder { +struct ThresholdLadder: Codable, Equatable { let elevatedHours: Double let criticalHours: Double diff --git a/Shared/TimeInterval+Duration.swift b/Shared/TimeInterval+Duration.swift new file mode 100644 index 0000000..a7f1d81 --- /dev/null +++ b/Shared/TimeInterval+Duration.swift @@ -0,0 +1,11 @@ +import Foundation + +extension TimeInterval { + /// Hours always take two digits: `08:05`. + var hoursMinutesFormatted: String { + let totalMinutes = Int(self) / 60 + let hours = totalMinutes / 60 + let minutes = totalMinutes % 60 + return String(format: "%02d:%02d", hours, minutes) + } +} diff --git a/Shared/WorkdayAppearance.swift b/Shared/WorkdayAppearance.swift new file mode 100644 index 0000000..1dc91a1 --- /dev/null +++ b/Shared/WorkdayAppearance.swift @@ -0,0 +1,105 @@ +import SwiftUI + +// MARK: - Palette + +/// The accent colours the app and the widget paint a Workday with, plus the +/// muted `secondary` for text and a Workday not yet started. The one place their +/// light and dark values live. +enum PaletteColor: CaseIterable { + case blue + case green + case orange + case red + case secondary + + var color: Color { + switch self { + case .blue: return Values.blue + case .green: return Values.green + case .orange: return Values.orange + case .red: return Values.red + case .secondary: return Values.secondary + } + } + + private enum Values { + static let blue = Color(light: .init(hex: 0x0058BC), dark: .init(hex: 0x007AFF)) + static let green = Color(light: .init(hex: 0x006B27), dark: .init(hex: 0x34C759)) + static let orange = Color(light: .init(hex: 0x995000), dark: .init(hex: 0xFF9500)) + static let red = Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) + static let secondary = Color(light: .init(hex: 0x414755), dark: .init(hex: 0xC1C6D7)) + } +} + +// MARK: - Tracking State + +extension WorkdayState { + /// The colour of the tracking state on its own, before any Threshold Level. + var accent: PaletteColor { + switch self { + case .notStarted: return .secondary + case .running: return .green + case .paused: return .orange + case .ended: return .blue + } + } + + /// The app's label key. + var labelKey: String { + "state.\(rawValue)" + } + + /// Resolves in the app's bundle. + var localizedLabel: String { + String(localized: String.LocalizationValue(labelKey)) + } + + /// The widget keeps its own, shorter wording under its own keys. + var widgetLabelKey: String { + switch self { + case .notStarted: return "widget.state.idle" + case .running, .paused, .ended: return "widget.state.\(rawValue)" + } + } + + /// Resolves in the widget extension's bundle. + var widgetLabel: String { + String(localized: String.LocalizationValue(widgetLabelKey)) + } +} + +// MARK: - Threshold Level + +extension ThresholdLevel { + /// The accent a Workday's Net Work Time is painted with at this level. + var accent: PaletteColor { + switch self { + case .normal: return .blue + case .elevated: return .orange + case .critical: return .red + } + } + + /// `nil` leaves the menu bar to its native, wallpaper-aware rendering. + var menuBarAccent: PaletteColor? { + self == .normal ? nil : accent + } +} + +// MARK: - Workday Appearance + +/// How a Workday looks for a tracking state at a Threshold Level. +struct WorkdayAppearance: Equatable { + let state: WorkdayState + let level: ThresholdLevel + + /// Elevated and critical Threshold Levels take over from the tracking state. + var indicator: PaletteColor { + level == .normal ? state.accent : level.accent + } + + /// Progress toward the daily goal stays green until the Threshold Ladder is reached. + var progress: PaletteColor { + level == .normal ? .green : indicator + } +} diff --git a/Shared/WorkdayState.swift b/Shared/WorkdayState.swift new file mode 100644 index 0000000..f5e5f37 --- /dev/null +++ b/Shared/WorkdayState.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Where the tracked Workday stands, shared with the widget through its snapshot. +/// +/// Raw values are part of the published snapshot and must not change. +enum WorkdayState: String, Codable, CaseIterable { + case notStarted + case running + case paused + case ended +} diff --git a/WorktimeWidget/WorktimeWidget.swift b/WorktimeWidget/WorktimeWidget.swift index d2a23fc..4e80ddb 100644 --- a/WorktimeWidget/WorktimeWidget.swift +++ b/WorktimeWidget/WorktimeWidget.swift @@ -18,14 +18,13 @@ struct WorktimeProvider: TimelineProvider { let now = Date() return WorktimeEntry(date: now, snapshot: WidgetSnapshot( measuredAt: now, - state: "running", + state: .running, netTime: 5 * 3600 + 23 * 60, grossTime: 6 * 3600, startTime: Calendar.current.date(bySettingHour: 8, minute: 30, second: 0, of: now), workDate: "2024-01-15", targetHours: 8.0, - orangeThreshold: 8.0, - redThreshold: 9.5 + thresholdLadder: ThresholdLadder(elevatedHours: 8.0, criticalHours: 9.5) )) } @@ -69,9 +68,9 @@ struct WorktimeWidgetSmallView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Circle() - .fill(stateColor) + .fill(appearance.indicator.color) .frame(width: 6, height: 6) - Text(stateLabel) + Text(appearance.state.widgetLabel) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -83,7 +82,7 @@ struct WorktimeWidgetSmallView: View { if snapshot.isRunning { Text(timerInterval: snapshot.liveNetStart...Date.distantFuture, countsDown: false) } else { - Text(formatTime(snapshot.netTime)) + Text(snapshot.netTime.hoursMinutesFormatted) } } .font(.system(size: 28, weight: .medium, design: .rounded)) @@ -104,28 +103,7 @@ struct WorktimeWidgetSmallView: View { .containerBackground(.fill.tertiary, for: .widget) } - private var stateColor: Color { - switch snapshot.thresholdLevel(at: date) { - case .critical: return Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) - case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .normal: - switch snapshot.state { - case "running": return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - case "paused": return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case "ended": return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) - default: return .secondary - } - } - } - - private var stateLabel: String { - switch snapshot.state { - case "running": return String(localized: "widget.state.running") - case "paused": return String(localized: "widget.state.paused") - case "ended": return String(localized: "widget.state.ended") - default: return String(localized: "widget.state.idle") - } - } + private var appearance: WorkdayAppearance { snapshot.appearance(at: date) } } // MARK: - Medium Widget View @@ -141,9 +119,9 @@ struct WorktimeWidgetMediumView: View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 4) { Circle() - .fill(stateColor) + .fill(appearance.indicator.color) .frame(width: 6, height: 6) - Text(stateLabel) + Text(appearance.state.widgetLabel) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(.secondary) .textCase(.uppercase) @@ -157,7 +135,7 @@ struct WorktimeWidgetMediumView: View { timerInterval: snapshot.liveNetStart...Date.distantFuture, countsDown: false) } else { - Text(formatTime(snapshot.netTime)) + Text(snapshot.netTime.hoursMinutesFormatted) } } .font(.system(size: 32, weight: .medium, design: .rounded)) @@ -185,7 +163,7 @@ struct WorktimeWidgetMediumView: View { .trim( from: 0, to: min(1.0, netTimeSeconds / (snapshot.targetHours * 3600)) ) - .stroke(progressColor, style: StrokeStyle(lineWidth: 6, lineCap: .round)) + .stroke(appearance.progress.color, style: StrokeStyle(lineWidth: 6, lineCap: .round)) .rotationEffect(.degrees(-90)) Text( String( @@ -200,7 +178,7 @@ struct WorktimeWidgetMediumView: View { Text( String( format: String(localized: "widget.target"), - formatTime(snapshot.targetHours * 3600)) + (snapshot.targetHours * 3600).hoursMinutesFormatted) ) .font(.system(size: 9)) .foregroundStyle(.secondary) @@ -210,46 +188,11 @@ struct WorktimeWidgetMediumView: View { .containerBackground(.fill.tertiary, for: .widget) } - private var stateColor: Color { - switch snapshot.thresholdLevel(at: date) { - case .critical: return Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) - case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .normal: - switch snapshot.state { - case "running": return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - case "paused": return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case "ended": return Color(light: .init(hex: 0x0055D4), dark: .init(hex: 0x0A84FF)) - default: return .secondary - } - } - } - - private var stateLabel: String { - switch snapshot.state { - case "running": return String(localized: "widget.state.running") - case "paused": return String(localized: "widget.state.paused") - case "ended": return String(localized: "widget.state.ended") - default: return String(localized: "widget.state.idle") - } - } - - private var progressColor: Color { - switch snapshot.thresholdLevel(at: date) { - case .critical: return Color(light: .init(hex: 0xBA1A1A), dark: .init(hex: 0xFF453A)) - case .elevated: return Color(light: .init(hex: 0xE67700), dark: .init(hex: 0xFF9500)) - case .normal: return Color(light: .init(hex: 0x1B7A2B), dark: .init(hex: 0x30D158)) - } - } + private var appearance: WorkdayAppearance { snapshot.appearance(at: date) } } // MARK: - Helpers -private func formatTime(_ seconds: TimeInterval) -> String { - let h = Int(seconds) / 3600 - let m = (Int(seconds) % 3600) / 60 - return String(format: "%d:%02d", h, m) -} - private let hourMinuteFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "HH:mm"