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
47 changes: 47 additions & 0 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Bool> {
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()
Expand Down
73 changes: 73 additions & 0 deletions Presentation/MenuBar/MenuBarContent.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
61 changes: 61 additions & 0 deletions Presentation/MenuBar/MenuBarSummary.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
2 changes: 2 additions & 0 deletions Presentation/Settings/GeneralSettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion Presentation/Settings/SettingsPane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Presentation/Settings/ViewPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 ⌘+ / ⌘−.
Expand Down
29 changes: 29 additions & 0 deletions StudyAppTests/MenuBarTests.swift
Original file line number Diff line number Diff line change
@@ -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 "))
}
}
6 changes: 6 additions & 0 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading