Skip to content

Commit eb05276

Browse files
committed
feat: require exact mensa selection
1 parent 0f4d55e commit eb05276

32 files changed

Lines changed: 937 additions & 31 deletions

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ Important runtime behavior:
4343
- View model: `lib/canteen/ui/viewmodels/canteen_view_model.dart`
4444
- Page: `lib/canteen/ui/canteen_page.dart`
4545
- Provider/repository: `lib/canteen/business/canteen_provider.dart`, `lib/canteen/data/canteen_meal_repository.dart`
46+
- Users must choose an exact mensa during onboarding before finishing; the same selection can be changed later in Settings.
47+
- Karlsruhe keeps the existing Studierendenwerk Karlsruhe scraper/model path (`Mensa Erzbergerstrasse`); non-Karlsruhe supported locations currently use the OpenMensa path.
48+
- The active mensa is stored as a single selected-location cache; widgets/background refresh follow that active selection.
4649
- Scraping/parsing offloads heavy parsing to isolate path in service layer.
4750
- UI is day-based with visible-content-day bounds (recent fixes prevent invalid swipe pages/overscroll edge issues).
4851

docs/canteen-feature.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,12 @@ The canteen feature provides a performant, swipeable daily menu for the DHBW Kar
2626
- Widget: `android/app/src/main/kotlin/com/fariszr/dualmate/widget/canteen` + `android/app/src/main/res/layout/widget_canteen_today.xml`
2727

2828
## Data Source
29-
- Provider: Studierendenwerk Karlsruhe
30-
- URL: `https://www.sw-ka.de/de/hochschulgastronomie/speiseplan/mensa_erzberger/?kw=<week>`
31-
- Week calculation uses ISO week numbers (see `_isoWeekNumber()` in `CanteenScraper`).
29+
- On first onboarding completion, users must choose the exact mensa to use.
30+
- Karlsruhe remains on the legacy Studierendenwerk Karlsruhe scraper path:
31+
- URL: `https://www.sw-ka.de/de/hochschulgastronomie/speiseplan/mensa_erzberger/?kw=<week>`
32+
- Week calculation uses ISO week numbers (see `_isoWeekNumber()` in `CanteenScraper`).
33+
- Other currently supported locations use OpenMensa day endpoints through `OpenMensaCanteenSource`.
34+
- Settings can change the selected mensa later; switching locations clears the active cached meal table and repopulates it for the newly selected location.
3235

3336
## Parsing Rules
3437
- Days are read from `#canteen_day_nav_1..5` using their `rel` date attribute.
@@ -65,6 +68,7 @@ To avoid frame drops while loading a new week:
6568
- The UI shows animated skeleton cards while a week is loading.
6669
- The day list switches from skeleton to real content using a short fade.
6770
- Chips are not rendered in the list (notes are shown as a lightweight text row).
71+
- The selected mensa remains the single active cache source for the page, widget, and background updater.
6872

