diff --git a/App/AppContainer.swift b/App/AppContainer.swift index ef90630..1823db4 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -20,6 +20,7 @@ final class AppContainer { let studIPAnnouncementService: any StudIPAnnouncementService let announcementRepository: any AnnouncementRepository let studIPScheduleService: any StudIPScheduleService + let canteenService: any CanteenService let studIPAccountStore: any StudIPAccountStore let courseFileRepository: any CourseFileRepository let backupArchiver: any BackupArchiving @@ -55,6 +56,7 @@ final class AppContainer { studIPService = studIPClient studIPAnnouncementService = studIPClient studIPScheduleService = studIPClient + canteenService = OpenMensaClient() if inMemory { preferences = InMemoryStudyPreferences() studIPAccountStore = InMemoryStudIPAccountStore() @@ -147,6 +149,10 @@ final class AppContainer { StudyTimerViewModel(repository: studySessionRepository, settings: preferences, scheduler: notificationScheduler) } + func makeMensaViewModel() -> MensaViewModel { + MensaViewModel(service: canteenService, settings: preferences) + } + func makeAssignmentsViewModel() -> AssignmentsViewModel { AssignmentsViewModel(repository: assignmentRepository) } diff --git a/App/StudyAppApp.swift b/App/StudyAppApp.swift index 2ad223f..abe6043 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -19,6 +19,7 @@ struct StudyAppApp: App { @State private var examsViewModel: ExamsViewModel @State private var announcementsViewModel: AnnouncementsViewModel @State private var studyTimerViewModel: StudyTimerViewModel + @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 @@ -88,6 +89,7 @@ struct StudyAppApp: App { _examsViewModel = State(initialValue: exams) _announcementsViewModel = State(initialValue: announcements) _studyTimerViewModel = State(initialValue: studyTimer) + _mensaViewModel = State(initialValue: container.makeMensaViewModel()) } catch { fatalError("Datenbank konnte nicht geöffnet werden: \(error)") } @@ -106,6 +108,7 @@ struct StudyAppApp: App { examsViewModel: examsViewModel, announcementsViewModel: announcementsViewModel, studyTimerViewModel: studyTimerViewModel, + mensaViewModel: mensaViewModel, makeSlidesViewModel: { moduleID in container.makeSlidesViewModel(moduleID: moduleID) }, @@ -176,7 +179,8 @@ struct StudyAppApp: App { updater: updater, studIP: studIPViewModel, backup: backupViewModel, - notifications: notificationsViewModel + notifications: notificationsViewModel, + mensa: mensaViewModel ) .tint(Theme.accent) .preferredColorScheme(appearance.colorScheme) @@ -240,6 +244,8 @@ struct StudyAppApp: App { try? await Task.sleep(for: .seconds(300)) await calendarViewModel.refreshStaleSubscriptions() await studIPViewModel.syncIfDue() + // Switches to tomorrow's menu after lunch; cached, so usually no request. + await mensaViewModel.refresh() // Picks up new or moved lectures and assignments. await notificationsViewModel.refresh() } diff --git a/Data/Mensa/OpenMensaClient.swift b/Data/Mensa/OpenMensaClient.swift new file mode 100644 index 0000000..c0c359d --- /dev/null +++ b/Data/Mensa/OpenMensaClient.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Loads canteens and menus from the OpenMensa API v2. No account is needed. +struct OpenMensaClient: CanteenService { + static let baseURL = URL(string: "https://openmensa.org/api/v2/")! + + var session: URLSession = .shared + var calendar: Calendar = .current + + func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { + let data = try await get("canteens", query: [ + URLQueryItem(name: "near[lat]", value: String(latitude)), + URLQueryItem(name: "near[lng]", value: String(longitude)), + URLQueryItem(name: "near[dist]", value: String(radius)), + ]) + return try OpenMensaParser.canteens(data) + } + + func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { + let day = OpenMensaParser.dayString(date, calendar: calendar) + let isClosed = try OpenMensaParser.isClosed(try await get("canteens/\(canteenID)/days/\(day)")) + let meals = isClosed ? [] : try OpenMensaParser.meals(try await get("canteens/\(canteenID)/days/\(day)/meals")) + return CanteenMenu(canteenID: canteenID, date: date, isClosed: isClosed, meals: meals) + } + + private func get(_ path: String, query: [URLQueryItem] = []) async throws -> Data { + var components = URLComponents(url: Self.baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) + if !query.isEmpty { components?.queryItems = query } + guard let url = components?.url else { throw MensaError.unavailable } + var request = URLRequest(url: url) + request.timeoutInterval = 20 + request.setValue("application/json", forHTTPHeaderField: "Accept") + let data: Data + let response: URLResponse + do { + (data, response) = try await session.data(for: request) + } catch { + throw MensaError.offline + } + guard let http = response as? HTTPURLResponse else { throw MensaError.unavailable } + // OpenMensa answers 404 for days without a published menu. + guard (200...299).contains(http.statusCode) else { throw MensaError.unavailable } + return data + } +} diff --git a/Data/Mensa/OpenMensaParser.swift b/Data/Mensa/OpenMensaParser.swift new file mode 100644 index 0000000..43ced60 --- /dev/null +++ b/Data/Mensa/OpenMensaParser.swift @@ -0,0 +1,55 @@ +import Foundation + +/// Reads OpenMensa API v2 responses (https://openmensa.org/api/v2). +enum OpenMensaParser { + private struct CanteenDTO: Decodable { + let id: Int + let name: String + let city: String? + let address: String? + } + + private struct DayDTO: Decodable { + let closed: Bool + } + + private struct MealDTO: Decodable { + struct Prices: Decodable { + let students: Double? + } + + let id: Int + let name: String + let category: String? + let prices: Prices? + let notes: [String]? + } + + static func canteens(_ data: Data) throws -> [Canteen] { + try JSONDecoder().decode([CanteenDTO].self, from: data).map { + Canteen(id: $0.id, name: $0.name, city: $0.city ?? "", address: $0.address ?? "") + } + } + + static func isClosed(_ data: Data) throws -> Bool { + try JSONDecoder().decode(DayDTO.self, from: data).closed + } + + static func meals(_ data: Data) throws -> [Meal] { + try JSONDecoder().decode([MealDTO].self, from: data).map { + Meal( + id: $0.id, + name: $0.name, + category: $0.category ?? "Essen", + studentPrice: $0.prices?.students, + notes: $0.notes ?? [] + ) + } + } + + /// OpenMensa addresses days as `yyyy-MM-dd` in the canteen's (German) time zone. + static func dayString(_ date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } +} diff --git a/Data/Preferences/UserDefaultsStudyPreferences.swift b/Data/Preferences/UserDefaultsStudyPreferences.swift index 7b702f2..03df2b3 100644 --- a/Data/Preferences/UserDefaultsStudyPreferences.swift +++ b/Data/Preferences/UserDefaultsStudyPreferences.swift @@ -13,6 +13,7 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto static let lastAutoOpenedLecture = "autopilot.lastHandledLecture" static let automaticBackups = "backup.automatic" static let importsStudIPTimetable = "studip.importsTimetable" + static let canteenID = "mensa.canteenID" static let notificationSettings = "notifications.settings" static let studyTimerSettings = "studyTimer.settings" static func lastPage(_ documentID: UUID) -> String { "reading.page.\(documentID.uuidString)" } @@ -67,6 +68,11 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto set { defaults.set(newValue, forKey: Key.importsStudIPTimetable) } } + var canteenID: Int? { + get { defaults.object(forKey: Key.canteenID) as? Int } + set { defaults.set(newValue, forKey: Key.canteenID) } + } + /// Stored as JSON; unreadable or missing data falls back to the defaults. var notificationSettings: NotificationSettings { get { diff --git a/Domain/Entities/Mensa.swift b/Domain/Entities/Mensa.swift new file mode 100644 index 0000000..2a6c24b --- /dev/null +++ b/Domain/Entities/Mensa.swift @@ -0,0 +1,52 @@ +import Foundation + +/// A canteen listed by OpenMensa. +struct Canteen: Identifiable, Hashable, Codable, Sendable { + let id: Int + var name: String + var city: String + var address: String +} + +/// One dish on a canteen's menu. +struct Meal: Identifiable, Hashable, Sendable { + let id: Int + var name: String + /// e.g. „Pasta“, „Vegetarisch“, „Pizza“. + var category: String + /// Price for students in euros, if the canteen publishes one. + var studentPrice: Double? + /// Hints such as „vegan“ or allergens. + var notes: [String] +} + +/// The menu of one canteen on one day. +struct CanteenMenu: Equatable, Sendable { + let canteenID: Int + let date: Date + var isClosed: Bool + var meals: [Meal] + + /// Meals grouped by category, in the order the canteen lists them. + var mealsByCategory: [(category: String, meals: [Meal])] { + var order: [String] = [] + var groups: [String: [Meal]] = [:] + for meal in meals { + if groups[meal.category] == nil { order.append(meal.category) } + groups[meal.category, default: []].append(meal) + } + return order.map { ($0, groups[$0] ?? []) } + } +} + +enum MensaError: LocalizedError, Equatable { + case offline + case unavailable + + var errorDescription: String? { + switch self { + case .offline: "Der Mensaplan ist gerade nicht erreichbar. Bist du online?" + case .unavailable: "Für diesen Tag gibt es keinen Speiseplan." + } + } +} diff --git a/Domain/Repositories/CanteenService.swift b/Domain/Repositories/CanteenService.swift new file mode 100644 index 0000000..155dd78 --- /dev/null +++ b/Domain/Repositories/CanteenService.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Port to a canteen menu source (OpenMensa). +@MainActor +protocol CanteenService { + /// Canteens within `radius` kilometres of a coordinate. + func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] + /// The menu of a canteen on a day. Throws `MensaError.unavailable` when the canteen publishes nothing for it. + func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu +} diff --git a/Domain/Repositories/StudyPreferences.swift b/Domain/Repositories/StudyPreferences.swift index 4344826..7a31a52 100644 --- a/Domain/Repositories/StudyPreferences.swift +++ b/Domain/Repositories/StudyPreferences.swift @@ -20,6 +20,8 @@ protocol StudySettingsStore: AnyObject { var automaticBackups: Bool { get set } /// Import the dates of linked Stud.IP courses into the calendar during a Stud.IP sync. var importsStudIPTimetable: Bool { get set } + /// OpenMensa canteen shown on „Heute“; `nil` hides the menu. + var canteenID: Int? { get set } var notificationSettings: NotificationSettings { get set } var studyTimerSettings: StudyTimerSettings { get set } } @@ -48,6 +50,7 @@ final class InMemoryStudyPreferences: StudySettingsStore, ReadingProgressStore { var lastAutoOpenedLecture: String? var automaticBackups = true var importsStudIPTimetable = false + var canteenID: Int? var notificationSettings = NotificationSettings() var studyTimerSettings = StudyTimerSettings() private var pages: [UUID: Int] = [:] diff --git a/Domain/UseCases/MensaUseCases.swift b/Domain/UseCases/MensaUseCases.swift new file mode 100644 index 0000000..d3e0c7d --- /dev/null +++ b/Domain/UseCases/MensaUseCases.swift @@ -0,0 +1,40 @@ +import Foundation + +/// Which day's menu is worth showing: after lunch is over, the next day's. +enum MensaSchedule { + /// Hot meals are served until about 14:15; afterwards the next day is more useful. + static let lunchEndHour = 15 + + static func menuDay(for now: Date, calendar: Calendar = .current) -> Date { + let today = calendar.startOfDay(for: now) + guard calendar.component(.hour, from: now) >= lunchEndHour else { return today } + return calendar.date(byAdding: .day, value: 1, to: today) ?? today + } +} + +/// Canteens of the Studierendenwerk Oldenburg area, nearest first. +enum MensaLocation { + /// University of Oldenburg, campus Haarentor. + static let oldenburgLatitude = 53.1478 + static let oldenburgLongitude = 8.1814 + static let searchRadius = 15.0 +} + +/// Loads a menu and keeps it for the rest of the day, so „Heute“ does not ask the server on every visit. +@MainActor +final class LoadCanteenMenuUseCase { + private let service: any CanteenService + private var cache: [String: CanteenMenu] = [:] + + init(service: any CanteenService) { + self.service = service + } + + func callAsFunction(canteenID: Int, day: Date) async throws -> CanteenMenu { + let key = "\(canteenID)-\(day.timeIntervalSince1970)" + if let cached = cache[key] { return cached } + let menu = try await service.menu(canteenID: canteenID, on: day) + cache = [key: menu] + return menu + } +} diff --git a/Presentation/Mensa/MensaCard.swift b/Presentation/Mensa/MensaCard.swift new file mode 100644 index 0000000..60927a1 --- /dev/null +++ b/Presentation/Mensa/MensaCard.swift @@ -0,0 +1,113 @@ +import SwiftUI + +/// Today's (or, after lunch, tomorrow's) canteen menu on „Heute“. +struct MensaCard: View { + let viewModel: MensaViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + SectionHeader("Mensa · \(viewModel.dayTitle)", systemImage: "fork.knife") + content + } + .card() + } + + @ViewBuilder + private var content: some View { + switch viewModel.state { + case .off: + EmptyView() + case .loading: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, minHeight: 44) + case .closed: + message("Die Mensa hat \(viewModel.dayTitle.lowercased()) geschlossen.", systemImage: "moon.zzz") + case .unavailable(let text): + message(text, systemImage: "wifi.slash") + case .menu(let menu): + VStack(alignment: .leading, spacing: 10) { + ForEach(menu.mealsByCategory, id: \.category) { group in + VStack(alignment: .leading, spacing: 4) { + Text(group.category) + .font(Theme.Typography.overline) + .textCase(.uppercase) + .foregroundStyle(Theme.textSecondary) + ForEach(group.meals) { meal in + MealRow(meal: meal) + } + } + } + } + } + } + + private func message(_ text: String, systemImage: String) -> some View { + Label(text, systemImage: systemImage) + .font(Theme.Typography.caption) + .foregroundStyle(Theme.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct MealRow: View { + let meal: Meal + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text(meal.name) + .font(Theme.Typography.body) + .foregroundStyle(Theme.textPrimary) + .lineLimit(2) + if !meal.notes.isEmpty { + Text(meal.notes.joined(separator: " · ")) + .font(Theme.Typography.small) + .foregroundStyle(Theme.textTertiary) + .lineLimit(1) + } + } + Spacer(minLength: 8) + if let price = MensaViewModel.priceText(meal.studentPrice) { + Text(price) + .font(Theme.Typography.caption.monospacedDigit()) + .foregroundStyle(Theme.textSecondary) + } + } + } +} + +/// Canteen choice in the settings. +struct MensaSettingsSection: View { + let viewModel: MensaViewModel + + private var selection: Binding { + Binding( + get: { viewModel.canteenID }, + set: { id in Task { await viewModel.selectCanteen(id) } } + ) + } + + var body: some View { + Section { + Picker("Speiseplan auf „Heute“", selection: selection) { + Text("Aus").tag(Int?.none) + ForEach(viewModel.canteens) { canteen in + Text(canteen.name).tag(Int?.some(canteen.id)) + } + // Keeps a stored choice visible while the list is still loading or offline. + if let id = viewModel.canteenID, !viewModel.canteens.contains(where: { $0.id == id }) { + Text("Mensa \(id)").tag(Int?.some(id)) + } + } + if viewModel.isLoadingCanteens { + ProgressView().controlSize(.small) + } + } header: { + Text("Mensa") + } footer: { + Text("Der Speiseplan kommt von OpenMensa. Nach dem Mittagessen zeigt „Heute“ schon den Plan für morgen.") + } + .task { await viewModel.loadCanteens() } + } +} diff --git a/Presentation/Mensa/MensaViewModel.swift b/Presentation/Mensa/MensaViewModel.swift new file mode 100644 index 0000000..476637e --- /dev/null +++ b/Presentation/Mensa/MensaViewModel.swift @@ -0,0 +1,86 @@ +import Foundation +import Observation + +/// The canteen menu on „Heute“ and the canteen choice in the settings. +@MainActor +@Observable +final class MensaViewModel { + enum State: Equatable { + case off + case loading + case closed + case menu(CanteenMenu) + case unavailable(String) + } + + private(set) var state: State = .off + private(set) var canteens: [Canteen] = [] + private(set) var isLoadingCanteens = false + /// The day the shown menu is for (today, or tomorrow after lunch). + private(set) var day: Date + + private let service: any CanteenService + private let loadMenu: LoadCanteenMenuUseCase + private let settings: any StudySettingsStore + private let now: () -> Date + private let calendar: Calendar + + init( + service: any CanteenService, + settings: any StudySettingsStore, + now: @escaping () -> Date = { .now }, + calendar: Calendar = .current + ) { + self.service = service + self.settings = settings + self.now = now + self.calendar = calendar + loadMenu = LoadCanteenMenuUseCase(service: service) + day = MensaSchedule.menuDay(for: now(), calendar: calendar) + } + + var canteenID: Int? { settings.canteenID } + var isEnabled: Bool { canteenID != nil } + + /// „Heute“ or „Morgen“. + var dayTitle: String { + calendar.isDateInToday(day) ? "Heute" : "Morgen" + } + + func selectCanteen(_ id: Int?) async { + settings.canteenID = id + await refresh() + } + + func refresh() async { + guard let canteenID else { + state = .off + return + } + day = MensaSchedule.menuDay(for: now(), calendar: calendar) + if case .menu(let menu) = state, menu.canteenID == canteenID, menu.date == day { return } + state = .loading + do { + let menu = try await loadMenu(canteenID: canteenID, day: day) + state = menu.isClosed || menu.meals.isEmpty ? .closed : .menu(menu) + } catch { + state = .unavailable(error.localizedDescription) + } + } + + /// Canteens around Oldenburg for the settings, nearest first. + func loadCanteens() async { + guard canteens.isEmpty, !isLoadingCanteens else { return } + isLoadingCanteens = true + defer { isLoadingCanteens = false } + canteens = (try? await service.canteens( + nearLatitude: MensaLocation.oldenburgLatitude, + longitude: MensaLocation.oldenburgLongitude, + radius: MensaLocation.searchRadius + )) ?? [] + } + + static func priceText(_ price: Double?) -> String? { + price.map { $0.formatted(.currency(code: "EUR").locale(Locale(identifier: "de_DE"))) } + } +} diff --git a/Presentation/RootView.swift b/Presentation/RootView.swift index 8ffd50d..e0f232b 100644 --- a/Presentation/RootView.swift +++ b/Presentation/RootView.swift @@ -11,6 +11,7 @@ struct RootView: View { @Bindable var examsViewModel: ExamsViewModel @Bindable var announcementsViewModel: AnnouncementsViewModel let studyTimerViewModel: StudyTimerViewModel + let mensaViewModel: MensaViewModel let makeSlidesViewModel: SlidesViewModelFactory let makeLearningViewModel: LearningViewModelFactory let makeCourseFilesViewModel: CourseFilesViewModelFactory @@ -141,6 +142,7 @@ struct RootView: View { assignments: assignmentsViewModel, exams: examsViewModel, announcements: announcementsViewModel, + mensa: mensaViewModel, onOpenModule: { moduleID, section, intent in navigation.show(module: moduleID, section: section, intent: intent) }, diff --git a/Presentation/Settings/GeneralSettingsPane.swift b/Presentation/Settings/GeneralSettingsPane.swift index 1048c1d..7ccb9a6 100644 --- a/Presentation/Settings/GeneralSettingsPane.swift +++ b/Presentation/Settings/GeneralSettingsPane.swift @@ -1,8 +1,9 @@ import SwiftUI -/// Appearance, note font size and what happens when the app opens during a lecture. +/// Appearance, note font size, what happens when the app opens during a lecture, and the canteen. struct GeneralSettingsPane: View { let settings: any StudySettingsStore + let mensa: MensaViewModel @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard @AppStorage(ViewPreferences.noteFontSizeKey) private var noteFontSize = NoteFontSize.standard @@ -42,6 +43,8 @@ struct GeneralSettingsPane: View { } footer: { Text("Öffnest du StudyApp während einer Vorlesung, landest du direkt in den Folien des Moduls – an der Stelle, an der du zuletzt warst.") } + + MensaSettingsSection(viewModel: mensa) } .onAppear { autoOpenLecture = settings.autoOpenLecture diff --git a/Presentation/Settings/SettingsPane.swift b/Presentation/Settings/SettingsPane.swift index d0dbe5c..8b76680 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: 370 + case .general: 490 case .schedule: 260 case .studIP: 400 case .notifications: 320 diff --git a/Presentation/Settings/SettingsView.swift b/Presentation/Settings/SettingsView.swift index 9619611..1f49c5f 100644 --- a/Presentation/Settings/SettingsView.swift +++ b/Presentation/Settings/SettingsView.swift @@ -8,6 +8,7 @@ struct SettingsView: View { let studIP: StudIPViewModel let backup: BackupViewModel let notifications: NotificationsViewModel + let mensa: MensaViewModel @AppStorage(ViewPreferences.settingsPaneKey) private var selection = SettingsPane.general @@ -25,7 +26,7 @@ struct SettingsView: View { private func content(for pane: SettingsPane) -> some View { switch pane { case .general: - GeneralSettingsPane(settings: settings) + GeneralSettingsPane(settings: settings, mensa: mensa) case .schedule: ScheduleSettingsPane(settings: settings) case .studIP: diff --git a/Presentation/Today/TodayView.swift b/Presentation/Today/TodayView.swift index 7a6f515..9314dbb 100644 --- a/Presentation/Today/TodayView.swift +++ b/Presentation/Today/TodayView.swift @@ -7,6 +7,7 @@ struct TodayView: View { @Bindable var assignments: AssignmentsViewModel @Bindable var exams: ExamsViewModel @Bindable var announcements: AnnouncementsViewModel + let mensa: MensaViewModel let onOpenModule: (UUID, ModuleSection, ModuleIntent?) -> Void let onShowCalendar: () -> Void let onCreateModule: () -> Void @@ -39,6 +40,7 @@ struct TodayView: View { .task { today.load() exams.load() + await mensa.refresh() } } @@ -72,6 +74,9 @@ struct TodayView: View { onOpenModule(overview.module.id, .exam, nil) } } + if mensa.isEnabled { + MensaCard(viewModel: mensa) + } } } diff --git a/StudyAppTests/MensaTests.swift b/StudyAppTests/MensaTests.swift new file mode 100644 index 0000000..9d28e74 --- /dev/null +++ b/StudyAppTests/MensaTests.swift @@ -0,0 +1,126 @@ +import Foundation +import Testing +@testable import StudyApp + +struct OpenMensaParserTests { + @Test func readsCanteens() throws { + let json = #"[{"id":1084,"name":"Oldenburg, Mensa Uhlhornsweg","city":"Oldenburg","address":"Uhlhornsweg 49-55, 26129 Oldenburg","coordinates":[53.14,8.18]}]"# + let canteens = try OpenMensaParser.canteens(Data(json.utf8)) + #expect(canteens == [Canteen(id: 1084, name: "Oldenburg, Mensa Uhlhornsweg", city: "Oldenburg", address: "Uhlhornsweg 49-55, 26129 Oldenburg")]) + } + + @Test func readsMealsWithStudentPriceAndNotes() throws { + let json = #""" + [{"id":1,"name":"Spaghetti Bolognese","category":"Pasta","prices":{"students":2.5,"employees":4.1,"pupils":null,"others":5.0},"notes":["Rind"]}, + {"id":2,"name":"Gemüsecurry","category":"Vegan","prices":{"students":null},"notes":[]}, + {"id":3,"name":"Salatbar","prices":null}] + """# + let meals = try OpenMensaParser.meals(Data(json.utf8)) + #expect(meals.map(\.studentPrice) == [2.5, nil, nil]) + #expect(meals.map(\.category) == ["Pasta", "Vegan", "Essen"]) + #expect(meals.first?.notes == ["Rind"]) + } + + @Test func readsClosedDaysAndFormatsDates() throws { + #expect(try OpenMensaParser.isClosed(Data(#"{"date":"2026-10-28","closed":true}"#.utf8))) + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) + let date = try #require(calendar.date(from: DateComponents(year: 2026, month: 3, day: 9, hour: 23, minute: 30))) + #expect(OpenMensaParser.dayString(date, calendar: calendar) == "2026-03-09") + } +} + +@MainActor +final class FakeCanteenService: CanteenService { + var menus: [Int: CanteenMenu] = [:] + var error: MensaError? + private(set) var requests = 0 + + func canteens(nearLatitude latitude: Double, longitude: Double, radius: Double) async throws -> [Canteen] { [] } + + func menu(canteenID: Int, on date: Date) async throws -> CanteenMenu { + requests += 1 + if let error { throw error } + return menus[canteenID] ?? CanteenMenu(canteenID: canteenID, date: date, isClosed: false, meals: []) + } +} + +@MainActor +struct MensaUseCaseTests { + @Test func showsTheNextDayAfterLunch() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) + let morning = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 28, hour: 11))) + let afternoon = try #require(calendar.date(from: DateComponents(year: 2026, month: 10, day: 28, hour: 16))) + + #expect(MensaSchedule.menuDay(for: morning, calendar: calendar) == calendar.startOfDay(for: morning)) + #expect(calendar.component(.day, from: MensaSchedule.menuDay(for: afternoon, calendar: calendar)) == 29) + } + + @Test func cachesTheMenuOfADay() async throws { + let service = FakeCanteenService() + let load = LoadCanteenMenuUseCase(service: service) + let day = Date(timeIntervalSince1970: 1_760_000_000) + + _ = try await load(canteenID: 1, day: day) + _ = try await load(canteenID: 1, day: day) + #expect(service.requests == 1) + + _ = try await load(canteenID: 2, day: day) + #expect(service.requests == 2) + } + + @Test func groupsMealsByCategoryInOrder() { + let menu = CanteenMenu(canteenID: 1, date: .now, isClosed: false, meals: [ + Meal(id: 1, name: "A", category: "Pasta", studentPrice: nil, notes: []), + Meal(id: 2, name: "B", category: "Vegan", studentPrice: nil, notes: []), + Meal(id: 3, name: "C", category: "Pasta", studentPrice: nil, notes: []), + ]) + #expect(menu.mealsByCategory.map(\.category) == ["Pasta", "Vegan"]) + #expect(menu.mealsByCategory.first?.meals.map(\.name) == ["A", "C"]) + } +} + +@MainActor +struct MensaViewModelTests { + let service = FakeCanteenService() + let settings = InMemoryStudyPreferences() + let morning = Date(timeIntervalSince1970: 1_761_642_000) // 28 Oct 2026, 10:00 in Berlin + + private func makeViewModel() throws -> MensaViewModel { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Europe/Berlin")) + return MensaViewModel(service: service, settings: settings, now: { [morning] in morning }, calendar: calendar) + } + + @Test func staysOffWithoutCanteen() async throws { + let viewModel = try makeViewModel() + await viewModel.refresh() + #expect(viewModel.state == .off) + #expect(service.requests == 0) + } + + @Test func showsMealsOrClosedOrUnavailable() async throws { + let viewModel = try makeViewModel() + let meal = Meal(id: 1, name: "Gemüsecurry", category: "Vegan", studentPrice: 2.4, notes: []) + service.menus[7] = CanteenMenu(canteenID: 7, date: viewModel.day, isClosed: false, meals: [meal]) + + await viewModel.selectCanteen(7) + #expect(viewModel.state == .menu(CanteenMenu(canteenID: 7, date: viewModel.day, isClosed: false, meals: [meal]))) + #expect(settings.canteenID == 7) + + service.menus[8] = CanteenMenu(canteenID: 8, date: viewModel.day, isClosed: true, meals: []) + await viewModel.selectCanteen(8) + #expect(viewModel.state == .closed) + + service.error = .offline + await viewModel.selectCanteen(9) + #expect(viewModel.state == .unavailable(MensaError.offline.localizedDescription)) + } + + @Test func formatsStudentPrices() { + // The currency style separates the amount with a no-break space. + #expect(MensaViewModel.priceText(2.5)?.replacingOccurrences(of: "\u{00A0}", with: " ") == "2,50 €") + #expect(MensaViewModel.priceText(nil) == nil) + } +} diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 761437c..f0d3f07 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -98,6 +98,12 @@ StudyApp offers actions in the Shortcuts app and to Siri without any setup: Code: `App/Intents/`, `Domain/UseCases/QuickInfoUseCases.swift` +## Mensa + +Settings → Allgemein → Mensa picks a canteen near the University of Oldenburg (list from OpenMensa, nearest first). „Heute“ then shows its menu grouped by category with the student price; after 15:00 it shows tomorrow's menu. Closed days and missing menus are shown as such; the menu of a day is cached, and the app works without it offline. + +Code: `Domain/UseCases/MensaUseCases.swift`, `Data/Mensa/` (OpenMensa API v2), `Presentation/Mensa/` + ## Updates and settings - Automatic updates through Sparkle („Nach Updates suchen …“ in the app menu). See [RELEASING.md](RELEASING.md).