Skip to content
6 changes: 6 additions & 0 deletions App/AppContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +56,7 @@ final class AppContainer {
studIPService = studIPClient
studIPAnnouncementService = studIPClient
studIPScheduleService = studIPClient
canteenService = OpenMensaClient()
if inMemory {
preferences = InMemoryStudyPreferences()
studIPAccountStore = InMemoryStudIPAccountStore()
Expand Down Expand Up @@ -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)
}
Expand Down
8 changes: 7 additions & 1 deletion App/StudyAppApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)")
}
Expand All @@ -106,6 +108,7 @@ struct StudyAppApp: App {
examsViewModel: examsViewModel,
announcementsViewModel: announcementsViewModel,
studyTimerViewModel: studyTimerViewModel,
mensaViewModel: mensaViewModel,
makeSlidesViewModel: { moduleID in
container.makeSlidesViewModel(moduleID: moduleID)
},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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()
}
Expand Down
45 changes: 45 additions & 0 deletions Data/Mensa/OpenMensaClient.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
55 changes: 55 additions & 0 deletions Data/Mensa/OpenMensaParser.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 6 additions & 0 deletions Data/Preferences/UserDefaultsStudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
Expand Down Expand Up @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions Domain/Entities/Mensa.swift
Original file line number Diff line number Diff line change
@@ -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."
}
}
}
10 changes: 10 additions & 0 deletions Domain/Repositories/CanteenService.swift
Original file line number Diff line number Diff line change
@@ -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
}
3 changes: 3 additions & 0 deletions Domain/Repositories/StudyPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
Expand Down Expand Up @@ -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] = [:]
Expand Down
40 changes: 40 additions & 0 deletions Domain/UseCases/MensaUseCases.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading