From d8e063be3df2dd6b04d4755e9a661a2a9acb94bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:03:59 +0000 Subject: [PATCH 1/2] feat(mac): include the study timer in the menu bar summary A running timer takes the menu bar label with its remaining time and a timer symbol; otherwise a running lecture shows as before. Refs #69 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Presentation/MenuBar/MenuBarSummary.swift | 36 ++++++++++++++++++++--- StudyAppTests/MenuBarTests.swift | 29 ++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/Presentation/MenuBar/MenuBarSummary.swift b/Presentation/MenuBar/MenuBarSummary.swift index 99fb70e..e8915ea 100644 --- a/Presentation/MenuBar/MenuBarSummary.swift +++ b/Presentation/MenuBar/MenuBarSummary.swift @@ -1,8 +1,17 @@ import Foundation /// What the menu bar item shows: the running lecture with remaining time, otherwise the next one, -/// and the due flashcards. Pure, so it is easy to test. +/// the due flashcards and the study timer. Pure, so it is easy to test. struct MenuBarSummary: Equatable { + /// The study timer while it runs or is paused. + struct StudyTimerStatus: Equatable { + /// „Fokus“, „Kurze Pause“ … + let phaseTitle: String + /// „24:59“. + let remainingText: String + let isRunning: Bool + } + struct Lecture: Equatable { let moduleID: UUID let moduleName: String @@ -15,20 +24,30 @@ struct MenuBarSummary: Equatable { let lecture: Lecture? let dueCards: Int + let studyTimer: StudyTimerStatus? - /// Short text next to the menu bar icon; empty when nothing is going on. + /// Short text next to the menu bar icon: the running timer first, then a running lecture; empty otherwise. var title: String { + if let studyTimer, studyTimer.isRunning { return studyTimer.remainingText } guard let lecture, lecture.isLive else { return "" } return lecture.timeText.replacingOccurrences(of: "noch ", with: "") } - init(lecture: Lecture?, dueCards: Int) { + /// SF Symbol of the menu bar item, matching `title`. + var symbolName: String { + if let studyTimer, studyTimer.isRunning { return "timer" } + return lecture?.isLive == true ? "record.circle" : "graduationcap" + } + + init(lecture: Lecture?, dueCards: Int, studyTimer: StudyTimerStatus? = nil) { self.lecture = lecture self.dueCards = dueCards + self.studyTimer = studyTimer } @MainActor - init(calendar: StudyCalendarViewModel, dueCards: Int) { + init(calendar: StudyCalendarViewModel, dueCards: Int, studyTimer: StudyTimerViewModel? = nil) { + self.studyTimer = studyTimer.flatMap { Self.status(of: $0) } let now = calendar.referenceDate let event = calendar.currentLecture ?? calendar.nextLecture if let event, let module = calendar.module(for: event) { @@ -50,6 +69,15 @@ struct MenuBarSummary: Equatable { self.dueCards = dueCards } + /// The timer's status, `nil` while it is idle. + @MainActor + static func status(of timer: StudyTimerViewModel) -> StudyTimerStatus? { + guard timer.isActive else { return nil } + return StudyTimerStatus( + phaseTitle: timer.timer.phase.title, remainingText: timer.remainingText, isRunning: timer.timer.isRunning + ) + } + static func startText(_ start: Date, now: Date, calendar: Calendar = .current) -> String { let time = start.formatted(date: .omitted, time: .shortened) if calendar.isDate(start, inSameDayAs: now) { return "um \(time)" } diff --git a/StudyAppTests/MenuBarTests.swift b/StudyAppTests/MenuBarTests.swift index 752d22e..8477349 100644 --- a/StudyAppTests/MenuBarTests.swift +++ b/StudyAppTests/MenuBarTests.swift @@ -16,6 +16,35 @@ struct MenuBarSummaryTests { #expect(MenuBarSummary(lecture: nil, dueCards: 0).title.isEmpty) } + @Test func runningTimerTakesTheLabelBeforeALecture() { + let running = MenuBarSummary.StudyTimerStatus(phaseTitle: "Fokus", remainingText: "24:59", isRunning: true) + let paused = MenuBarSummary.StudyTimerStatus(phaseTitle: "Fokus", remainingText: "12:00", isRunning: false) + let live = lecture(live: true, time: "noch 38 Min.") + + #expect(MenuBarSummary(lecture: live, dueCards: 0, studyTimer: running).title == "24:59") + #expect(MenuBarSummary(lecture: live, dueCards: 0, studyTimer: running).symbolName == "timer") + #expect(MenuBarSummary(lecture: live, dueCards: 0, studyTimer: paused).title == "38 Min.") + #expect(MenuBarSummary(lecture: live, dueCards: 0, studyTimer: paused).symbolName == "record.circle") + #expect(MenuBarSummary(lecture: nil, dueCards: 0, studyTimer: paused).symbolName == "graduationcap") + } + + @Test func timerStatusIsOnlyShownWhileTheTimerIsActive() async { + let timer = StudyTimerViewModel( + repository: InMemoryStudySessionRepository(), settings: InMemoryStudyPreferences(), clock: { Date(timeIntervalSince1970: 0) } + ) + #expect(MenuBarSummary.status(of: timer) == nil) + + await timer.start() + let status = MenuBarSummary.status(of: timer) + #expect(status?.isRunning == true) + #expect(status?.phaseTitle == "Fokus") + #expect(status?.remainingText == "25:00") + + timer.pause() + #expect(MenuBarSummary.status(of: timer)?.isRunning == false) + timer.reset() + } + @Test func startTextSaysTodayTomorrowOrWeekday() throws { var calendar = Calendar(identifier: .gregorian) calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) From 0e7b2361456452595c74d2c0aa72719c947ebbe8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 15:03:59 +0000 Subject: [PATCH 2/2] feat(mac): control the study timer from the menu bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The menu bar popover shows the timer's phase and remaining time with Pause/Weiter, or „Lerntimer starten“ while it is idle. Closes #69 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/MenuBarController.swift | 11 ++++--- App/StudyAppApp.swift | 4 +-- Presentation/MenuBar/MenuBarContent.swift | 39 ++++++++++++++++++++++- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/App/MenuBarController.swift b/App/MenuBarController.swift index 83e689a..078e8df 100644 --- a/App/MenuBarController.swift +++ b/App/MenuBarController.swift @@ -11,6 +11,7 @@ import SwiftUI final class MenuBarController: NSObject { private let calendar: StudyCalendarViewModel private let today: TodayViewModel + private let studyTimer: StudyTimerViewModel private let navigation: AppNavigation private let defaults: UserDefaults private var statusItem: NSStatusItem? @@ -20,11 +21,13 @@ final class MenuBarController: NSObject { init( calendar: StudyCalendarViewModel, today: TodayViewModel, + studyTimer: StudyTimerViewModel, navigation: AppNavigation, defaults: UserDefaults = .standard ) { self.calendar = calendar self.today = today + self.studyTimer = studyTimer self.navigation = navigation self.defaults = defaults } @@ -63,11 +66,11 @@ final class MenuBarController: NSObject { // MARK: Label - /// Updates the label now and again whenever the lecture or the due cards change. + /// Updates the label now and again whenever the lecture, the due cards or the study timer change. private func observeSummary() { guard statusItem != nil else { return } let summary = withObservationTracking { - MenuBarSummary(calendar: calendar, dueCards: today.totalDue) + MenuBarSummary(calendar: calendar, dueCards: today.totalDue, studyTimer: studyTimer) } onChange: { [weak self] in Task { @MainActor in self?.observeSummary() } } @@ -76,8 +79,7 @@ final class MenuBarController: NSObject { private func updateLabel(_ summary: MenuBarSummary) { guard let button = statusItem?.button else { return } - let symbol = summary.title.isEmpty ? "graduationcap" : "record.circle" - button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "StudyApp") + button.image = NSImage(systemSymbolName: summary.symbolName, accessibilityDescription: "StudyApp") button.title = summary.title.isEmpty ? "" : " \(summary.title)" } @@ -98,6 +100,7 @@ final class MenuBarController: NSObject { popover.contentViewController = NSHostingController(rootView: MenuBarPopoverView( calendar: calendar, today: today, + studyTimer: studyTimer, onOpenLecture: { [weak self] moduleID in self?.openMainWindow() self?.navigation.show(module: moduleID, section: .slides, intent: .resumeSlides) diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index 5cba309..f058672 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -33,7 +33,8 @@ struct StudyAppApp: App { let assignments = container.makeAssignmentsViewModel() let exams = container.makeExamsViewModel() let navigation = AppNavigation() - menuBar = MenuBarController(calendar: calendar, today: today, navigation: navigation) + let studyTimer = container.makeStudyTimerViewModel() + menuBar = MenuBarController(calendar: calendar, today: today, studyTimer: studyTimer, navigation: navigation) let studIP = container.makeStudIPViewModel() let announcements = container.makeAnnouncementsViewModel() studIP.onAnnouncementsSynced = { announcements.load() } @@ -73,7 +74,6 @@ struct StudyAppApp: App { } responder.install() notificationResponder = responder - let studyTimer = container.makeStudyTimerViewModel() let backup = container.makeBackupViewModel() // After a restore every screen shows the restored data. backup.onRestored = { diff --git a/Presentation/MenuBar/MenuBarContent.swift b/Presentation/MenuBar/MenuBarContent.swift index 6a3982f..005b2bb 100644 --- a/Presentation/MenuBar/MenuBarContent.swift +++ b/Presentation/MenuBar/MenuBarContent.swift @@ -6,6 +6,7 @@ import SwiftUI struct MenuBarPopoverView: View { let calendar: StudyCalendarViewModel let today: TodayViewModel + let studyTimer: StudyTimerViewModel let onOpenLecture: (UUID) -> Void let onReviewCards: () -> Void let onOpenApp: () -> Void @@ -13,14 +14,24 @@ struct MenuBarPopoverView: View { var body: some View { MenuBarContent( - summary: MenuBarSummary(calendar: calendar, dueCards: today.totalDue), + summary: MenuBarSummary(calendar: calendar, dueCards: today.totalDue, studyTimer: studyTimer), onOpenLecture: onOpenLecture, onReviewCards: onReviewCards, + onToggleTimer: toggleTimer, onOpenApp: onOpenApp ) .tint(Theme.accent) .preferredColorScheme(appearance.colorScheme) } + + /// Starts or continues the timer, or pauses it while it runs. + private func toggleTimer() { + if studyTimer.timer.isRunning { + studyTimer.pause() + } else { + Task { await studyTimer.start() } + } + } } /// The window that opens from the menu bar item. @@ -28,6 +39,7 @@ struct MenuBarContent: View { let summary: MenuBarSummary let onOpenLecture: (UUID) -> Void let onReviewCards: () -> Void + let onToggleTimer: () -> Void let onOpenApp: () -> Void var body: some View { @@ -48,6 +60,8 @@ struct MenuBarContent: View { .buttonStyle(.plain) .disabled(summary.dueCards == 0) Divider() + timerSection + Divider() HStack { Button("StudyApp öffnen", action: onOpenApp) Spacer() @@ -59,6 +73,29 @@ struct MenuBarContent: View { .frame(width: 280) } + @ViewBuilder + private var timerSection: some View { + if let timer = summary.studyTimer { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Lerntimer · \(timer.phaseTitle)") + .font(Theme.Typography.small) + .foregroundStyle(Theme.textSecondary) + Text(timer.remainingText) + .font(Theme.Typography.headline.monospacedDigit()) + } + Spacer() + Button(timer.isRunning ? "Pause" : "Weiter", action: onToggleTimer) + .controlSize(.small) + } + } else { + Button(action: onToggleTimer) { + Label("Lerntimer starten", systemImage: "timer") + } + .buttonStyle(.plain) + } + } + private func lectureSection(_ lecture: MenuBarSummary.Lecture) -> some View { VStack(alignment: .leading, spacing: 6) { Text(lecture.isLive ? "Läuft gerade" : "Als Nächstes")