6973
### Filters
7074
The filter dropdown (top-right) uses `CanteenFilter` to filter the list in-memory, without re-querying the network or database.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
module: Canteen UI
3+
date: 2026-06-01
4+
problem_type: ui_bug
5+
component: onboarding_settings
6+
symptoms:
7+
- "Users could finish onboarding without choosing an exact mensa"
8+
- "The canteen feature stayed hardcoded to Karlsruhe even when supporting other mensas"
9+
- "Canteen setup UI did not match the schedule onboarding/setup design"
10+
root_cause: canteen_selection_was_not_modeled_as_required_app_configuration_and_the_data_source_was_hardcoded_to_karlsruhe
11+
resolution_type: code_fix
12+
severity: medium
13+
tags: [canteen, mensa, onboarding, settings, karlsruhe, openmensa, flutter]
14+
---
15+
16+
# Troubleshooting: Required exact mensa selection
17+
18+
## Problem
19+
The app could not support exact mensa selection end to end. Onboarding had no required mensa step, Settings could not reconfigure the canteen source, and the canteen backend was still hardcoded to Karlsruhe.
20+
21+
## Root Cause
22+
The canteen stack only had one implicit source: the Karlsruhe scraper path. There was no persisted selected-location concept, so onboarding and Settings could not drive the runtime provider or widget/background cache behavior.
23+
24+
## Solution
25+
- Added a persisted selected canteen location via `PreferencesProvider` and `CanteenLocationService`.
26+
- Added a required onboarding mensa step that intentionally matches the schedule setup page structure.
27+
- Added a Settings dialog to reselect the exact mensa later.
28+
- Kept Karlsruhe on the existing scraper/model path exactly as before.
29+
- Added an OpenMensa-backed source for the newly supported non-Karlsruhe locations.
30+
- Kept the SQLite/widget cache model minimal by treating the selected mensa as the single active canteen cache and clearing cached meals when the selection changes.
31+
32+
## Test Coverage
33+
- `required canteen step cannot be skipped when invalid`
34+
- `refreshWeek uses OpenMensa for non-Karlsruhe locations`
35+
- existing canteen page bounds, visible-days, and startup loading policy suites updated for the new location service path
36+
37+
## Commands run
38+
```bash
39+
flutter test test/canteen/business/canteen_provider_refresh_policy_test.dart test/canteen/ui/viewmodels/canteen_visible_days_test.dart test/canteen/ui/viewmodels/canteen_startup_loading_policy_test.dart test/canteen/ui/canteen_page_bounds_test.dart test/ui/onboarding/onboarding_required_canteen_step_test.dart
40+
flutter analyze lib/canteen lib/ui/onboarding lib/ui/settings lib/common/appstart/service_injector.dart test/canteen test/ui/onboarding/onboarding_required_canteen_step_test.dart
41+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import 'package:dualmate/canteen/model/canteen_location.dart';
2+
import 'package:dualmate/common/data/preferences/preferences_provider.dart';
3+
4+
class CanteenLocationService {
5+
final PreferencesProvider _preferencesProvider;
6+
7+
CanteenLocationService(this._preferencesProvider);
8+
9+
Future<CanteenLocation?> getConfiguredLocation() async {
10+
final id = await _preferencesProvider.getSelectedCanteenLocationId();
11+
if (id == null || id.isEmpty) {
12+
return null;
13+
}
14+
15+
return CanteenLocations.fromId(id);
16+
}
17+
18+
Future<CanteenLocation> getSelectedLocation() async {
19+
return await getConfiguredLocation() ?? CanteenLocations.defaultLocation;
20+
}
21+
22+
Future<void> setSelectedLocation(CanteenLocation location) async {
23+
await _preferencesProvider.setSelectedCanteenLocationId(location.id);
24+
}
25+
26+
List<CanteenLocation> supportedLocations() {
27+
return List<CanteenLocation>.unmodifiable(CanteenLocations.supported);
28+
}
29+
}

lib/canteen/business/canteen_provider.dart

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import 'dart:async';
22

33
import 'package:dualmate/canteen/data/canteen_meal_repository.dart';
4+
import 'package:dualmate/canteen/model/canteen_location.dart';
45
import 'package:dualmate/canteen/model/daily_menu.dart';
56
import 'package:dualmate/canteen/model/meal.dart';
7+
import 'package:dualmate/canteen/business/canteen_location_service.dart';
68
import 'package:dualmate/canteen/service/canteen_scraper.dart';
9+
import 'package:dualmate/canteen/service/open_mensa_canteen_source.dart';
710
import 'package:dualmate/common/util/cancellation_token.dart';
811
import 'package:dualmate/common/util/date_utils.dart';
912

@@ -15,12 +18,20 @@ typedef CanteenMenuUpdatedCallback = Future<void> Function(
1518

1619
class CanteenProvider {
1720
final CanteenMealRepository _repository;
21+
final CanteenLocationService _locationService;
1822
final CanteenScraper _scraper;
23+
final OpenMensaCanteenSource _openMensaSource;
1924
final List<CanteenMenuUpdatedCallback> _callbacks = [];
2025
final Map<DateTime, Future<List<DailyMenu>>> _refreshInFlight = {};
2126
final Map<DateTime, DateTime> _lastRefreshAtByWeek = {};
27+
String? _activeLocationId;
2228

23-
CanteenProvider(this._repository, this._scraper);
29+
CanteenProvider(
30+
this._repository,
31+
this._locationService,
32+
this._scraper,
33+
this._openMensaSource,
34+
);
2435

2536
void addMenuUpdatedCallback(CanteenMenuUpdatedCallback callback) {
2637
_callbacks.add(callback);
@@ -31,6 +42,7 @@ class CanteenProvider {
3142
}
3243

3344
Future<List<DailyMenu>> getCachedWeek(DateTime date) async {
45+
await _ensureActiveLocationCache();
3446
var weekStart = toStartOfDay(toMonday(date));
3547
var weekEnd = weekStart.add(const Duration(days: 5));
3648

@@ -39,6 +51,7 @@ class CanteenProvider {
3951
}
4052

4153
Future<DateTime?> lastUpdatedForWeek(DateTime date) async {
54+
await _ensureActiveLocationCache();
4255
var weekStart = toStartOfDay(toMonday(date));
4356
var weekEnd = weekStart.add(const Duration(days: 5));
4457
return _repository.latestMealDateBetween(weekStart, weekEnd);
@@ -111,9 +124,14 @@ class CanteenProvider {
111124
CancellationToken? cancellationToken,
112125
required bool prefetchNextWeek,
113126
}) async {
127+
final location = await _ensureActiveLocationCache();
114128
var weekEnd = weekStart.add(const Duration(days: 5));
115129

116-
var menus = await _scraper.loadWeek(weekStart, cancellationToken);
130+
var menus = await _loadWeekForLocation(
131+
location,
132+
weekStart,
133+
cancellationToken,
134+
);
117135
var normalizedMenus = _normalizeMenus(weekStart, menus);
118136

119137
await _repository.deleteMealsBetween(weekStart, weekEnd);
@@ -135,11 +153,16 @@ class CanteenProvider {
135153
DateTime weekStart,
136154
CancellationToken? cancellationToken,
137155
) async {
156+
final location = await _ensureActiveLocationCache();
138157
var nextWeekStart = toStartOfDay(weekStart.add(const Duration(days: 7)));
139158
var nextWeekEnd = nextWeekStart.add(const Duration(days: 5));
140159

141160
try {
142-
var nextMenus = await _scraper.loadWeek(nextWeekStart, cancellationToken);
161+
var nextMenus = await _loadWeekForLocation(
162+
location,
163+
nextWeekStart,
164+
cancellationToken,
165+
);
143166
var normalizedNextMenus = _normalizeMenus(nextWeekStart, nextMenus);
144167

145168
await _repository.deleteMealsBetween(nextWeekStart, nextWeekEnd);
@@ -164,6 +187,36 @@ class CanteenProvider {
164187
}
165188
}
166189

190+
Future<CanteenLocation> _ensureActiveLocationCache() async {
191+
final selected = await _locationService.getSelectedLocation();
192+
if (_activeLocationId == selected.id) {
193+
return selected;
194+
}
195+
196+
_activeLocationId = selected.id;
197+
_refreshInFlight.clear();
198+
_lastRefreshAtByWeek.clear();
199+
await _repository.clearMeals();
200+
return selected;
201+
}
202+
203+
Future<List<DailyMenu>> _loadWeekForLocation(
204+
CanteenLocation location,
205+
DateTime weekStart,
206+
CancellationToken? cancellationToken,
207+
) {
208+
if (location.isKarlsruheLegacy) {
209+
return _scraper.loadWeek(weekStart, cancellationToken);
210+
}
211+
212+
final openMensaId = location.openMensaId;
213+
if (openMensaId == null) {
214+
throw Exception('Missing OpenMensa id for selected canteen');
215+
}
216+
217+
return _openMensaSource.loadWeek(openMensaId, weekStart, cancellationToken);
218+
}
219+
167220
List<DailyMenu> _groupMealsByDay(DateTime weekStart, List<Meal> meals) {
168221
var mealsByDay = <DateTime, List<Meal>>{};
169222

lib/canteen/data/canteen_meal_repository.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,4 +72,8 @@ class CanteenMealRepository {
7272
],
7373
);
7474
}
75+
76+
Future<void> clearMeals() async {
77+
await _database.deleteWhere(CanteenMealEntity.tableName());
78+
}
7579
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
class CanteenLocation {
2+
final String id;
3+
final String name;
4+
final String? subtitle;
5+
final CanteenLocationSource source;
6+
final int? openMensaId;
7+
8+
const CanteenLocation({
9+
required this.id,
10+
required this.name,
11+
required this.source,
12+
this.subtitle,
13+
this.openMensaId,
14+
});
15+
16+
bool get isKarlsruheLegacy => source == CanteenLocationSource.karlsruheLegacy;
17+
}
18+
19+
enum CanteenLocationSource {
20+
karlsruheLegacy,
21+
openMensa,
22+
}
23+
24+
class CanteenLocations {
25+
static const String karlsruheId = 'karlsruhe_erzbergerstrasse';
26+
27+
static const List<CanteenLocation> supported = <CanteenLocation>[
28+
CanteenLocation(
29+
id: karlsruheId,
30+
name: 'DHBW Karlsruhe',
31+
subtitle: 'Mensa Erzbergerstrasse',
32+
source: CanteenLocationSource.karlsruheLegacy,
33+
),
34+
CanteenLocation(
35+
id: 'mannheim_dhbw_eppelheim',
36+
name: 'DHBW Mannheim',
37+
subtitle: 'Mensa DHBW Eppelheim',
38+
source: CanteenLocationSource.openMensa,
39+
openMensaId: 795,
40+
),
41+
CanteenLocation(
42+
id: 'horb_dhbw_stuttgart',
43+
name: 'DHBW Horb',
44+
subtitle: 'Campus Horb',
45+
source: CanteenLocationSource.openMensa,
46+
openMensaId: 923,
47+
),
48+
];
49+
50+
static const CanteenLocation defaultLocation = CanteenLocation(
51+
id: karlsruheId,
52+
name: 'DHBW Karlsruhe',
53+
subtitle: 'Mensa Erzbergerstrasse',
54+
source: CanteenLocationSource.karlsruheLegacy,
55+
);
56+
57+
static CanteenLocation fromId(String? id) {
58+
if (id == null || id.isEmpty) {
59+
return defaultLocation;
60+
}
61+
62+
for (final location in supported) {
63+
if (location.id == id) {
64+
return location;
65+
}
66+
}
67+
68+
return defaultLocation;
69+
}
70+
}

0 commit comments

Comments
 (0)