diff --git a/flutter_app/lib/src/views/maps_view.dart b/flutter_app/lib/src/views/maps_view.dart index 6e31fdd..7c93ca4 100644 --- a/flutter_app/lib/src/views/maps_view.dart +++ b/flutter_app/lib/src/views/maps_view.dart @@ -49,86 +49,67 @@ class _MapsViewState extends State { @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, - ), - ), - ), - ), + return 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, + top: StudyOsSpacing.sm, + child: DecoratedBox( + decoration: BoxDecoration( + color: StudyOsColors.background.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(StudyOsRadii.sm), + border: Border.all(color: StudyOsColors.border), + ), + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: StudyOsSpacing.sm, + vertical: StudyOsSpacing.xs, ), - 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, + child: Text( + '© OpenStreetMap contributors', + style: TextStyle( + color: StudyOsColors.textMuted, + fontSize: 11, ), ), - ], + ), ), ), - ), - ], + Positioned( + left: StudyOsSpacing.md, + right: StudyOsSpacing.md, + bottom: StudyOsSpacing.md, + child: MapOverlay( + controller: _searchController, + results: _results, + selectedLocation: _selectedLocation, + isSearching: _isSearching, + searchError: _searchError, + hasSearched: _hasSearched, + onSearch: _search, + onSelect: _selectLocation, + onAskAssistant: _askAssistant, + onOpenExternalMaps: _openExternalMaps, + ), + ), + ], + ), ); } @@ -188,8 +169,12 @@ class _MapsViewState extends State { void _askAssistant() { final location = _selectedLocation; - if (location == null) return; - widget.onAskAssistant(location.assistantPrompt()); + widget.onAskAssistant( + location?.assistantPrompt() ?? + 'Use my current StudyOS location context and help me with nearby ' + 'places. Suggest useful options such as food nearby, study ' + 'spaces, transit, and what I should know before going there.', + ); } Future _openExternalMaps() async { diff --git a/flutter_app/lib/src/widgets/maps_controls.dart b/flutter_app/lib/src/widgets/maps_controls.dart index 86a05f4..8680a74 100644 --- a/flutter_app/lib/src/widgets/maps_controls.dart +++ b/flutter_app/lib/src/widgets/maps_controls.dart @@ -22,9 +22,26 @@ class MapSearchBar extends StatelessWidget { textInputAction: TextInputAction.search, onSubmitted: (_) => onSearch(), decoration: InputDecoration( - labelText: 'Destination', - hintText: 'Library, lecture hall, mensa...', + filled: true, + fillColor: StudyOsColors.surface.withValues(alpha: 0.96), + hintText: 'Where to?', prefixIcon: const Icon(Icons.search_rounded), + contentPadding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.lg, + vertical: StudyOsSpacing.lg, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: const BorderSide(color: StudyOsColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: const BorderSide(color: StudyOsColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: const BorderSide(color: StudyOsColors.accent), + ), suffixIcon: IconButton( tooltip: 'Search destination', onPressed: isSearching ? null : onSearch, @@ -42,139 +59,187 @@ class MapSearchBar extends StatelessWidget { class MapOverlay extends StatelessWidget { const MapOverlay({ + required this.controller, required this.results, required this.selectedLocation, required this.isSearching, required this.searchError, required this.hasSearched, + required this.onSearch, required this.onSelect, required this.onAskAssistant, required this.onOpenExternalMaps, super.key, }); + final TextEditingController controller; final List results; final MapLocation? selectedLocation; final bool isSearching; final String? searchError; final bool hasSearched; + final VoidCallback onSearch; 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), - ), - ], - ), - ], - ], + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _MapResultStrip( + results: results, + selectedLocation: selectedLocation, + isSearching: isSearching, + searchError: searchError, + hasSearched: hasSearched, + onSelect: onSelect, + onOpenExternalMaps: onOpenExternalMaps, + ), + const SizedBox(height: StudyOsSpacing.sm), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: MapSearchBar( + controller: controller, + isSearching: isSearching, + onSearch: onSearch, + ), ), - ), + const SizedBox(width: StudyOsSpacing.sm), + _AskAssistantButton(onPressed: onAskAssistant), + ], ), - ), + ], ); } } -class _ResultList extends StatelessWidget { - const _ResultList({ +class _MapResultStrip extends StatelessWidget { + const _MapResultStrip({ required this.results, required this.selectedLocation, + required this.isSearching, + required this.searchError, + required this.hasSearched, required this.onSelect, + required this.onOpenExternalMaps, }); final List results; final MapLocation? selectedLocation; + final bool isSearching; + final String? searchError; + final bool hasSearched; final ValueChanged onSelect; + final VoidCallback onOpenExternalMaps; @override Widget build(BuildContext context) { + if (!isSearching && + searchError == null && + !(results.isEmpty && hasSearched) && + results.isEmpty) { + return const SizedBox.shrink(); + } + + final selected = selectedLocation; + final message = + searchError ?? + (results.isEmpty && hasSearched ? 'No destination found.' : null); + + if (isSearching || message != null) { + return _FloatingMapPanel( + child: isSearching + ? const LinearProgressIndicator() + : Text(message!, style: Theme.of(context).textTheme.bodyMedium), + ); + } + 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, + return _FloatingMapPanel( + child: Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (final result in visibleResults) + Padding( + padding: const EdgeInsets.only(right: StudyOsSpacing.sm), + child: ChoiceChip( + selected: result == selected, + label: Text( + result.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + avatar: const Icon(Icons.place_outlined, size: 18), + onSelected: (_) => onSelect(result), + ), + ), + ], + ), ), - subtitle: Text( - result.address ?? result.coordinateText, - maxLines: 1, - overflow: TextOverflow.ellipsis, + ), + if (selected != null) ...[ + const SizedBox(width: StudyOsSpacing.xs), + IconButton.filledTonal( + tooltip: 'Open in maps', + onPressed: onOpenExternalMaps, + icon: const Icon(Icons.near_me_outlined), ), - onTap: () => onSelect(result), + ], + ], + ), + ); + } +} + +class _AskAssistantButton extends StatelessWidget { + const _AskAssistantButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 58, + child: FilledButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.auto_awesome_rounded), + label: const Text('Ask AI'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: StudyOsSpacing.lg), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(28), ), - ], + ), + ), + ); + } +} + +class _FloatingMapPanel extends StatelessWidget { + const _FloatingMapPanel({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + 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.sm), + child: child, + ), ); } } diff --git a/flutter_app/test/maps_controls_test.dart b/flutter_app/test/maps_controls_test.dart new file mode 100644 index 0000000..9b67605 --- /dev/null +++ b/flutter_app/test/maps_controls_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:studyos_agent/src/map_location_models.dart'; +import 'package:studyos_agent/src/studyos_theme.dart'; +import 'package:studyos_agent/src/widgets/maps_controls.dart'; + +void main() { + testWidgets('map overlay keeps search and assistant actions at the bottom', ( + WidgetTester tester, + ) async { + final controller = TextEditingController(); + var searched = false; + var selected = false; + var asked = false; + var opened = false; + addTearDown(controller.dispose); + + const location = MapLocation( + name: 'Mensa Morgenstelle', + latitude: 48.53882, + longitude: 9.03531, + address: 'Mensa Morgenstelle, Tuebingen, Germany', + ); + + await tester.pumpWidget( + MaterialApp( + theme: buildStudyOsTheme(), + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: MapOverlay( + controller: controller, + results: const [location], + selectedLocation: location, + isSearching: false, + searchError: null, + hasSearched: true, + onSearch: () => searched = true, + onSelect: (_) => selected = true, + onAskAssistant: () => asked = true, + onOpenExternalMaps: () => opened = true, + ), + ), + ), + ), + ); + + expect(find.text('Where to?'), findsOneWidget); + expect(find.text('Ask AI'), findsOneWidget); + expect(find.text('Mensa Morgenstelle'), findsOneWidget); + expect(find.textContaining('Tuebingen, Germany'), findsNothing); + + await tester.tap(find.text('Mensa Morgenstelle')); + await tester.tap(find.byTooltip('Open in maps')); + await tester.tap(find.text('Ask AI')); + await tester.tap(find.byTooltip('Search destination')); + + expect(selected, isTrue); + expect(opened, isTrue); + expect(asked, isTrue); + expect(searched, isTrue); + }); +} diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index f368ea4..be32153 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -79,7 +79,8 @@ void main() { await tester.pump(); expect(selectedView, AppView.maps); - expect(find.text('Maps'), findsOneWidget); + expect(find.text('Where to?'), findsOneWidget); + expect(find.text('Ask AI'), findsOneWidget); await tester.tap(find.text('Chat')); await tester.pumpAndSettle();