diff --git a/flutter_app/lib/src/views/maps_view.dart b/flutter_app/lib/src/views/maps_view.dart index 7c93ca4..b64987e 100644 --- a/flutter_app/lib/src/views/maps_view.dart +++ b/flutter_app/lib/src/views/maps_view.dart @@ -24,6 +24,7 @@ class _MapsViewState extends State { static const _tuebingen = LatLng(48.5216, 9.0576); late final TextEditingController _searchController; + late final FocusNode _searchFocusNode; late final MapController _mapController; late final MapSearchClient _searchClient; List _results = const []; @@ -31,17 +32,22 @@ class _MapsViewState extends State { bool _isSearching = false; String? _searchError; bool _hasSearched = false; + bool _searchHasFocus = false; @override void initState() { super.initState(); _searchController = TextEditingController(); + _searchFocusNode = FocusNode(); + _searchFocusNode.addListener(_handleSearchFocusChanged); _mapController = MapController(); _searchClient = widget.searchClient ?? MapSearchClient(); } @override void dispose() { + _searchFocusNode.removeListener(_handleSearchFocusChanged); + _searchFocusNode.dispose(); _searchController.dispose(); _searchClient.close(); super.dispose(); @@ -49,67 +55,68 @@ class _MapsViewState extends State { @override Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(StudyOsRadii.lg), - child: Stack( - children: [ - FlutterMap( - mapController: _mapController, - options: const MapOptions( - initialCenter: _tuebingen, - initialZoom: 13.5, + return Stack( + children: [ + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _tuebingen, + initialZoom: 13.5, + interactionOptions: InteractionOptions( + flags: InteractiveFlag.all & ~InteractiveFlag.rotate, + cursorKeyboardRotationOptions: + CursorKeyboardRotationOptions.disabled(), ), - 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), + 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, ), - child: const Padding( - padding: EdgeInsets.symmetric( - horizontal: StudyOsSpacing.sm, - vertical: StudyOsSpacing.xs, - ), - child: Text( - '© OpenStreetMap contributors', - style: TextStyle( - color: StudyOsColors.textMuted, - fontSize: 11, - ), - ), + 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, - ), + ), + Positioned( + left: StudyOsSpacing.sm, + right: StudyOsSpacing.sm, + bottom: StudyOsSpacing.md, + child: MapOverlay( + controller: _searchController, + focusNode: _searchFocusNode, + results: _results, + selectedLocation: _selectedLocation, + isSearching: _isSearching, + searchError: _searchError, + hasSearched: _hasSearched, + showAssistantAction: _searchHasFocus, + onSearch: _search, + onSelect: _selectLocation, + onAskAssistant: _askAssistant, + onOpenExternalMaps: _openExternalMaps, ), - ], - ), + ), + ], ); } @@ -163,6 +170,11 @@ class _MapsViewState extends State { _moveTo(location); } + void _handleSearchFocusChanged() { + if (!mounted) return; + setState(() => _searchHasFocus = _searchFocusNode.hasFocus); + } + void _moveTo(MapLocation location) { _mapController.move(LatLng(location.latitude, location.longitude), 16); } diff --git a/flutter_app/lib/src/widgets/agent_home_scaffold.dart b/flutter_app/lib/src/widgets/agent_home_scaffold.dart index dbb7fdb..55080db 100644 --- a/flutter_app/lib/src/widgets/agent_home_scaffold.dart +++ b/flutter_app/lib/src/widgets/agent_home_scaffold.dart @@ -74,6 +74,39 @@ class AgentHomeScaffold extends StatelessWidget { @override Widget build(BuildContext context) { + final header = StudyHeader( + status: status, + onCreateSession: selectedView == AppView.chat ? onCreateSession : null, + ); + final selectedContent = AgentSelectedView( + selectedView: selectedView, + sessions: sessions, + activeSessionId: activeSessionId, + inputController: inputController, + messageScrollController: messageScrollController, + isSending: isSending, + compactMessages: compactMessages, + onSuggestionSelected: onSuggestionSelected, + onSend: onSend, + onAskAssistant: onAskAssistant, + onSelectView: onSelectView, + worldState: worldState, + memoryText: memoryText, + timetable: timetable, + timetableError: timetableError, + isRefreshingTimetable: isRefreshingTimetable, + agentConfig: agentConfig, + nativeBridge: nativeBridge, + profile: profile, + status: status, + onLogout: onLogout, + onSaveProfile: onSaveProfile, + onSaveAgentConfig: onSaveAgentConfig, + onSaveMemory: onSaveMemory, + onRefreshTimetable: onRefreshTimetable, + onCompactMessagesChanged: onCompactMessagesChanged, + ); + return Scaffold( drawer: AppDrawer( sessions: sessions.where((session) => session.hasTurns).toList(), @@ -133,55 +166,35 @@ class AgentHomeScaffold extends StatelessWidget { ], ), body: SafeArea( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 760), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: StudyOsSpacing.lg, - ), - child: Column( - children: [ - StudyHeader( - status: status, - onCreateSession: selectedView == AppView.chat - ? onCreateSession - : null, + child: Column( + children: [ + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.lg, ), - Expanded( - child: AgentSelectedView( - selectedView: selectedView, - sessions: sessions, - activeSessionId: activeSessionId, - inputController: inputController, - messageScrollController: messageScrollController, - isSending: isSending, - compactMessages: compactMessages, - onSuggestionSelected: onSuggestionSelected, - onSend: onSend, - onAskAssistant: onAskAssistant, - onSelectView: onSelectView, - worldState: worldState, - memoryText: memoryText, - timetable: timetable, - timetableError: timetableError, - isRefreshingTimetable: isRefreshingTimetable, - agentConfig: agentConfig, - nativeBridge: nativeBridge, - profile: profile, - status: status, - onLogout: onLogout, - onSaveProfile: onSaveProfile, - onSaveAgentConfig: onSaveAgentConfig, - onSaveMemory: onSaveMemory, - onRefreshTimetable: onRefreshTimetable, - onCompactMessagesChanged: onCompactMessagesChanged, - ), - ), - ], + child: header, + ), ), ), - ), + Expanded( + child: selectedView == AppView.maps + ? selectedContent + : Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: StudyOsSpacing.lg, + ), + child: selectedContent, + ), + ), + ), + ), + ], ), ), ); diff --git a/flutter_app/lib/src/widgets/maps_controls.dart b/flutter_app/lib/src/widgets/maps_controls.dart index 8680a74..dba571d 100644 --- a/flutter_app/lib/src/widgets/maps_controls.dart +++ b/flutter_app/lib/src/widgets/maps_controls.dart @@ -6,19 +6,26 @@ import '../studyos_theme.dart'; class MapSearchBar extends StatelessWidget { const MapSearchBar({ required this.controller, + required this.focusNode, required this.isSearching, + required this.showAssistantAction, required this.onSearch, + required this.onAskAssistant, super.key, }); final TextEditingController controller; + final FocusNode focusNode; final bool isSearching; + final bool showAssistantAction; final VoidCallback onSearch; + final VoidCallback onAskAssistant; @override Widget build(BuildContext context) { return TextField( controller: controller, + focusNode: focusNode, textInputAction: TextInputAction.search, onSubmitted: (_) => onSearch(), decoration: InputDecoration( @@ -42,15 +49,32 @@ class MapSearchBar extends StatelessWidget { borderRadius: BorderRadius.circular(28), borderSide: const BorderSide(color: StudyOsColors.accent), ), - 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), + suffixIconConstraints: const BoxConstraints(minHeight: 48), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: StudyOsSpacing.xs), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showAssistantAction) ...[ + _RoundSearchIconButton( + tooltip: 'Ask AI', + icon: Icons.auto_awesome_rounded, + onPressed: onAskAssistant, + ), + const SizedBox(width: StudyOsSpacing.xs), + ], + 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), + ), + ], + ), ), ), ); @@ -60,11 +84,13 @@ class MapSearchBar extends StatelessWidget { class MapOverlay extends StatelessWidget { const MapOverlay({ required this.controller, + required this.focusNode, required this.results, required this.selectedLocation, required this.isSearching, required this.searchError, required this.hasSearched, + required this.showAssistantAction, required this.onSearch, required this.onSelect, required this.onAskAssistant, @@ -73,11 +99,13 @@ class MapOverlay extends StatelessWidget { }); final TextEditingController controller; + final FocusNode focusNode; final List results; final MapLocation? selectedLocation; final bool isSearching; final String? searchError; final bool hasSearched; + final bool showAssistantAction; final VoidCallback onSearch; final ValueChanged onSelect; final VoidCallback onAskAssistant; @@ -104,12 +132,13 @@ class MapOverlay extends StatelessWidget { Expanded( child: MapSearchBar( controller: controller, + focusNode: focusNode, isSearching: isSearching, + showAssistantAction: showAssistantAction, onSearch: onSearch, + onAskAssistant: onAskAssistant, ), ), - const SizedBox(width: StudyOsSpacing.sm), - _AskAssistantButton(onPressed: onAskAssistant), ], ), ], @@ -199,25 +228,27 @@ class _MapResultStrip extends StatelessWidget { } } -class _AskAssistantButton extends StatelessWidget { - const _AskAssistantButton({required this.onPressed}); +class _RoundSearchIconButton extends StatelessWidget { + const _RoundSearchIconButton({ + required this.tooltip, + required this.icon, + required this.onPressed, + }); + final String tooltip; + final IconData icon; 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), - ), - ), + return IconButton.filled( + tooltip: tooltip, + onPressed: onPressed, + icon: Icon(icon), + style: IconButton.styleFrom( + minimumSize: const Size.square(40), + fixedSize: const Size.square(40), + shape: const CircleBorder(), ), ); } diff --git a/flutter_app/test/maps_controls_test.dart b/flutter_app/test/maps_controls_test.dart index 9b67605..22433b0 100644 --- a/flutter_app/test/maps_controls_test.dart +++ b/flutter_app/test/maps_controls_test.dart @@ -9,11 +9,13 @@ void main() { WidgetTester tester, ) async { final controller = TextEditingController(); + final focusNode = FocusNode(); var searched = false; var selected = false; var asked = false; var opened = false; addTearDown(controller.dispose); + addTearDown(focusNode.dispose); const location = MapLocation( name: 'Mensa Morgenstelle', @@ -30,11 +32,13 @@ void main() { alignment: Alignment.bottomCenter, child: MapOverlay( controller: controller, + focusNode: focusNode, results: const [location], selectedLocation: location, isSearching: false, searchError: null, hasSearched: true, + showAssistantAction: true, onSearch: () => searched = true, onSelect: (_) => selected = true, onAskAssistant: () => asked = true, @@ -46,13 +50,14 @@ void main() { ); expect(find.text('Where to?'), findsOneWidget); - expect(find.text('Ask AI'), findsOneWidget); + expect(find.text('Ask AI'), findsNothing); + expect(find.byTooltip('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('Ask AI')); await tester.tap(find.byTooltip('Search destination')); expect(selected, isTrue); @@ -60,4 +65,39 @@ void main() { expect(asked, isTrue); expect(searched, isTrue); }); + + testWidgets( + 'map overlay hides the assistant action until search is focused', + (WidgetTester tester) async { + final controller = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + MaterialApp( + theme: buildStudyOsTheme(), + home: Scaffold( + body: MapOverlay( + controller: controller, + focusNode: focusNode, + results: const [], + selectedLocation: null, + isSearching: false, + searchError: null, + hasSearched: false, + showAssistantAction: false, + onSearch: () {}, + onSelect: (_) {}, + onAskAssistant: () {}, + onOpenExternalMaps: () {}, + ), + ), + ), + ); + + expect(find.byTooltip('Ask AI'), findsNothing); + expect(find.byTooltip('Search destination'), findsOneWidget); + }, + ); } diff --git a/flutter_app/test/navigation_test.dart b/flutter_app/test/navigation_test.dart index be32153..124c567 100644 --- a/flutter_app/test/navigation_test.dart +++ b/flutter_app/test/navigation_test.dart @@ -80,7 +80,12 @@ void main() { expect(selectedView, AppView.maps); expect(find.text('Where to?'), findsOneWidget); - expect(find.text('Ask AI'), findsOneWidget); + expect(find.byTooltip('Ask AI'), findsNothing); + + await tester.tap(find.byType(TextField)); + await tester.pump(); + + expect(find.byTooltip('Ask AI'), findsOneWidget); await tester.tap(find.text('Chat')); await tester.pumpAndSettle();