From 2dbbfcf080a2a2d4f9ccdbf771394eefac852ed5 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Thu, 2 Jul 2026 13:34:40 +0000 Subject: [PATCH 1/2] feat(campus): improve mensa menu cards --- flutter_app/lib/src/campus_models.dart | 74 ++++++++++++ flutter_app/lib/src/views/campus_view.dart | 109 +++++++++++++++--- .../Flutter/GeneratedPluginRegistrant.swift | 2 + flutter_app/pubspec.lock | 32 +++++ flutter_app/pubspec.yaml | 1 + flutter_app/test/campus_client_test.dart | 73 ++++++++++++ .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 8 files changed, 279 insertions(+), 16 deletions(-) diff --git a/flutter_app/lib/src/campus_models.dart b/flutter_app/lib/src/campus_models.dart index 8bd8956..c3881a9 100644 --- a/flutter_app/lib/src/campus_models.dart +++ b/flutter_app/lib/src/campus_models.dart @@ -21,6 +21,13 @@ class CampusCanteen { } } +class CampusMenuEntry { + const CampusMenuEntry({required this.canteen, required this.menu}); + + final CampusCanteen canteen; + final CampusMenu menu; +} + class CampusMenu { const CampusMenu({ required this.id, @@ -38,6 +45,8 @@ class CampusMenu { final List icons; final String? studentPrice; + DateTime? get parsedDate => DateTime.tryParse(date); + bool matches(FoodPreference preference) { final normalized = icons.map((icon) => icon.toLowerCase()).toSet(); return switch (preference) { @@ -50,3 +59,68 @@ class CampusMenu { }; } } + +List sortedCampusMenuEntries(List canteens) { + final entries = [ + for (final canteen in canteens) + for (final menu in canteen.menus) + CampusMenuEntry(canteen: canteen, menu: menu), + ]; + entries.sort((a, b) { + final aDate = a.menu.parsedDate; + final bDate = b.menu.parsedDate; + if (aDate == null && bDate != null) return 1; + if (aDate != null && bDate == null) return -1; + if (aDate != null && bDate != null) { + final dateCompare = aDate.compareTo(bDate); + if (dateCompare != 0) return dateCompare; + } + final canteenCompare = a.canteen.name.compareTo(b.canteen.name); + if (canteenCompare != 0) return canteenCompare; + return a.menu.line.compareTo(b.menu.line); + }); + return entries; +} + +class CampusCanteenAction { + const CampusCanteenAction({required this.website, required this.navigation}); + + final Uri website; + final Uri navigation; +} + +CampusCanteenAction? actionForCanteen(CampusCanteen canteen) { + final normalized = canteen.name.toLowerCase(); + if (normalized.contains('wilhelm')) { + return _action( + website: 'https://www.my-stuwe.de/mensa/mensa-wilhelmstrasse-tuebingen/', + destination: 'Mensa Wilhelmstraße Tübingen', + ); + } + if (normalized.contains('morgenstelle')) { + return _action( + website: 'https://www.my-stuwe.de/mensa/mensa-morgenstelle-tuebingen/', + destination: 'Mensa Morgenstelle Tübingen', + ); + } + if (normalized.contains('prinz')) { + return _action( + website: 'https://www.my-stuwe.de/mensa/mensa-prinz-karl-tuebingen/', + destination: 'Mensa Prinz Karl Tübingen', + ); + } + return null; +} + +CampusCanteenAction _action({ + required String website, + required String destination, +}) { + return CampusCanteenAction( + website: Uri.parse(website), + navigation: Uri.https('www.google.com', '/maps/dir/', { + 'api': '1', + 'destination': destination, + }), + ); +} diff --git a/flutter_app/lib/src/views/campus_view.dart b/flutter_app/lib/src/views/campus_view.dart index dc606a5..2865e6c 100644 --- a/flutter_app/lib/src/views/campus_view.dart +++ b/flutter_app/lib/src/views/campus_view.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../campus_client.dart'; import '../campus_models.dart'; @@ -101,7 +103,8 @@ class _CampusViewState extends State { } return Column( children: [ - for (final canteen in canteens) _CanteenCard(canteen: canteen), + for (final entry in sortedCampusMenuEntries(canteens)) + _CanteenCard(entry: entry), ], ); }, @@ -112,13 +115,17 @@ class _CampusViewState extends State { } class _CanteenCard extends StatelessWidget { - const _CanteenCard({required this.canteen}); + const _CanteenCard({required this.entry}); - final CampusCanteen canteen; + final CampusMenuEntry entry; @override Widget build(BuildContext context) { - final menu = canteen.menus.first; + final canteen = entry.canteen; + final menu = entry.menu; + final action = actionForCanteen(canteen); + final foodName = menu.items.first; + final additionalItems = menu.items.skip(1).toList(); return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: StudyOsSpacing.md), @@ -131,20 +138,34 @@ class _CanteenCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(canteen.name, style: Theme.of(context).textTheme.titleMedium), + Text( + canteen.name, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: StudyOsColors.textMuted, + fontWeight: FontWeight.w400, + ), + ), const SizedBox(height: StudyOsSpacing.xs), Text( - [ - menu.date, - menu.line, - _priceLabel(menu), - ].where((part) => part.isNotEmpty).join(' · '), - style: Theme.of(context).textTheme.bodyMedium, + _headline(foodName: foodName, menu: menu), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + height: 1.2, + ), ), - const SizedBox(height: StudyOsSpacing.md), + if (additionalItems.isNotEmpty) ...[ + const SizedBox(height: StudyOsSpacing.sm), + Text( + additionalItems.join('\n'), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + const SizedBox(height: StudyOsSpacing.sm), Text( - menu.items.join('\n'), - style: Theme.of(context).textTheme.bodyLarge, + menu.date, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: StudyOsColors.textMuted), ), if (menu.icons.isNotEmpty) ...[ const SizedBox(height: StudyOsSpacing.md), @@ -157,15 +178,71 @@ class _CanteenCard extends StatelessWidget { ], ), ], + if (action != null) ...[ + const SizedBox(height: StudyOsSpacing.md), + Align( + alignment: Alignment.centerRight, + child: Wrap( + spacing: StudyOsSpacing.xs, + children: [ + IconButton( + tooltip: 'Open Mensa website', + onPressed: () => _open(action.website), + icon: const Icon(Icons.public_rounded), + ), + IconButton( + tooltip: 'Share menu', + onPressed: () => _share(entry, action.website), + icon: const Icon(Icons.share_rounded), + ), + IconButton( + tooltip: 'Navigate to Mensa', + onPressed: () => _open(action.navigation), + icon: const Icon(Icons.navigation_rounded), + ), + ], + ), + ), + ], ], ), ); } - String _priceLabel(CampusMenu menu) { + String _headline({required String foodName, required CampusMenu menu}) { + return [ + foodName, + menu.line, + _priceLabel(menu), + ].where((part) => part.isNotEmpty).join(' - '); + } + + static String _priceLabel(CampusMenu menu) { final price = menu.studentPrice; if (price == null || price.isEmpty) return ''; - return '$price Euro'; + return '$price €'; + } + + static Future _open(Uri uri) async { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + static Future _share(CampusMenuEntry entry, Uri website) async { + final menu = entry.menu; + final foodName = menu.items.first; + final additionalItems = menu.items.skip(1).toList(); + final lines = [ + entry.canteen.name, + [ + menu.date, + foodName, + menu.line, + _priceLabel(menu), + ].where((part) => part.isNotEmpty).join(' - '), + if (additionalItems.isNotEmpty) additionalItems.join(', '), + website.toString(), + ]; + await SharePlus.instance.share(ShareParams(text: lines.join('\n'))); } } diff --git a/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift index 5551f43..465ec1f 100644 --- a/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import flutter_secure_storage_darwin +import share_plus import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index 13b7378..e2c9b66 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -65,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -384,6 +392,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" objective_c: dependency: transitive description: @@ -504,6 +520,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "9eee8283462d91a7a1c8bdb67d08874abd75a2f8fae3bc0ca033035e375fb3d8" + url: "https://pub.dev" + source: hosted + version: "13.2.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "7f7ae28cf400d13f811e297ff37742dba83b79e0a6f5dce14eec0248274e6ce9" + url: "https://pub.dev" + source: hosted + version: "7.1.0" shared_preferences: dependency: "direct main" description: diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 1395fc0..4b0b973 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: flutter_map: ^8.2.2 latlong2: ^0.9.1 url_launcher: ^6.3.2 + share_plus: ^13.2.0 dev_dependencies: flutter_test: diff --git a/flutter_app/test/campus_client_test.dart b/flutter_app/test/campus_client_test.dart index 2ae102e..25ac01f 100644 --- a/flutter_app/test/campus_client_test.dart +++ b/flutter_app/test/campus_client_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:studyos_agent/src/campus_client.dart'; +import 'package:studyos_agent/src/campus_models.dart'; import 'package:studyos_agent/src/student_profile.dart'; void main() { @@ -42,4 +43,76 @@ void main() { expect(filtered.menus.single.items, contains('Cous-Cous')); expect(filtered.menus.single.studentPrice, '3,70'); }); + + test('campus menus are flattened and sorted by closest date first', () { + final canteens = [ + CampusCanteen( + id: '621', + name: 'Mensa Morgenstelle', + menus: [ + _menu(id: 'later', date: '2026-07-07', item: 'Pasta'), + ], + ), + CampusCanteen( + id: '611', + name: 'Mensa Wilhelmstraße', + menus: [ + _menu(id: 'soon', date: '2026-07-03', item: 'Curry'), + _menu(id: 'unknown', date: '', item: 'Salad'), + ], + ), + ]; + + final entries = sortedCampusMenuEntries(canteens); + + expect(entries.map((entry) => entry.menu.id), [ + 'soon', + 'later', + 'unknown', + ]); + }); + + test('known Tübingen canteens expose website and navigation actions', () { + final wilhelm = actionForCanteen( + CampusCanteen( + id: '611', + name: 'Mensa Wilhelmstraße', + menus: [_menu(id: '1', date: '2026-07-03')], + ), + ); + final morgenstelle = actionForCanteen( + CampusCanteen( + id: '621', + name: 'Mensa Morgenstelle', + menus: [_menu(id: '2', date: '2026-07-03')], + ), + ); + final prinzKarl = actionForCanteen( + CampusCanteen( + id: '623', + name: 'Mensa Prinz Karl', + menus: [_menu(id: '3', date: '2026-07-03')], + ), + ); + + expect(wilhelm?.website.toString(), contains('mensa-wilhelmstrasse')); + expect(morgenstelle?.website.toString(), contains('mensa-morgenstelle')); + expect(prinzKarl?.website.toString(), contains('mensa-prinz-karl')); + expect(wilhelm?.navigation.host, 'www.google.com'); + }); +} + +CampusMenu _menu({ + required String id, + required String date, + String item = 'Meal', +}) { + return CampusMenu( + id: id, + line: 'Tagesmenü', + date: date, + items: [item], + icons: const [], + studentPrice: '3,70', + ); } diff --git a/flutter_app/windows/flutter/generated_plugin_registrant.cc b/flutter_app/windows/flutter/generated_plugin_registrant.cc index 2048c45..be05421 100644 --- a/flutter_app/windows/flutter/generated_plugin_registrant.cc +++ b/flutter_app/windows/flutter/generated_plugin_registrant.cc @@ -7,11 +7,14 @@ #include "generated_plugin_registrant.h" #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter_app/windows/flutter/generated_plugins.cmake b/flutter_app/windows/flutter/generated_plugins.cmake index e17c858..a702342 100644 --- a/flutter_app/windows/flutter/generated_plugins.cmake +++ b/flutter_app/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows + share_plus url_launcher_windows ) From 5e986ac061f71972590496e20d745e0f5f16f91c Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 3 Jul 2026 08:58:07 +0000 Subject: [PATCH 2/2] test(campus): match mensa headline text --- flutter_app/test/campus_view_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter_app/test/campus_view_test.dart b/flutter_app/test/campus_view_test.dart index 15d356d..0a2f081 100644 --- a/flutter_app/test/campus_view_test.dart +++ b/flutter_app/test/campus_view_test.dart @@ -63,7 +63,7 @@ void main() { expect(find.text('This week\'s Mensa menus for Tübingen.'), findsOneWidget); expect(find.text('Mensa Morgenstelle'), findsOneWidget); expect(find.textContaining('2026-06-30'), findsOneWidget); - expect(find.text('Bandnudeln'), findsOneWidget); + expect(find.textContaining('Bandnudeln'), findsOneWidget); expect(find.text('Cafeteria Wilhelmstraße'), findsNothing); expect(find.textContaining('2026-07-07'), findsNothing); expect(find.text('Hausgemachte Pasta'), findsNothing);