diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index d894132..b50f29a 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -18,6 +18,7 @@ struct StudyAppApp: App { @State private var examsViewModel: ExamsViewModel @State private var updater = AppUpdater() @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard + @AppStorage(ViewPreferences.showsMenuBarItemKey) private var showsMenuBarItem = true init() { do { @@ -126,6 +127,29 @@ struct StudyAppApp: App { BackupCommands(viewModel: backupViewModel) } + MenuBarExtra(isInserted: menuBarItemInserted) { + MenuBarContent( + summary: menuBarSummary, + onOpenLecture: { moduleID in + Self.bringToFront() + navigation.show(module: moduleID, section: .slides, intent: .resumeSlides) + }, + onReviewCards: { + Self.bringToFront() + // Starts with the module that has the most due cards, like „Lernen starten“ on Today. + if let moduleID = todayViewModel.dueCounts.max(by: { $0.value < $1.value })?.key { + navigation.show(module: moduleID, section: .flashcards) + } + }, + onOpenApp: { Self.bringToFront() } + ) + .tint(Theme.accent) + .preferredColorScheme(appearance.colorScheme) + } label: { + MenuBarLabel(summary: menuBarSummary) + } + .menuBarExtraStyle(.window) + Settings { SettingsView( settings: container.preferences, @@ -160,6 +184,29 @@ struct StudyAppApp: App { await notificationsViewModel.refresh() } + /// Keeps the menu bar item out of the unit test host: on CI runners its status item + /// blocks the launch, so the test runner never connects. + private var menuBarItemInserted: Binding { + Binding( + get: { showsMenuBarItem && !Self.isRunningAsTestHost }, + set: { showsMenuBarItem = $0 } + ) + } + + private static var isRunningAsTestHost: Bool { + ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil + } + + private var menuBarSummary: MenuBarSummary { + MenuBarSummary(calendar: calendarViewModel, dueCards: todayViewModel.totalDue) + } + + /// Shows the main window, e.g. after a click in the menu bar. + private static func bringToFront() { + NSApplication.shared.activate() + NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil) + } + /// Opens what a clicked notification refers to. private static func open(_ target: NotificationTarget, navigation: AppNavigation) { NSApplication.shared.activate() diff --git a/Presentation/MenuBar/MenuBarContent.swift b/Presentation/MenuBar/MenuBarContent.swift new file mode 100644 index 0000000..9499508 --- /dev/null +++ b/Presentation/MenuBar/MenuBarContent.swift @@ -0,0 +1,73 @@ +import AppKit +import SwiftUI + +/// The menu bar item's label: an icon, plus the remaining minutes while a lecture runs. +struct MenuBarLabel: View { + let summary: MenuBarSummary + + var body: some View { + if summary.title.isEmpty { + Image(systemName: "graduationcap") + } else { + Label(summary.title, systemImage: "record.circle") + .labelStyle(.titleAndIcon) + } + } +} + +/// The window that opens from the menu bar item. +struct MenuBarContent: View { + let summary: MenuBarSummary + let onOpenLecture: (UUID) -> Void + let onReviewCards: () -> Void + let onOpenApp: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let lecture = summary.lecture { + lectureSection(lecture) + } else { + Label("Heute keine Vorlesung mehr", systemImage: "calendar") + .foregroundStyle(Theme.textSecondary) + } + Divider() + Button(action: onReviewCards) { + Label( + summary.dueCards == 0 ? "Keine Karten fällig" : "\(summary.dueCards) Karten wiederholen", + systemImage: "rectangle.stack" + ) + } + .buttonStyle(.plain) + .disabled(summary.dueCards == 0) + Divider() + HStack { + Button("StudyApp öffnen", action: onOpenApp) + Spacer() + Button("Beenden") { NSApplication.shared.terminate(nil) } + } + .buttonStyle(.link) + } + .padding(14) + .frame(width: 280) + } + + private func lectureSection(_ lecture: MenuBarSummary.Lecture) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(lecture.isLive ? "Läuft gerade" : "Als Nächstes") + .font(Theme.Typography.overline) + .textCase(.uppercase) + .foregroundStyle(lecture.isLive ? Theme.live : Theme.textSecondary) + Text(lecture.moduleName) + .font(Theme.Typography.headline) + Text([lecture.timeText, lecture.location].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(Theme.Typography.small) + .foregroundStyle(Theme.textSecondary) + if lecture.isLive { + SlimProgressBar(progress: lecture.progress, tint: Theme.live) + } + Button("Folien öffnen") { onOpenLecture(lecture.moduleID) } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } +} diff --git a/Presentation/MenuBar/MenuBarSummary.swift b/Presentation/MenuBar/MenuBarSummary.swift new file mode 100644 index 0000000..99fb70e --- /dev/null +++ b/Presentation/MenuBar/MenuBarSummary.swift @@ -0,0 +1,61 @@ +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. +struct MenuBarSummary: Equatable { + struct Lecture: Equatable { + let moduleID: UUID + let moduleName: String + let location: String + let isLive: Bool + /// „noch 38 Min.“ or „um 14:15“. + let timeText: String + let progress: Double + } + + let lecture: Lecture? + let dueCards: Int + + /// Short text next to the menu bar icon; empty when nothing is going on. + var title: String { + guard let lecture, lecture.isLive else { return "" } + return lecture.timeText.replacingOccurrences(of: "noch ", with: "") + } + + init(lecture: Lecture?, dueCards: Int) { + self.lecture = lecture + self.dueCards = dueCards + } + + @MainActor + init(calendar: StudyCalendarViewModel, dueCards: Int) { + let now = calendar.referenceDate + let event = calendar.currentLecture ?? calendar.nextLecture + if let event, let module = calendar.module(for: event) { + let isLive = event.startDate <= now + let timeText = isLive + ? "noch \(max(Int(event.endDate.timeIntervalSince(now) / 60), 0)) Min." + : Self.startText(event.startDate, now: now) + lecture = Lecture( + moduleID: module.id, + moduleName: module.name, + location: event.location, + isLive: isLive, + timeText: timeText, + progress: isLive ? calendar.progress(of: event) : 0 + ) + } else { + lecture = nil + } + self.dueCards = dueCards + } + + 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)" } + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(start, inSameDayAs: tomorrow) { + return "morgen \(time)" + } + return start.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + } +} diff --git a/Presentation/Settings/GeneralSettingsPane.swift b/Presentation/Settings/GeneralSettingsPane.swift index fb6fc8b..1048c1d 100644 --- a/Presentation/Settings/GeneralSettingsPane.swift +++ b/Presentation/Settings/GeneralSettingsPane.swift @@ -6,6 +6,7 @@ struct GeneralSettingsPane: View { @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard @AppStorage(ViewPreferences.noteFontSizeKey) private var noteFontSize = NoteFontSize.standard + @AppStorage(ViewPreferences.showsMenuBarItemKey) private var showsMenuBarItem = true @State private var autoOpenLecture = true @State private var leadMinutes = 10 @@ -18,6 +19,7 @@ struct GeneralSettingsPane: View { } } .pickerStyle(.segmented) + Toggle("In der Menüleiste anzeigen (laufende Vorlesung, fällige Karten)", isOn: $showsMenuBarItem) LabeledContent("Schriftgröße der Notizen") { HStack(spacing: 10) { Slider(value: $noteFontSize, in: NoteFontSize.range, step: NoteFontSize.step) diff --git a/Presentation/Settings/SettingsPane.swift b/Presentation/Settings/SettingsPane.swift index 6dbd950..9b70564 100644 --- a/Presentation/Settings/SettingsPane.swift +++ b/Presentation/Settings/SettingsPane.swift @@ -37,7 +37,7 @@ enum SettingsPane: String, CaseIterable, Identifiable { /// content taller than this scrolls, so the window never outgrows a laptop display. var preferredHeight: CGFloat { switch self { - case .general: 340 + case .general: 370 case .schedule: 260 case .studIP: 400 case .notifications: 290 diff --git a/Presentation/Settings/ViewPreferences.swift b/Presentation/Settings/ViewPreferences.swift index dd28820..bb38d19 100644 --- a/Presentation/Settings/ViewPreferences.swift +++ b/Presentation/Settings/ViewPreferences.swift @@ -36,6 +36,8 @@ enum ViewPreferences { static let slideReaderShowsNotesKey = "slideReader.showsNotes" /// Last opened pane of the settings window. static let settingsPaneKey = "settings.selectedPane" + /// Show the StudyApp item in the menu bar. + static let showsMenuBarItemKey = "menuBar.isShown" } /// Schriftgröße für Notizen (Editor und Vorschau), einstellbar über ⌘+ / ⌘−. diff --git a/StudyAppTests/MenuBarTests.swift b/StudyAppTests/MenuBarTests.swift new file mode 100644 index 0000000..752d22e --- /dev/null +++ b/StudyAppTests/MenuBarTests.swift @@ -0,0 +1,29 @@ +import Foundation +import Testing +@testable import StudyApp + +@MainActor +struct MenuBarSummaryTests { + private func lecture(live: Bool, time: String) -> MenuBarSummary.Lecture { + MenuBarSummary.Lecture( + moduleID: UUID(), moduleName: "Analysis I", location: "HS 1", isLive: live, timeText: time, progress: 0.5 + ) + } + + @Test func titleShowsRemainingMinutesOnlyWhileALectureRuns() { + #expect(MenuBarSummary(lecture: lecture(live: true, time: "noch 38 Min."), dueCards: 3).title == "38 Min.") + #expect(MenuBarSummary(lecture: lecture(live: false, time: "um 14:15"), dueCards: 3).title.isEmpty) + #expect(MenuBarSummary(lecture: nil, dueCards: 0).title.isEmpty) + } + + @Test func startTextSaysTodayTomorrowOrWeekday() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 28, hour: 9))) + let later = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 28, hour: 14, minute: 15))) + let tomorrow = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 29, hour: 8))) + + #expect(MenuBarSummary.startText(later, now: now, calendar: calendar).hasPrefix("um ")) + #expect(MenuBarSummary.startText(tomorrow, now: now, calendar: calendar).hasPrefix("morgen ")) + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 264d3a7..615fc3d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -69,6 +69,12 @@ Code: `Presentation/Grades/`, `Domain/UseCases/GradeUseCases.swift`, `Domain/Ent Code: `Presentation/StudIP/`, `Domain/UseCases/StudIPUseCases.swift`, `Data/StudIP/` · Details: [STUDIP.md](STUDIP.md) +## Menu bar + +The menu bar item shows the remaining minutes while a lecture runs. Its window lists the running or next lecture (with „Folien öffnen“) and the due flashcards, and opens the main window. It can be hidden in Settings → Allgemein. + +Code: `Presentation/MenuBar/` + ## Updates and settings - Automatic updates through Sparkle („Nach Updates suchen …“ in the app menu). See [RELEASING.md](RELEASING.md).