diff --git a/flutter_app/lib/src/campus_models.dart b/flutter_app/lib/src/campus_models.dart index fb6f7c1..e5697cc 100644 --- a/flutter_app/lib/src/campus_models.dart +++ b/flutter_app/lib/src/campus_models.dart @@ -34,6 +34,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, @@ -70,6 +77,71 @@ 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, + }), + ); +} + DateTime _weekStart(DateTime day) { final date = DateTime(day.year, day.month, day.day); return date.subtract(Duration(days: date.weekday - DateTime.monday)); diff --git a/flutter_app/lib/src/views/campus_view.dart b/flutter_app/lib/src/views/campus_view.dart index 2d2d2a3..6549f93 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'; @@ -105,7 +107,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), ], ); }, @@ -116,13 +119,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), @@ -135,20 +142,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), @@ -161,15 +182,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 bdf670a..212088b 100644 --- a/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import flutter_secure_storage_darwin import flutter_tts +import share_plus import shared_preferences_foundation import speech_to_text import url_launcher_macos @@ -14,6 +15,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) diff --git a/flutter_app/pubspec.lock b/flutter_app/pubspec.lock index c6682e7..b702f98 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: @@ -400,6 +408,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: @@ -520,6 +536,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 4308731..e23af0d 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 speech_to_text: ^7.4.0 flutter_tts: ^4.2.5 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/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); diff --git a/flutter_app/windows/flutter/generated_plugin_registrant.cc b/flutter_app/windows/flutter/generated_plugin_registrant.cc index 9de7993..16e8fcf 100644 --- a/flutter_app/windows/flutter/generated_plugin_registrant.cc +++ b/flutter_app/windows/flutter/generated_plugin_registrant.cc @@ -8,6 +8,7 @@ #include #include +#include #include #include @@ -16,6 +17,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); FlutterTtsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterTtsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); SpeechToTextWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("SpeechToTextWindows")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/flutter_app/windows/flutter/generated_plugins.cmake b/flutter_app/windows/flutter/generated_plugins.cmake index 2ec0ab2..35952aa 100644 --- a/flutter_app/windows/flutter/generated_plugins.cmake +++ b/flutter_app/windows/flutter/generated_plugins.cmake @@ -5,6 +5,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_windows flutter_tts + share_plus speech_to_text_windows url_launcher_windows )