Skip to content
Merged
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
11 changes: 7 additions & 4 deletions App/MenuBarController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
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?
Expand All @@ -20,11 +21,13 @@
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
}
Expand Down Expand Up @@ -63,21 +66,20 @@

// 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() }

Check warning on line 75 in App/MenuBarController.swift

View workflow job for this annotation

GitHub Actions / Build & Tests (macOS)

reference to captured var 'self' in concurrently-executing code; this is an error in the Swift 6 language mode
}
updateLabel(summary)
}

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)"
}

Expand All @@ -98,6 +100,7 @@
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)
Expand Down
4 changes: 2 additions & 2 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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 = {
Expand Down
39 changes: 38 additions & 1 deletion Presentation/MenuBar/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,40 @@ import SwiftUI
struct MenuBarPopoverView: View {
let calendar: StudyCalendarViewModel
let today: TodayViewModel
let studyTimer: StudyTimerViewModel
let onOpenLecture: (UUID) -> Void
let onReviewCards: () -> Void
let onOpenApp: () -> Void
@AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard

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.
struct MenuBarContent: View {
let summary: MenuBarSummary
let onOpenLecture: (UUID) -> Void
let onReviewCards: () -> Void
let onToggleTimer: () -> Void
let onOpenApp: () -> Void

var body: some View {
Expand All @@ -48,6 +60,8 @@ struct MenuBarContent: View {
.buttonStyle(.plain)
.disabled(summary.dueCards == 0)
Divider()
timerSection
Divider()
HStack {
Button("StudyApp öffnen", action: onOpenApp)
Spacer()
Expand All @@ -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")
Expand Down
36 changes: 32 additions & 4 deletions Presentation/MenuBar/MenuBarSummary.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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)" }
Expand Down
29 changes: 29 additions & 0 deletions StudyAppTests/MenuBarTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Loading