diff --git a/flutter_app/lib/src/agent_home_page.dart b/flutter_app/lib/src/agent_home_page.dart index 036d1b4..813c5de 100644 --- a/flutter_app/lib/src/agent_home_page.dart +++ b/flutter_app/lib/src/agent_home_page.dart @@ -213,6 +213,14 @@ class _AgentHomePageState extends State _inputController.selection = TextSelection.collapsed(offset: text.length); } + void _prefillChatPrompt(String text) { + setState(() { + _inputController.text = text; + _inputController.selection = TextSelection.collapsed(offset: text.length); + _selectedView = AppView.chat; + }); + } + Future _sendMessage() async { final text = _inputController.text.trim(); if (text.isEmpty || _isSending) return; @@ -298,6 +306,7 @@ class _AgentHomePageState extends State onDeleteSession: _deleteSession, onSuggestionSelected: _useSuggestion, onSend: _sendMessage, + onAskAssistant: _prefillChatPrompt, onLogout: widget.onLogout, onSaveProfile: widget.onSaveProfile, onSaveAgentConfig: _saveAgentConfig, diff --git a/flutter_app/lib/src/map_location_models.dart b/flutter_app/lib/src/map_location_models.dart new file mode 100644 index 0000000..f4b4140 --- /dev/null +++ b/flutter_app/lib/src/map_location_models.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +class MapLocation { + const MapLocation({ + required this.name, + required this.latitude, + required this.longitude, + this.address, + this.category, + this.source = 'nominatim', + }); + + final String name; + final double latitude; + final double longitude; + final String? address; + final String? category; + final String source; + + String get coordinateText => + '${latitude.toStringAsFixed(5)}, ${longitude.toStringAsFixed(5)}'; + + String get mapsQuery => Uri.encodeComponent('$latitude,$longitude ($name)'); + + String assistantPrompt() { + final buffer = StringBuffer() + ..writeln('I selected this destination in StudyOS Maps:') + ..writeln('- Name: $name') + ..writeln('- Coordinates: $coordinateText') + ..writeln('- Source: $source'); + if (address != null && address!.isNotEmpty) { + buffer.writeln('- Address: $address'); + } + if (category != null && category!.isNotEmpty) { + buffer.writeln('- Category: $category'); + } + buffer + ..writeln() + ..writeln( + 'Please explain what is useful to know about this place, how I could get there from my current study context, and any navigation options or caveats.', + ); + return buffer.toString().trim(); + } + + static MapLocation? fromNominatimJson(Map json) { + final lat = double.tryParse(json['lat']?.toString() ?? ''); + final lon = double.tryParse(json['lon']?.toString() ?? ''); + final displayName = json['display_name']?.toString().trim(); + if (lat == null || + lon == null || + displayName == null || + displayName.isEmpty) { + return null; + } + final namedParts = displayName.split(','); + final name = namedParts.first.trim(); + return MapLocation( + name: name.isEmpty ? displayName : name, + latitude: lat, + longitude: lon, + address: displayName, + category: json['type']?.toString(), + ); + } +} + +List mapLocationsFromNominatim(String body) { + final decoded = jsonDecode(body); + if (decoded is! List) return const []; + return decoded + .whereType() + .map( + (item) => + MapLocation.fromNominatimJson(Map.from(item)), + ) + .whereType() + .toList(growable: false); +} diff --git a/flutter_app/lib/src/map_search_client.dart b/flutter_app/lib/src/map_search_client.dart new file mode 100644 index 0000000..afe8422 --- /dev/null +++ b/flutter_app/lib/src/map_search_client.dart @@ -0,0 +1,50 @@ +import 'package:http/http.dart' as http; + +import 'map_location_models.dart'; + +class MapSearchClient { + MapSearchClient({http.Client? client}) + : _client = client ?? http.Client(), + _ownsClient = client == null; + + final http.Client _client; + final bool _ownsClient; + + Future> search(String query) async { + final trimmed = query.trim(); + if (trimmed.isEmpty) return const []; + final uri = Uri.https('nominatim.openstreetmap.org', '/search', { + 'format': 'jsonv2', + 'limit': '8', + 'addressdetails': '1', + 'bounded': '1', + 'viewbox': '8.93,48.57,9.16,48.47', + 'q': '$trimmed Tuebingen', + }); + final response = await _client.get( + uri, + headers: const { + 'User-Agent': 'StudyOS Agent course prototype', + }, + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw MapSearchException( + 'Search failed with HTTP ${response.statusCode}.', + ); + } + return mapLocationsFromNominatim(response.body); + } + + void close() { + if (_ownsClient) _client.close(); + } +} + +class MapSearchException implements Exception { + const MapSearchException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/flutter_app/lib/src/models.dart b/flutter_app/lib/src/models.dart index 0d17fde..e62e7d3 100644 --- a/flutter_app/lib/src/models.dart +++ b/flutter_app/lib/src/models.dart @@ -60,7 +60,7 @@ class ChatMessage { } } -enum AppView { home, chat, schedule, mail, campus, memories, settings } +enum AppView { home, chat, schedule, mail, maps, campus, memories, settings } enum AgentProvider { local, cloud } diff --git a/flutter_app/lib/src/views/agent_selected_view.dart b/flutter_app/lib/src/views/agent_selected_view.dart index 81a5e00..7a99c1a 100644 --- a/flutter_app/lib/src/views/agent_selected_view.dart +++ b/flutter_app/lib/src/views/agent_selected_view.dart @@ -7,6 +7,7 @@ import 'campus_view.dart'; import 'chat_view.dart'; import 'home_view.dart'; import 'mail_view.dart'; +import 'maps_view.dart'; import 'memories_view.dart'; import 'schedule_view.dart'; import 'settings_view.dart'; @@ -22,6 +23,7 @@ class AgentSelectedView extends StatelessWidget { required this.compactMessages, required this.onSuggestionSelected, required this.onSend, + required this.onAskAssistant, required this.onSelectView, required this.worldState, required this.memoryText, @@ -50,6 +52,7 @@ class AgentSelectedView extends StatelessWidget { final bool compactMessages; final ValueChanged onSuggestionSelected; final VoidCallback onSend; + final ValueChanged onAskAssistant; final ValueChanged onSelectView; final Map worldState; final String memoryText; @@ -77,6 +80,7 @@ class AgentSelectedView extends StatelessWidget { memoryText: memoryText, timetable: timetable, onOpenMail: () => onSelectView(AppView.mail), + onOpenMaps: () => onSelectView(AppView.maps), onOpenCampus: () => onSelectView(AppView.campus), onOpenSchedule: () => onSelectView(AppView.schedule), ), @@ -97,6 +101,7 @@ class AgentSelectedView extends StatelessWidget { onRefresh: onRefreshTimetable, ), AppView.mail => MailView(profile: profile), + AppView.maps => MapsView(onAskAssistant: onAskAssistant), AppView.campus => CampusView(profile: profile), AppView.memories => MemoriesView( worldState: worldState, diff --git a/flutter_app/lib/src/views/home_view.dart b/flutter_app/lib/src/views/home_view.dart index c356bfa..1b7bc0f 100644 --- a/flutter_app/lib/src/views/home_view.dart +++ b/flutter_app/lib/src/views/home_view.dart @@ -11,6 +11,7 @@ class HomeView extends StatelessWidget { required this.memoryText, required this.timetable, required this.onOpenMail, + required this.onOpenMaps, required this.onOpenCampus, required this.onOpenSchedule, super.key, @@ -21,6 +22,7 @@ class HomeView extends StatelessWidget { final String memoryText; final TimetableSnapshot? timetable; final VoidCallback onOpenMail; + final VoidCallback onOpenMaps; final VoidCallback onOpenCampus; final VoidCallback onOpenSchedule; @@ -69,6 +71,11 @@ class HomeView extends StatelessWidget { label: 'Mail', value: profile == null ? 'Sign in needed' : 'Local tools ready', ), + const _HomeStatusItem( + icon: Icons.map_outlined, + label: 'Map', + value: 'Tübingen', + ), ], ), const SizedBox(height: StudyOsSpacing.lg), @@ -86,6 +93,14 @@ class HomeView extends StatelessWidget { onTap: onOpenMail, ), const SizedBox(height: StudyOsSpacing.md), + _HomeCard( + key: const ValueKey('home-maps-card'), + icon: Icons.map_outlined, + title: 'Navigate', + body: 'Search destinations and ask StudyOS about routes.', + onTap: onOpenMaps, + ), + const SizedBox(height: StudyOsSpacing.md), _HomeCard( key: const ValueKey('home-campus-card'), icon: Icons.restaurant_outlined, diff --git a/flutter_app/lib/src/views/maps_view.dart b/flutter_app/lib/src/views/maps_view.dart new file mode 100644 index 0000000..6e31fdd --- /dev/null +++ b/flutter_app/lib/src/views/maps_view.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../map_location_models.dart'; +import '../map_search_client.dart'; +import '../studyos_theme.dart'; +import '../widgets/maps_controls.dart'; + +class MapsView extends StatefulWidget { + const MapsView({required this.onAskAssistant, this.searchClient, super.key}); + + final ValueChanged onAskAssistant; + final MapSearchClient? searchClient; + + @override + State createState() => _MapsViewState(); +} + +class _MapsViewState extends State { + static const _tuebingen = LatLng(48.5216, 9.0576); + + late final TextEditingController _searchController; + late final MapController _mapController; + late final MapSearchClient _searchClient; + List _results = const []; + MapLocation? _selectedLocation; + bool _isSearching = false; + String? _searchError; + bool _hasSearched = false; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + _mapController = MapController(); + _searchClient = widget.searchClient ?? MapSearchClient(); + } + + @override + void dispose() { + _searchController.dispose(); + _searchClient.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Maps', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: StudyOsSpacing.xs), + Text( + 'Search destinations around Tübingen and ask StudyOS for local context.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: StudyOsSpacing.md), + MapSearchBar( + controller: _searchController, + isSearching: _isSearching, + onSearch: _search, + ), + const SizedBox(height: StudyOsSpacing.md), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(StudyOsRadii.lg), + child: Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: const MapOptions( + initialCenter: _tuebingen, + initialZoom: 13.5, + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.studyos.studyos_agent', + ), + MarkerLayer(markers: _markers), + ], + ), + Positioned( + left: StudyOsSpacing.sm, + bottom: StudyOsSpacing.sm, + child: DecoratedBox( + decoration: BoxDecoration( + color: StudyOsColors.background.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + border: Border.all(color: StudyOsColors.border), + ), + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: StudyOsSpacing.xs, + ), + child: Text( + '© OpenStreetMap contributors', + style: TextStyle( + color: StudyOsColors.textMuted, + fontSize: 11, + ), + ), + ), + ), + ), + Positioned( + left: StudyOsSpacing.md, + right: StudyOsSpacing.md, + top: StudyOsSpacing.md, + child: MapOverlay( + results: _results, + selectedLocation: _selectedLocation, + isSearching: _isSearching, + searchError: _searchError, + hasSearched: _hasSearched, + onSelect: _selectLocation, + onAskAssistant: _askAssistant, + onOpenExternalMaps: _openExternalMaps, + ), + ), + ], + ), + ), + ), + ], + ); + } + + List get _markers { + final selected = _selectedLocation; + if (selected == null) return const []; + return [ + Marker( + point: LatLng(selected.latitude, selected.longitude), + width: 44, + height: 44, + child: const Icon( + Icons.location_on_rounded, + color: StudyOsColors.accent, + size: 42, + ), + ), + ]; + } + + Future _search() async { + final query = _searchController.text.trim(); + if (query.isEmpty) return; + setState(() { + _isSearching = true; + _searchError = null; + _hasSearched = true; + }); + try { + final results = await _searchClient.search(query); + if (!mounted) return; + final selected = results.isEmpty ? null : results.first; + setState(() { + _results = results; + _selectedLocation = selected; + }); + if (selected != null) _moveTo(selected); + } catch (error) { + if (!mounted) return; + setState(() { + _results = const []; + _searchError = error.toString(); + }); + } finally { + if (mounted) setState(() => _isSearching = false); + } + } + + void _selectLocation(MapLocation location) { + setState(() => _selectedLocation = location); + _moveTo(location); + } + + void _moveTo(MapLocation location) { + _mapController.move(LatLng(location.latitude, location.longitude), 16); + } + + void _askAssistant() { + final location = _selectedLocation; + if (location == null) return; + widget.onAskAssistant(location.assistantPrompt()); + } + + Future _openExternalMaps() async { + final location = _selectedLocation; + if (location == null) return; + final uri = Uri.https('www.google.com', '/maps/search/', { + 'api': '1', + 'query': '${location.latitude},${location.longitude}', + }); + if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { + throw StateError('Could not open maps.'); + } + } +} diff --git a/flutter_app/lib/src/widgets/agent_home_scaffold.dart b/flutter_app/lib/src/widgets/agent_home_scaffold.dart index cb71c32..dbb7fdb 100644 --- a/flutter_app/lib/src/widgets/agent_home_scaffold.dart +++ b/flutter_app/lib/src/widgets/agent_home_scaffold.dart @@ -31,6 +31,7 @@ class AgentHomeScaffold extends StatelessWidget { required this.onDeleteSession, required this.onSuggestionSelected, required this.onSend, + required this.onAskAssistant, required this.onLogout, required this.onSaveProfile, required this.onSaveAgentConfig, @@ -62,6 +63,7 @@ class AgentHomeScaffold extends StatelessWidget { final ValueChanged onDeleteSession; final ValueChanged onSuggestionSelected; final VoidCallback onSend; + final ValueChanged onAskAssistant; final VoidCallback? onLogout; final Future Function(OnboardingProfile profile)? onSaveProfile; final Future Function(AgentConfig config, String? apiKey) @@ -108,6 +110,11 @@ class AgentHomeScaffold extends StatelessWidget { icon: Icon(Icons.mail_outline_rounded), label: 'Mail', ), + NavigationDestination( + selectedIcon: Icon(Icons.map_rounded), + icon: Icon(Icons.map_outlined), + label: 'Map', + ), NavigationDestination( selectedIcon: Icon(Icons.restaurant_rounded), icon: Icon(Icons.restaurant_outlined), @@ -152,6 +159,7 @@ class AgentHomeScaffold extends StatelessWidget { compactMessages: compactMessages, onSuggestionSelected: onSuggestionSelected, onSend: onSend, + onAskAssistant: onAskAssistant, onSelectView: onSelectView, worldState: worldState, memoryText: memoryText, @@ -185,6 +193,7 @@ const List _navigationViews = [ AppView.chat, AppView.schedule, AppView.mail, + AppView.maps, AppView.campus, AppView.memories, AppView.settings, diff --git a/flutter_app/lib/src/widgets/maps_controls.dart b/flutter_app/lib/src/widgets/maps_controls.dart new file mode 100644 index 0000000..86a05f4 --- /dev/null +++ b/flutter_app/lib/src/widgets/maps_controls.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; + +import '../map_location_models.dart'; +import '../studyos_theme.dart'; + +class MapSearchBar extends StatelessWidget { + const MapSearchBar({ + required this.controller, + required this.isSearching, + required this.onSearch, + super.key, + }); + + final TextEditingController controller; + final bool isSearching; + final VoidCallback onSearch; + + @override + Widget build(BuildContext context) { + return TextField( + controller: controller, + textInputAction: TextInputAction.search, + onSubmitted: (_) => onSearch(), + decoration: InputDecoration( + labelText: 'Destination', + hintText: 'Library, lecture hall, mensa...', + prefixIcon: const Icon(Icons.search_rounded), + suffixIcon: IconButton( + tooltip: 'Search destination', + onPressed: isSearching ? null : onSearch, + icon: isSearching + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.arrow_forward_rounded), + ), + ), + ); + } +} + +class MapOverlay extends StatelessWidget { + const MapOverlay({ + required this.results, + required this.selectedLocation, + required this.isSearching, + required this.searchError, + required this.hasSearched, + required this.onSelect, + required this.onAskAssistant, + required this.onOpenExternalMaps, + super.key, + }); + + final List results; + final MapLocation? selectedLocation; + final bool isSearching; + final String? searchError; + final bool hasSearched; + final ValueChanged onSelect; + final VoidCallback onAskAssistant; + final VoidCallback onOpenExternalMaps; + + @override + Widget build(BuildContext context) { + final selected = selectedLocation; + return DecoratedBox( + decoration: BoxDecoration( + color: StudyOsColors.surface.withValues(alpha: 0.94), + borderRadius: BorderRadius.circular(StudyOsRadii.md), + border: Border.all(color: StudyOsColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(StudyOsSpacing.md), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 380), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (searchError != null) + Text( + searchError!, + style: Theme.of(context).textTheme.bodyMedium, + ) + else if (isSearching) + const LinearProgressIndicator() + else if (results.isEmpty && hasSearched) + Text( + 'No destination found.', + style: Theme.of(context).textTheme.bodyMedium, + ) + else if (results.isNotEmpty) + _ResultList( + results: results, + selectedLocation: selected, + onSelect: onSelect, + ), + if (selected != null) ...[ + const Divider(height: StudyOsSpacing.lg), + Text( + selected.name, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: StudyOsSpacing.xs), + Text( + selected.address ?? selected.coordinateText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: StudyOsSpacing.sm), + Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: onAskAssistant, + icon: const Icon(Icons.auto_awesome_rounded), + label: const Text('Ask StudyOS'), + ), + ), + const SizedBox(width: StudyOsSpacing.sm), + IconButton.filledTonal( + tooltip: 'Open in maps', + onPressed: onOpenExternalMaps, + icon: const Icon(Icons.near_me_outlined), + ), + ], + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _ResultList extends StatelessWidget { + const _ResultList({ + required this.results, + required this.selectedLocation, + required this.onSelect, + }); + + final List results; + final MapLocation? selectedLocation; + final ValueChanged onSelect; + + @override + Widget build(BuildContext context) { + final visibleResults = results.take(4).toList(growable: false); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final result in visibleResults) + ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + selected: result == selectedLocation, + leading: const Icon(Icons.place_outlined), + title: Text( + result.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + result.address ?? result.coordinateText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => onSelect(result), + ), + ], + ); + } +} diff --git a/flutter_app/linux/flutter/generated_plugin_registrant.cc b/flutter_app/linux/flutter/generated_plugin_registrant.cc index d0e7f79..38dd0bc 100644 --- a/flutter_app/linux/flutter/generated_plugin_registrant.cc +++ b/flutter_app/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,13 @@ #include "generated_plugin_registrant.h" #include +#include void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/flutter_app/linux/flutter/generated_plugins.cmake b/flutter_app/linux/flutter/generated_plugins.cmake index ce58916..7e7bd77 100644 --- a/flutter_app/linux/flutter/generated_plugins.cmake +++ b/flutter_app/linux/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST flutter_secure_storage_linux + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift index ee71999..5551f43 100644 --- a/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,8 +7,10 @@ import Foundation import flutter_secure_storage_darwin import shared_preferences_foundation +import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) 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 5bf8ee7..c383e4d 100644 --- a/flutter_app/pubspec.lock +++ b/flutter_app/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff + url: "https://pub.dev" + source: hosted + version: "4.0.9" args: dependency: transitive description: @@ -81,6 +89,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + dart_earcut: + dependency: transitive + description: + name: dart_earcut + sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b + url: "https://pub.dev" + source: hosted + version: "1.2.0" + dart_polylabel2: + dependency: transitive + description: + name: dart_polylabel2 + sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6" + url: "https://pub.dev" + source: hosted + version: "1.0.0" fake_async: dependency: transitive description: @@ -113,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" flutter: dependency: "direct main" description: flutter @@ -126,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_map: + dependency: "direct main" + description: + name: flutter_map + sha256: "03b71c02806ff20c3718d108cbbb3638142ebafe368d8ce2dd22a33344bcb02b" + url: "https://pub.dev" + source: hosted + version: "8.3.0" flutter_markdown_plus: dependency: "direct main" description: @@ -224,6 +264,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" jni: dependency: transitive description: @@ -240,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + latlong2: + dependency: "direct main" + description: + name: latlong2 + sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe" + url: "https://pub.dev" + source: hosted + version: "0.9.1" leak_tracker: dependency: transitive description: @@ -312,6 +368,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + mgrs_dart: + dependency: transitive + description: + name: mgrs_dart + sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f" + url: "https://pub.dev" + source: hosted + version: "3.0.0" objective_c: dependency: transitive description: @@ -400,6 +464,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + proj4dart: + dependency: transitive + description: + name: proj4dart + sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859 + url: "https://pub.dev" + source: hosted + version: "3.0.0" pub_semver: dependency: transitive description: @@ -472,6 +552,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + simple_sparse_list: + dependency: transitive + description: + name: simple_sparse_list + sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f + url: "https://pub.dev" + source: hosted + version: "0.1.4" sky_engine: dependency: transitive description: flutter @@ -533,6 +621,86 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + unicode: + dependency: transitive + description: + name: unicode + sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57 + url: "https://pub.dev" + source: hosted + version: "1.1.9" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" vector_math: dependency: transitive description: @@ -565,6 +733,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.0" + wkt_parser: + dependency: transitive + description: + name: wkt_parser + sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13" + url: "https://pub.dev" + source: hosted + version: "2.0.0" xdg_directories: dependency: transitive description: diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml index 4c6f6d9..1ced5c6 100644 --- a/flutter_app/pubspec.yaml +++ b/flutter_app/pubspec.yaml @@ -40,6 +40,9 @@ dependencies: flutter_markdown_plus: ^1.0.7 path_provider: ^2.1.5 html: ^0.15.6 + flutter_map: ^8.2.2 + latlong2: ^0.9.1 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/flutter_app/test/map_location_models_test.dart b/flutter_app/test/map_location_models_test.dart new file mode 100644 index 0000000..59614d3 --- /dev/null +++ b/flutter_app/test/map_location_models_test.dart @@ -0,0 +1,27 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/map_location_models.dart'; + +void main() { + test('parses Nominatim locations and builds assistant prompt', () { + final locations = mapLocationsFromNominatim(''' +[ + { + "display_name": "University Library, Wilhelmstraße, Tübingen", + "lat": "48.525", + "lon": "9.060", + "type": "library" + } +] +'''); + + expect(locations, hasLength(1)); + final location = locations.single; + expect(location.name, 'University Library'); + expect(location.coordinateText, '48.52500, 9.06000'); + + final prompt = location.assistantPrompt(); + expect(prompt, contains('University Library')); + expect(prompt, contains('48.52500, 9.06000')); + expect(prompt, contains('navigation options')); + }); +} diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index b01119e..f368ea4 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -49,6 +49,7 @@ void main() { onDeleteSession: (_) {}, onSuggestionSelected: (_) {}, onSend: () {}, + onAskAssistant: (_) {}, onLogout: null, onSaveProfile: (_) async {}, onSaveAgentConfig: (_, _) async {}, @@ -65,6 +66,7 @@ void main() { expect(find.byIcon(Icons.home_rounded), findsOneWidget); expect(find.text('Schedule'), findsOneWidget); expect(find.text('Mail'), findsWidgets); + expect(find.text('Map'), findsWidgets); expect(find.text('Campus'), findsWidgets); await tester.tap(find.text('Schedule')); @@ -73,6 +75,12 @@ void main() { expect(selectedView, AppView.schedule); expect(find.text('No timetable synced yet'), findsOneWidget); + await tester.tap(find.text('Map')); + await tester.pump(); + + expect(selectedView, AppView.maps); + expect(find.text('Maps'), findsOneWidget); + await tester.tap(find.text('Chat')); await tester.pumpAndSettle(); @@ -105,6 +113,7 @@ void main() { memoryText: '', timetable: null, onOpenMail: () => selectedView = AppView.mail, + onOpenMaps: () => selectedView = AppView.maps, onOpenCampus: () => selectedView = AppView.campus, onOpenSchedule: () => selectedView = AppView.schedule, ), @@ -136,6 +145,7 @@ void main() { memoryText: '', timetable: null, onOpenMail: () => selectedView = AppView.mail, + onOpenMaps: () => selectedView = AppView.maps, onOpenCampus: () => selectedView = AppView.campus, onOpenSchedule: () => selectedView = AppView.schedule, ), @@ -147,4 +157,38 @@ void main() { expect(selectedView, AppView.mail); }); + + testWidgets('home navigation card opens maps view', ( + WidgetTester tester, + ) async { + var selectedView = AppView.home; + + await tester.pumpWidget( + MaterialApp( + theme: buildStudyOsTheme(), + home: Scaffold( + body: HomeView( + profile: null, + config: const AgentConfig.defaults(), + memoryText: '', + timetable: null, + onOpenMail: () => selectedView = AppView.mail, + onOpenMaps: () => selectedView = AppView.maps, + onOpenCampus: () => selectedView = AppView.campus, + onOpenSchedule: () => selectedView = AppView.schedule, + ), + ), + ), + ); + + final mapsCard = find.byKey(const ValueKey('home-maps-card')); + await tester.scrollUntilVisible( + mapsCard, + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(mapsCard); + + expect(selectedView, AppView.maps); + }); } diff --git a/flutter_app/windows/flutter/generated_plugin_registrant.cc b/flutter_app/windows/flutter/generated_plugin_registrant.cc index 0c50753..2048c45 100644 --- a/flutter_app/windows/flutter/generated_plugin_registrant.cc +++ b/flutter_app/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/flutter_app/windows/flutter/generated_plugins.cmake b/flutter_app/windows/flutter/generated_plugins.cmake index d0b33f8..e17c858 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 + url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST