From 6cbbc8dbbe63c99a9227cb2cbf494f7d3f65fdbb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:50:15 +0000 Subject: [PATCH 1/5] feat(mensa): add canteens and menus to the domain Canteen, Meal and CanteenMenu (grouped by category), a CanteenService port, the canteen setting (nil hides the menu), MensaSchedule (after 15:00 the next day's menu is more useful) and LoadCanteenMenuUseCase, which keeps the menu of the day in memory. Refs #43 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- .../UserDefaultsStudyPreferences.swift | 6 +++ Domain/Entities/Mensa.swift | 52 +++++++++++++++++++ Domain/Repositories/CanteenService.swift | 10 ++++ Domain/Repositories/StudyPreferences.swift | 3 ++ Domain/UseCases/MensaUseCases.swift | 40 ++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 Domain/Entities/Mensa.swift create mode 100644 Domain/Repositories/CanteenService.swift create mode 100644 Domain/UseCases/MensaUseCases.swift diff --git a/Data/Preferences/UserDefaultsStudyPreferences.swift b/Data/Preferences/UserDefaultsStudyPreferences.swift index cf5e41f..26a53c5 100644 --- a/Data/Preferences/UserDefaultsStudyPreferences.swift +++ b/Data/Preferences/UserDefaultsStudyPreferences.swift @@ -12,6 +12,7 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto static let ignoredCourseKeys = "moduleDetection.ignoredKeys" static let lastAutoOpenedLecture = "autopilot.lastHandledLecture" static let automaticBackups = "backup.automatic" + static let canteenID = "mensa.canteenID" static let notificationSettings = "notifications.settings" static func lastPage(_ documentID: UUID) -> String { "reading.page.\(documentID.uuidString)" } static func lastDocument(_ moduleID: UUID) -> String { "reading.document.\(moduleID.uuidString)" } @@ -60,6 +61,11 @@ final class UserDefaultsStudyPreferences: StudySettingsStore, ReadingProgressSto set { defaults.set(newValue, forKey: Key.automaticBackups) } } + 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 daf86ff..1915376 100644 --- a/Domain/Repositories/StudyPreferences.swift +++ b/Domain/Repositories/StudyPreferences.swift @@ -18,6 +18,8 @@ protocol StudySettingsStore: AnyObject { var lastAutoOpenedLecture: String? { get set } /// Create a weekly backup in the app's backup folder. var automaticBackups: Bool { get set } + /// OpenMensa canteen shown on „Heute“; `nil` hides the menu. + var canteenID: Int? { get set } var notificationSettings: NotificationSettings { get set } } @@ -44,6 +46,7 @@ final class InMemoryStudyPreferences: StudySettingsStore, ReadingProgressStore { var ignoredCourseKeys: Set = [] var lastAutoOpenedLecture: String? var automaticBackups = true + var canteenID: Int? var notificationSettings = NotificationSettings() private var pages: [UUID: Int] = [:] private var documents: [UUID: UUID] = [:] 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 + } +} From 1d16d55abf808d07bd1b0e678030c581dca262ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:50:15 +0000 Subject: [PATCH 2/5] feat(mensa): load canteens and menus from OpenMensa OpenMensaClient talks to the public OpenMensa API v2: canteens near a coordinate, whether a day is closed and its meals with the student price. A missing menu (404) and network errors map to MensaError. Refs #43 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Data/Mensa/OpenMensaClient.swift | 45 ++++++++++++++++++ Data/Mensa/OpenMensaParser.swift | 55 +++++++++++++++++++++ StudyAppTests/MensaTests.swift | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 Data/Mensa/OpenMensaClient.swift create mode 100644 Data/Mensa/OpenMensaParser.swift create mode 100644 StudyAppTests/MensaTests.swift 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/StudyAppTests/MensaTests.swift b/StudyAppTests/MensaTests.swift new file mode 100644 index 0000000..b5de6c6 --- /dev/null +++ b/StudyAppTests/MensaTests.swift @@ -0,0 +1,82 @@ +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"]) + } +} From 31bb7243f51ea69753e5d7bf7215102448f488f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:52:58 +0000 Subject: [PATCH 3/5] feat(mensa): add the menu card and canteen setting MensaViewModel loads the chosen canteen's menu for today (tomorrow after lunch) and reports off, loading, closed, menu or unavailable. MensaCard groups the meals by category with the student price; MensaSettingsSection offers the canteens around Oldenburg. Refs #43 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- Presentation/Mensa/MensaCard.swift | 113 ++++++++++++++++++++++++ Presentation/Mensa/MensaViewModel.swift | 86 ++++++++++++++++++ StudyAppTests/MensaTests.swift | 44 +++++++++ 3 files changed, 243 insertions(+) create mode 100644 Presentation/Mensa/MensaCard.swift create mode 100644 Presentation/Mensa/MensaViewModel.swift 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/StudyAppTests/MensaTests.swift b/StudyAppTests/MensaTests.swift index b5de6c6..9d28e74 100644 --- a/StudyAppTests/MensaTests.swift +++ b/StudyAppTests/MensaTests.swift @@ -80,3 +80,47 @@ struct MensaUseCaseTests { #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) + } +} From 0942807ec11b89c3e045aa9bc0910011b79ca517 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:53:23 +0000 Subject: [PATCH 4/5] feat(mensa): show the menu on Today and the canteen in the settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit „Heute“ shows the menu card at the end of the right column when a canteen is chosen; Settings → Allgemein gets a „Mensa“ section. The menu refreshes with the five-minute background loop, which switches to tomorrow's menu after lunch. Refs #43 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- App/AppContainer.swift | 6 ++++++ App/StudyAppApp.swift | 8 +++++++- Presentation/RootView.swift | 2 ++ Presentation/Settings/GeneralSettingsPane.swift | 5 ++++- Presentation/Settings/SettingsPane.swift | 2 +- Presentation/Settings/SettingsView.swift | 3 ++- Presentation/Today/TodayView.swift | 5 +++++ 7 files changed, 27 insertions(+), 4 deletions(-) diff --git a/App/AppContainer.swift b/App/AppContainer.swift index 9d944fc..723d203 100644 --- a/App/AppContainer.swift +++ b/App/AppContainer.swift @@ -17,6 +17,7 @@ final class AppContainer { let fileStorage: any FileStorage let pdfInspector: any PDFInspector let studIPService: any StudIPService + let canteenService: any CanteenService let studIPAccountStore: any StudIPAccountStore let courseFileRepository: any CourseFileRepository let backupArchiver: any BackupArchiving @@ -45,6 +46,7 @@ final class AppContainer { calendarEventParser = ICalendarFeedParser() pdfInspector = PDFKitInspector() studIPService = StudIPJSONAPIClient() + canteenService = OpenMensaClient() if inMemory { preferences = InMemoryStudyPreferences() studIPAccountStore = InMemoryStudIPAccountStore() @@ -128,6 +130,10 @@ final class AppContainer { ) } + 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 d894132..dbe9021 100644 --- a/App/StudyAppApp.swift +++ b/App/StudyAppApp.swift @@ -16,6 +16,7 @@ struct StudyAppApp: App { @State private var backupViewModel: BackupViewModel @State private var notificationsViewModel: NotificationsViewModel @State private var examsViewModel: ExamsViewModel + @State private var mensaViewModel: MensaViewModel @State private var updater = AppUpdater() @AppStorage(ViewPreferences.appearanceKey) private var appearance = AppAppearance.standard @@ -74,6 +75,7 @@ struct StudyAppApp: App { } _backupViewModel = State(initialValue: backup) _examsViewModel = State(initialValue: exams) + _mensaViewModel = State(initialValue: container.makeMensaViewModel()) } catch { fatalError("Datenbank konnte nicht geöffnet werden: \(error)") } @@ -90,6 +92,7 @@ struct StudyAppApp: App { studIPViewModel: studIPViewModel, backupViewModel: backupViewModel, examsViewModel: examsViewModel, + mensaViewModel: mensaViewModel, makeSlidesViewModel: { moduleID in container.makeSlidesViewModel(moduleID: moduleID) }, @@ -132,7 +135,8 @@ struct StudyAppApp: App { updater: updater, studIP: studIPViewModel, backup: backupViewModel, - notifications: notificationsViewModel + notifications: notificationsViewModel, + mensa: mensaViewModel ) .tint(Theme.accent) .preferredColorScheme(appearance.colorScheme) @@ -186,6 +190,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/Presentation/RootView.swift b/Presentation/RootView.swift index 68f5ea3..00a8a99 100644 --- a/Presentation/RootView.swift +++ b/Presentation/RootView.swift @@ -9,6 +9,7 @@ struct RootView: View { @Bindable var studIPViewModel: StudIPViewModel @Bindable var backupViewModel: BackupViewModel @Bindable var examsViewModel: ExamsViewModel + let mensaViewModel: MensaViewModel let makeSlidesViewModel: SlidesViewModelFactory let makeLearningViewModel: LearningViewModelFactory let makeCourseFilesViewModel: CourseFilesViewModelFactory @@ -118,6 +119,7 @@ struct RootView: View { today: todayViewModel, assignments: assignmentsViewModel, exams: examsViewModel, + 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 fb6fc8b..596b054 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 @@ -40,6 +41,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 6dbd950..9269e45 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: 460 case .schedule: 260 case .studIP: 400 case .notifications: 290 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 dbbaa19..89c6357 100644 --- a/Presentation/Today/TodayView.swift +++ b/Presentation/Today/TodayView.swift @@ -6,6 +6,7 @@ struct TodayView: View { @Bindable var today: TodayViewModel @Bindable var assignments: AssignmentsViewModel @Bindable var exams: ExamsViewModel + let mensa: MensaViewModel let onOpenModule: (UUID, ModuleSection, ModuleIntent?) -> Void let onShowCalendar: () -> Void let onCreateModule: () -> Void @@ -38,6 +39,7 @@ struct TodayView: View { .task { today.load() exams.load() + await mensa.refresh() } } @@ -64,6 +66,9 @@ struct TodayView: View { onOpenModule(overview.module.id, .exam, nil) } } + if mensa.isEnabled { + MensaCard(viewModel: mensa) + } } } From ce8b8e266010b62eb48580e4ce9a6f228d5c4910 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 10:53:24 +0000 Subject: [PATCH 5/5] docs(mensa): describe the canteen menu Refs #43 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TmmqmKmX8VDSfoPbzTS1ew --- docs/FEATURES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 264d3a7..eaf6d0c 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) +## 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).