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
124 changes: 124 additions & 0 deletions App/MenuBarController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import AppKit
import Observation
import SwiftUI

/// The StudyApp item in the menu bar, built with AppKit (`NSStatusItem` and a popover).
///
/// SwiftUI's `MenuBarExtra` scene kept the app's scene graph changing at launch, so the main
/// window rebuilt its toolbar endlessly and the app hung. A status item owned by the app avoids
/// the extra scene; the popover still shows the SwiftUI `MenuBarContent`.
@MainActor
final class MenuBarController: NSObject {
private let calendar: StudyCalendarViewModel
private let today: TodayViewModel
private let navigation: AppNavigation
private let defaults: UserDefaults
private var statusItem: NSStatusItem?
private var defaultsObserver: NSObjectProtocol?
private lazy var popover: NSPopover = makePopover()

init(
calendar: StudyCalendarViewModel,
today: TodayViewModel,
navigation: AppNavigation,
defaults: UserDefaults = .standard
) {
self.calendar = calendar
self.today = today
self.navigation = navigation
self.defaults = defaults
}

/// Shows the item when the setting is on and follows later changes of the setting.
func install() {
guard defaultsObserver == nil else { return }
defaultsObserver = NotificationCenter.default.addObserver(
forName: UserDefaults.didChangeNotification, object: defaults, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated { self?.applyVisibility() }
}
applyVisibility()
}

// MARK: Visibility

private var isEnabled: Bool {
defaults.object(forKey: ViewPreferences.showsMenuBarItemKey) as? Bool ?? true
}

private func applyVisibility() {
if isEnabled, statusItem == nil {
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
item.button?.target = self
item.button?.action = #selector(togglePopover(_:))
item.button?.imagePosition = .imageLeading
statusItem = item
observeSummary()
} else if !isEnabled, let item = statusItem {
popover.performClose(nil)
NSStatusBar.system.removeStatusItem(item)
statusItem = nil
}
}

// MARK: Label

/// Updates the label now and again whenever the lecture or the due cards change.
private func observeSummary() {
guard statusItem != nil else { return }
let summary = withObservationTracking {
MenuBarSummary(calendar: calendar, dueCards: today.totalDue)
} onChange: { [weak self] in
Task { @MainActor in self?.observeSummary() }
}
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.title = summary.title.isEmpty ? "" : " \(summary.title)"
}

// MARK: Popover

@objc private func togglePopover(_ sender: NSStatusBarButton) {
if popover.isShown {
popover.performClose(sender)
} else {
popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}

private func makePopover() -> NSPopover {
let popover = NSPopover()
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: MenuBarPopoverView(
calendar: calendar,
today: today,
onOpenLecture: { [weak self] moduleID in
self?.openMainWindow()
self?.navigation.show(module: moduleID, section: .slides, intent: .resumeSlides)
},
onReviewCards: { [weak self] in
guard let self else { return }
openMainWindow()
// Starts with the module that has the most due cards, like „Lernen starten“ on Today.
if let moduleID = today.dueCounts.max(by: { $0.value < $1.value })?.key {
navigation.show(module: moduleID, section: .flashcards)
}
},
onOpenApp: { [weak self] in self?.openMainWindow() }
))
return popover
}

/// Closes the popover and brings the main window to the front.
private func openMainWindow() {
popover.performClose(nil)
NSApplication.shared.activate()
NSApplication.shared.windows.first { $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
}
}
37 changes: 3 additions & 34 deletions App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ struct StudyAppApp: App {
private let autopilot: LectureAutopilot
/// Installed in `init` so a click that launches the app is not lost.
private let notificationResponder: NotificationResponder
private let menuBar: MenuBarController
@State private var modulesViewModel: ModulesViewModel
@State private var calendarViewModel: StudyCalendarViewModel
@State private var todayViewModel: TodayViewModel
Expand All @@ -22,7 +23,6 @@ struct StudyAppApp: App {
@State private var mensaViewModel: MensaViewModel
@State private var updater = AppUpdater()
@AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard
@AppStorage(ViewPreferences.showsMenuBarItemKey) private var showsMenuBarItem = true

init() {
do {
Expand All @@ -33,6 +33,7 @@ struct StudyAppApp: App {
let assignments = container.makeAssignmentsViewModel()
let exams = container.makeExamsViewModel()
let navigation = AppNavigation()
menuBar = MenuBarController(calendar: calendar, today: today, navigation: navigation)
let studIP = container.makeStudIPViewModel()
let announcements = container.makeAnnouncementsViewModel()
studIP.onAnnouncementsSynced = { announcements.load() }
Expand Down Expand Up @@ -155,33 +156,6 @@ struct StudyAppApp: App {
BackupCommands(viewModel: backupViewModel)
}

MenuBarExtra(isInserted: $showsMenuBarItem) {
MenuBarSummaryReader(calendar: calendarViewModel, today: todayViewModel) { summary in
MenuBarContent(
summary: summary,
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: {
MenuBarSummaryReader(calendar: calendarViewModel, today: todayViewModel) { summary in
MenuBarLabel(summary: summary)
}
}
.menuBarExtraStyle(.window)

Settings {
SettingsView(
settings: container.preferences,
Expand Down Expand Up @@ -210,6 +184,7 @@ struct StudyAppApp: App {
if navigation.destination == nil {
navigation.showToday()
}
menuBar.install()
autopilot.run(isLaunch: true)

// Abonnements im Hintergrund auffrischen; eine dabei neu erkannte Vorlesung wird ebenfalls geöffnet.
Expand All @@ -223,12 +198,6 @@ struct StudyAppApp: App {
await notificationsViewModel.refresh()
}

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

/// Keeps Spotlight in line with notes and study sheets; failures only cost search results.
private func reindexSearch() async {
try? await container.makeReindexSearch()()
Expand Down
34 changes: 15 additions & 19 deletions Presentation/MenuBar/MenuBarContent.swift
Original file line number Diff line number Diff line change
@@ -1,29 +1,25 @@
import AppKit
import SwiftUI

/// Builds the summary inside a view, so only the menu bar item observes the calendar and the due cards.
/// Reading them in the App body would rebuild every scene on each change, already while the app launches.
struct MenuBarSummaryReader<Content: View>: View {
/// Content of the menu bar popover: builds the summary inside the view, so it follows the calendar
/// and the due cards, and applies the chosen appearance.
struct MenuBarPopoverView: View {
let calendar: StudyCalendarViewModel
let today: TodayViewModel
@ViewBuilder let content: (MenuBarSummary) -> Content

var body: some View {
content(MenuBarSummary(calendar: calendar, dueCards: today.totalDue))
}
}

/// The menu bar item's label: an icon, plus the remaining minutes while a lecture runs.
struct MenuBarLabel: View {
let summary: MenuBarSummary
let onOpenLecture: (UUID) -> Void
let onReviewCards: () -> Void
let onOpenApp: () -> Void
@AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard

var body: some View {
if summary.title.isEmpty {
Image(systemName: "graduationcap")
} else {
Label(summary.title, systemImage: "record.circle")
.labelStyle(.titleAndIcon)
}
MenuBarContent(
summary: MenuBarSummary(calendar: calendar, dueCards: today.totalDue),
onOpenLecture: onOpenLecture,
onReviewCards: onReviewCards,
onOpenApp: onOpenApp
)
.tint(Theme.accent)
.preferredColorScheme(appearance.colorScheme)
}
}

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Dependencies point **inward**: Presentation and Data both depend on Domain, Doma

`AppContainer(inMemory: true)` swaps in in-memory persistence and preferences for previews and tests.

`AppLauncher` is the entry point: under XCTest it starts an empty host app instead of `StudyAppApp`, so launch work (database, sync, menu bar item) never runs during unit tests. On CI runners the menu bar scene kept the test host from finishing its launch.
`AppLauncher` is the entry point: under XCTest it starts an empty host app instead of `StudyAppApp`, so launch work (database, sync, menu bar item) never runs during unit tests. This keeps the unit tests independent of the window, the menu bar item and the network.

## Data flow example: calendar subscription → module

Expand Down
4 changes: 3 additions & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ Code: `Presentation/StudIP/`, `Domain/UseCases/StudIPUseCases.swift`, `Data/Stud

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/`
The item is an AppKit `NSStatusItem` with a popover (`App/MenuBarController.swift`), not a SwiftUI `MenuBarExtra` scene: that scene kept the scene graph changing at launch and the app hung.

Code: `App/MenuBarController.swift`, `Presentation/MenuBar/`

## Shortcuts and Siri

Expand Down
Loading