diff --git a/lib/app_state.dart b/lib/app_state.dart index e7846ad..c3fd170 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -13,6 +13,7 @@ import 'services/node_cache.dart'; import 'services/tile_preview_service.dart'; import 'widgets/camera_provider_with_cache.dart'; import 'state/auth_state.dart'; +import 'state/navigation_state.dart'; import 'state/operator_profile_state.dart'; import 'state/profile_state.dart'; import 'state/search_state.dart'; @@ -21,6 +22,7 @@ import 'state/settings_state.dart'; import 'state/upload_queue_state.dart'; // Re-export types +export 'state/navigation_state.dart' show AppNavigationMode; export 'state/settings_state.dart' show UploadMode, FollowMeMode; export 'state/session_state.dart' show AddNodeSession, EditNodeSession; @@ -30,6 +32,7 @@ class AppState extends ChangeNotifier { // State modules late final AuthState _authState; + late final NavigationState _navigationState; late final OperatorProfileState _operatorProfileState; late final ProfileState _profileState; late final SearchState _searchState; @@ -42,6 +45,7 @@ class AppState extends ChangeNotifier { AppState() { instance = this; _authState = AuthState(); + _navigationState = NavigationState(); _operatorProfileState = OperatorProfileState(); _profileState = ProfileState(); _searchState = SearchState(); @@ -51,6 +55,7 @@ class AppState extends ChangeNotifier { // Set up state change listeners _authState.addListener(_onStateChanged); + _navigationState.addListener(_onStateChanged); _operatorProfileState.addListener(_onStateChanged); _profileState.addListener(_onStateChanged); _searchState.addListener(_onStateChanged); @@ -68,6 +73,28 @@ class AppState extends ChangeNotifier { bool get isLoggedIn => _authState.isLoggedIn; String get username => _authState.username; + // Navigation state + AppNavigationMode get navigationMode => _navigationState.mode; + LatLng? get provisionalPinLocation => _navigationState.provisionalPinLocation; + String? get provisionalPinAddress => _navigationState.provisionalPinAddress; + bool get showProvisionalPin => _navigationState.showProvisionalPin; + bool get isInSearchMode => _navigationState.isInSearchMode; + bool get isInRouteMode => _navigationState.isInRouteMode; + bool get hasActiveRoute => _navigationState.hasActiveRoute; + List? get routePath => _navigationState.routePath; + + // Route state + LatLng? get routeStart => _navigationState.routeStart; + LatLng? get routeEnd => _navigationState.routeEnd; + String? get routeStartAddress => _navigationState.routeStartAddress; + String? get routeEndAddress => _navigationState.routeEndAddress; + double? get routeDistance => _navigationState.routeDistance; + bool get settingRouteStart => _navigationState.settingRouteStart; + + // Navigation search state + bool get isNavigationSearchLoading => _navigationState.isSearchLoading; + List get navigationSearchResults => _navigationState.searchResults; + // Profile state List get profiles => _profileState.profiles; List get enabledProfiles => _profileState.enabledProfiles; @@ -250,6 +277,56 @@ class AppState extends ChangeNotifier { _searchState.clearResults(); } + // ---------- Navigation Methods ---------- + void enterSearchMode(LatLng mapCenter) { + _navigationState.enterSearchMode(mapCenter); + } + + void cancelSearchMode() { + _navigationState.cancelSearchMode(); + } + + void updateProvisionalPinLocation(LatLng newLocation) { + _navigationState.updateProvisionalPinLocation(newLocation); + } + + void selectSearchResult(SearchResult result) { + _navigationState.selectSearchResult(result); + } + + void startRouteSetup({required bool settingStart}) { + _navigationState.startRouteSetup(settingStart: settingStart); + } + + void selectRouteLocation() { + _navigationState.selectRouteLocation(); + } + + void startRoute() { + _navigationState.startRoute(); + } + + void cancelRoute() { + _navigationState.cancelRoute(); + } + + void viewRouteOverview() { + _navigationState.viewRouteOverview(); + } + + void returnToActiveRoute() { + _navigationState.returnToActiveRoute(); + } + + // Navigation search methods + Future searchNavigation(String query) async { + await _navigationState.search(query); + } + + void clearNavigationSearchResults() { + _navigationState.clearSearchResults(); + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); @@ -347,6 +424,7 @@ class AppState extends ChangeNotifier { @override void dispose() { _authState.removeListener(_onStateChanged); + _navigationState.removeListener(_onStateChanged); _operatorProfileState.removeListener(_onStateChanged); _profileState.removeListener(_onStateChanged); _searchState.removeListener(_onStateChanged); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index eb3fe7a..ce3d77a 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_animations/flutter_map_animations.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:latlong2/latlong.dart'; import 'package:provider/provider.dart'; import '../app_state.dart'; @@ -15,6 +16,7 @@ import '../widgets/node_tag_sheet.dart'; import '../widgets/camera_provider_with_cache.dart'; import '../widgets/download_area_dialog.dart'; import '../widgets/measured_sheet.dart'; +import '../widgets/navigation_sheet.dart'; import '../widgets/search_bar.dart'; import '../models/osm_node.dart'; import '../models/search_result.dart'; @@ -31,11 +33,13 @@ class _HomeScreenState extends State with TickerProviderStateMixin { final GlobalKey _mapViewKey = GlobalKey(); late final AnimatedMapController _mapController; bool _editSheetShown = false; + bool _navigationSheetShown = false; // Track sheet heights for map positioning double _addSheetHeight = 0.0; double _editSheetHeight = 0.0; double _tagSheetHeight = 0.0; + double _navigationSheetHeight = 0.0; // Flag to prevent map bounce when transitioning from tag sheet to edit sheet bool _transitioningToEdit = false; @@ -162,7 +166,50 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); } + void _openNavigationSheet() { + final controller = _scaffoldKey.currentState!.showBottomSheet( + (ctx) => MeasuredSheet( + onHeightChanged: (height) { + setState(() { + _navigationSheetHeight = height; + }); + }, + child: const NavigationSheet(), + ), + ); + + // Reset height when sheet is dismissed + controller.closed.then((_) { + setState(() { + _navigationSheetHeight = 0.0; + }); + }); + } + + void _onNavigationButtonPressed() { + final appState = context.read(); + + if (appState.hasActiveRoute) { + // Route button - view route overview + appState.viewRouteOverview(); + } else { + // Search button - enter search mode + try { + final mapCenter = _mapController.mapController.camera.center; + appState.enterSearchMode(mapCenter); + } catch (_) { + // Controller not ready, use fallback location + appState.enterSearchMode(LatLng(37.7749, -122.4194)); + } + } + } + void _onSearchResultSelected(SearchResult result) { + final appState = context.read(); + + // Update navigation state with selected result + appState.selectSearchResult(result); + // Jump to the search result location try { _mapController.animateTo( @@ -246,12 +293,22 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _editSheetShown = false; } + // Auto-open navigation sheet during search/route modes + if ((appState.isInSearchMode || appState.isInRouteMode) && !_navigationSheetShown) { + _navigationSheetShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet()); + } else if (!appState.isInSearchMode && !appState.isInRouteMode) { + _navigationSheetShown = false; + } + // Pass the active sheet height directly to the map final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : (_editSheetHeight > 0 ? _editSheetHeight - : _tagSheetHeight); + : (_navigationSheetHeight > 0 + ? _navigationSheetHeight + : _tagSheetHeight)); return MultiProvider( providers: [ @@ -290,94 +347,105 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), ], ), - body: Column( + body: Stack( children: [ - // Search bar at the top - LocationSearchBar( - onResultSelected: _onSearchResultSelected, + MapView( + key: _mapViewKey, + controller: _mapController, + followMeMode: appState.followMeMode, + sheetHeight: activeSheetHeight, + selectedNodeId: _selectedNodeId, + onNodeTap: openNodeTagSheet, + onUserGesture: () { + if (appState.followMeMode != FollowMeMode.off) { + appState.setFollowMeMode(FollowMeMode.off); + } + }, ), - // Map takes the rest of the space - Expanded( - child: Stack( - children: [ - MapView( - key: _mapViewKey, - controller: _mapController, - followMeMode: appState.followMeMode, - sheetHeight: activeSheetHeight, - selectedNodeId: _selectedNodeId, - onNodeTap: openNodeTagSheet, - onUserGesture: () { - if (appState.followMeMode != FollowMeMode.off) { - appState.setFollowMeMode(FollowMeMode.off); - } - }, - ), - Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset, - left: 8, - right: 8, - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Theme.of(context).shadowColor.withOpacity(0.3), - blurRadius: 10, - offset: Offset(0, -2), - ) - ], + // Search bar (slides in when in search mode) + if (appState.isInSearchMode) + Positioned( + top: 0, + left: 0, + right: 0, + child: LocationSearchBar( + onResultSelected: _onSearchResultSelected, + onCancel: () => appState.cancelSearchMode(), + ), + ), + // Bottom button bar (restored to original) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).padding.bottom + kBottomButtonBarOffset, + left: 8, + right: 8, + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), // Match typical sheet width + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Theme.of(context).shadowColor.withOpacity(0.3), + blurRadius: 10, + offset: Offset(0, -2), + ) + ], + ), + margin: EdgeInsets.only(bottom: kBottomButtonBarOffset), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + child: Row( + children: [ + Expanded( + child: AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => ElevatedButton.icon( + icon: Icon(Icons.add_location_alt), + label: Text(LocalizationService.instance.tagNode), + onPressed: _openAddNodeSheet, + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), ), - margin: EdgeInsets.only(bottom: kBottomButtonBarOffset), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - child: Row( - children: [ - Expanded( - child: AnimatedBuilder( - animation: LocalizationService.instance, - builder: (context, child) => ElevatedButton.icon( - icon: Icon(Icons.add_location_alt), - label: Text(LocalizationService.instance.tagNode), - onPressed: _openAddNodeSheet, - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), - ), - ), - ), - ), - SizedBox(width: 12), - Expanded( - child: AnimatedBuilder( - animation: LocalizationService.instance, - builder: (context, child) => ElevatedButton.icon( - icon: Icon(Icons.download_for_offline), - label: Text(LocalizationService.instance.download), - onPressed: () => showDialog( - context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), - ), - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), - ), - ), - ), - ), - ], ), ), + SizedBox(width: 12), + Expanded( + child: AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => ElevatedButton.icon( + icon: Icon(Icons.download_for_offline), + label: Text(LocalizationService.instance.download), + onPressed: () => showDialog( + context: context, + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), + ), + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), + ), + ), ), - ), + ], ), - ], + ), + ), + ), + ), + // Search button as floating map control + Positioned( + bottom: 200, // Position above other controls + right: 16, + child: FloatingActionButton( + onPressed: _onNavigationButtonPressed, + tooltip: appState.hasActiveRoute ? 'Route Overview' : 'Search Location', + child: Icon(appState.hasActiveRoute ? Icons.route : Icons.search), ), ), ], diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart new file mode 100644 index 0000000..18a58c9 --- /dev/null +++ b/lib/state/navigation_state.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/search_result.dart'; +import '../services/search_service.dart'; + +/// Navigation modes for routing and search functionality +enum AppNavigationMode { + normal, // Default state - normal map view + search, // Search box visible, provisional pin active + searchInput, // Keyboard open, UI elements hidden + routeSetup, // Placing second pin for routing + routeCalculating, // Computing route with loading indicator + routePreview, // Route ready, showing start/cancel options + routeActive, // Following an active route + routeOverview, // Viewing active route overview +} + +/// Manages all navigation, search, and routing state +class NavigationState extends ChangeNotifier { + final SearchService _searchService = SearchService(); + + AppNavigationMode _mode = AppNavigationMode.normal; + + // Search state + bool _isSearchLoading = false; + List _searchResults = []; + String _lastQuery = ''; + List _searchHistory = []; + + // Provisional pin state (for route planning) + LatLng? _provisionalPinLocation; + String? _provisionalPinAddress; + + // Route state + LatLng? _routeStart; + LatLng? _routeEnd; + String? _routeStartAddress; + String? _routeEndAddress; + List? _routePath; + double? _routeDistance; + bool _settingRouteStart = true; // true = setting start, false = setting end + + // Getters + AppNavigationMode get mode => _mode; + bool get isSearchLoading => _isSearchLoading; + List get searchResults => List.unmodifiable(_searchResults); + String get lastQuery => _lastQuery; + List get searchHistory => List.unmodifiable(_searchHistory); + + LatLng? get provisionalPinLocation => _provisionalPinLocation; + String? get provisionalPinAddress => _provisionalPinAddress; + + LatLng? get routeStart => _routeStart; + LatLng? get routeEnd => _routeEnd; + String? get routeStartAddress => _routeStartAddress; + String? get routeEndAddress => _routeEndAddress; + List? get routePath => _routePath != null ? List.unmodifiable(_routePath!) : null; + double? get routeDistance => _routeDistance; + bool get settingRouteStart => _settingRouteStart; + + // Convenience getters + bool get isInSearchMode => _mode == AppNavigationMode.search || _mode == AppNavigationMode.searchInput; + bool get isInRouteMode => _mode == AppNavigationMode.routeSetup || + _mode == AppNavigationMode.routeCalculating || + _mode == AppNavigationMode.routePreview || + _mode == AppNavigationMode.routeActive || + _mode == AppNavigationMode.routeOverview; + bool get hasActiveRoute => _routePath != null; + bool get showProvisionalPin => _provisionalPinLocation != null && + (_mode == AppNavigationMode.search || + _mode == AppNavigationMode.routeSetup); + + /// Enter search mode with provisional pin at current map center + void enterSearchMode(LatLng mapCenter) { + if (_mode != AppNavigationMode.normal) return; + + _mode = AppNavigationMode.search; + _provisionalPinLocation = mapCenter; + _provisionalPinAddress = null; + _clearSearchResults(); + debugPrint('[NavigationState] Entered search mode at $mapCenter'); + notifyListeners(); + } + + /// Enter search input mode (keyboard open) + void enterSearchInputMode() { + if (_mode != AppNavigationMode.search) return; + + _mode = AppNavigationMode.searchInput; + debugPrint('[NavigationState] Entered search input mode'); + notifyListeners(); + } + + /// Exit search input mode back to search + void exitSearchInputMode() { + if (_mode != AppNavigationMode.searchInput) return; + + _mode = AppNavigationMode.search; + debugPrint('[NavigationState] Exited search input mode'); + notifyListeners(); + } + + /// Cancel search mode and return to normal + void cancelSearchMode() { + if (!isInSearchMode && _mode != AppNavigationMode.routeSetup) return; + + _mode = AppNavigationMode.normal; + _provisionalPinLocation = null; + _provisionalPinAddress = null; + _clearSearchResults(); + debugPrint('[NavigationState] Cancelled search mode'); + notifyListeners(); + } + + /// Update provisional pin location (when map moves during search) + void updateProvisionalPinLocation(LatLng newLocation) { + if (!showProvisionalPin) return; + + _provisionalPinLocation = newLocation; + // Clear address since location changed + _provisionalPinAddress = null; + notifyListeners(); + } + + /// Jump to search result and update provisional pin + void selectSearchResult(SearchResult result) { + if (!isInSearchMode) return; + + _provisionalPinLocation = result.coordinates; + _provisionalPinAddress = result.displayName; + _mode = AppNavigationMode.search; // Exit search input mode + _clearSearchResults(); + debugPrint('[NavigationState] Selected search result: ${result.displayName}'); + notifyListeners(); + } + + /// Start route setup (user clicked "route to" or "route from") + void startRouteSetup({required bool settingStart}) { + if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) return; + + _settingRouteStart = settingStart; + if (settingStart) { + _routeStart = _provisionalPinLocation; + _routeStartAddress = _provisionalPinAddress; + } else { + _routeEnd = _provisionalPinLocation; + _routeEndAddress = _provisionalPinAddress; + } + + _mode = AppNavigationMode.routeSetup; + // Keep provisional pin active for second location + debugPrint('[NavigationState] Started route setup (setting ${settingStart ? 'start' : 'end'})'); + notifyListeners(); + } + + /// Lock in second route location + void selectRouteLocation() { + if (_mode != AppNavigationMode.routeSetup || _provisionalPinLocation == null) return; + + if (_settingRouteStart) { + _routeStart = _provisionalPinLocation; + _routeStartAddress = _provisionalPinAddress; + } else { + _routeEnd = _provisionalPinLocation; + _routeEndAddress = _provisionalPinAddress; + } + + // Start route calculation + _calculateRoute(); + } + + /// Calculate route (mock implementation for now) + void _calculateRoute() { + if (_routeStart == null || _routeEnd == null) return; + + _mode = AppNavigationMode.routeCalculating; + notifyListeners(); + + // Mock route calculation with delay + Future.delayed(const Duration(seconds: 2), () { + if (_mode != AppNavigationMode.routeCalculating) return; + + // Create simple straight line route for now + _routePath = [_routeStart!, _routeEnd!]; + _routeDistance = const Distance().as(LengthUnit.Meter, _routeStart!, _routeEnd!); + + _mode = AppNavigationMode.routePreview; + _provisionalPinLocation = null; // Hide provisional pin + debugPrint('[NavigationState] Route calculated: ${_routeDistance! / 1000.0} km'); + notifyListeners(); + }); + } + + /// Start following the route + void startRoute() { + if (_mode != AppNavigationMode.routePreview || _routePath == null) return; + + _mode = AppNavigationMode.routeActive; + debugPrint('[NavigationState] Started route following'); + notifyListeners(); + } + + /// View route overview (from route button during active route) + void viewRouteOverview() { + if (_mode != AppNavigationMode.routeActive || _routePath == null) return; + + _mode = AppNavigationMode.routeOverview; + debugPrint('[NavigationState] Viewing route overview'); + notifyListeners(); + } + + /// Return to active route from overview + void returnToActiveRoute() { + if (_mode != AppNavigationMode.routeOverview) return; + + _mode = AppNavigationMode.routeActive; + debugPrint('[NavigationState] Returned to active route'); + notifyListeners(); + } + + /// Cancel route and return to normal mode + void cancelRoute() { + if (!isInRouteMode) return; + + _mode = AppNavigationMode.normal; + _routeStart = null; + _routeEnd = null; + _routeStartAddress = null; + _routeEndAddress = null; + _routePath = null; + _routeDistance = null; + _provisionalPinLocation = null; + _provisionalPinAddress = null; + debugPrint('[NavigationState] Cancelled route'); + notifyListeners(); + } + + /// Search functionality (delegates to existing search service) + Future search(String query) async { + if (query.trim().isEmpty) { + _clearSearchResults(); + return; + } + + if (query.trim() == _lastQuery.trim()) return; + + _setSearchLoading(true); + _lastQuery = query.trim(); + + try { + final results = await _searchService.search(query.trim()); + _searchResults = results; + debugPrint('[NavigationState] Found ${results.length} results for "$query"'); + } catch (e) { + debugPrint('[NavigationState] Search failed: $e'); + _searchResults = []; + } + + _setSearchLoading(false); + } + + /// Clear search results + void clearSearchResults() { + _clearSearchResults(); + } + + void _clearSearchResults() { + if (_searchResults.isNotEmpty || _lastQuery.isNotEmpty) { + _searchResults = []; + _lastQuery = ''; + notifyListeners(); + } + } + + void _setSearchLoading(bool loading) { + if (_isSearchLoading != loading) { + _isSearchLoading = loading; + notifyListeners(); + } + } +} \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 605a6fb..10530fc 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -21,6 +21,7 @@ import 'map/tile_layer_manager.dart'; import 'map/camera_refresh_controller.dart'; import 'map/gps_controller.dart'; import 'network_status_indicator.dart'; +import 'provisional_pin.dart'; import 'proximity_alert_banner.dart'; import '../dev_config.dart'; import '../app_state.dart' show FollowMeMode; @@ -374,10 +375,33 @@ class MapViewState extends State { } } + // Build provisional pin for navigation/search mode + if (appState.showProvisionalPin && appState.provisionalPinLocation != null) { + centerMarkers.add( + Marker( + point: appState.provisionalPinLocation!, + width: 48.0, + height: 48.0, + child: const ProvisionalPin(), + ), + ); + } + + // Build route path visualization + final routeLines = []; + if (appState.routePath != null && appState.routePath!.length > 1) { + routeLines.add(Polyline( + points: appState.routePath!, + color: Colors.blue, + strokeWidth: 4.0, + )); + } + return Stack( children: [ PolygonLayer(polygons: overlays), if (editLines.isNotEmpty) PolylineLayer(polylines: editLines), + if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines), MarkerLayer(markers: [...markers, ...centerMarkers]), ], ); @@ -406,6 +430,11 @@ class MapViewState extends State { appState.updateEditSession(target: pos.center); } + // Update provisional pin location during navigation search/routing + if (appState.showProvisionalPin) { + appState.updateProvisionalPinLocation(pos.center); + } + // Start dual-source waiting when map moves (user is expecting new tiles AND nodes) NetworkStatus.instance.setDualSourceWaiting(); diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart new file mode 100644 index 0000000..23e6a3c --- /dev/null +++ b/lib/widgets/navigation_sheet.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:latlong2/latlong.dart'; + +import '../app_state.dart'; + +class NavigationSheet extends StatelessWidget { + const NavigationSheet({super.key}); + + String _formatCoordinates(LatLng coordinates) { + return '${coordinates.latitude.toStringAsFixed(6)}, ${coordinates.longitude.toStringAsFixed(6)}'; + } + + Widget _buildLocationInfo({ + required String label, + required LatLng coordinates, + String? address, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 4), + if (address != null) ...[ + Text( + address, + style: const TextStyle(fontSize: 16), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ], + Text( + _formatCoordinates(coordinates), + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + fontFamily: 'monospace', + ), + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, appState, child) { + final navigationMode = appState.navigationMode; + final provisionalLocation = appState.provisionalPinLocation; + final provisionalAddress = appState.provisionalPinAddress; + + if (provisionalLocation == null) { + return const SizedBox.shrink(); + } + + switch (navigationMode) { + case AppNavigationMode.search: + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Location info + _buildLocationInfo( + label: 'Location', + coordinates: provisionalLocation, + address: provisionalAddress, + ), + + const SizedBox(height: 16), + + // Action buttons + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.directions), + label: const Text('Route To'), + onPressed: () { + appState.startRouteSetup(settingStart: false); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.my_location), + label: const Text('Route From'), + onPressed: () { + appState.startRouteSetup(settingStart: true); + }, + ), + ), + ], + ), + ], + ), + ); + + case AppNavigationMode.routeSetup: + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Route points info + if (appState.routeStart != null) ...[ + _buildLocationInfo( + label: 'Start', + coordinates: appState.routeStart!, + address: appState.routeStartAddress, + ), + const SizedBox(height: 12), + ], + + if (appState.routeEnd != null) ...[ + _buildLocationInfo( + label: 'End', + coordinates: appState.routeEnd!, + address: appState.routeEndAddress, + ), + const SizedBox(height: 12), + ], + + _buildLocationInfo( + label: appState.settingRouteStart ? 'Start (select)' : 'End (select)', + coordinates: provisionalLocation, + address: provisionalAddress, + ), + + const SizedBox(height: 16), + + // Select location button + ElevatedButton.icon( + icon: const Icon(Icons.check), + label: const Text('Select Location'), + onPressed: () { + appState.selectRouteLocation(); + }, + ), + ], + ), + ); + + case AppNavigationMode.routeCalculating: + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + const CircularProgressIndicator(), + const SizedBox(height: 16), + const Text('Calculating route...'), + const SizedBox(height: 16), + + ElevatedButton( + onPressed: () { + appState.cancelRoute(); + }, + child: const Text('Cancel'), + ), + ], + ), + ); + + case AppNavigationMode.routePreview: + case AppNavigationMode.routeOverview: + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Route info + if (appState.routeStart != null) ...[ + _buildLocationInfo( + label: 'Start', + coordinates: appState.routeStart!, + address: appState.routeStartAddress, + ), + const SizedBox(height: 12), + ], + + if (appState.routeEnd != null) ...[ + _buildLocationInfo( + label: 'End', + coordinates: appState.routeEnd!, + address: appState.routeEndAddress, + ), + const SizedBox(height: 12), + ], + + // Distance info + if (appState.routeDistance != null) ...[ + Text( + 'Distance: ${(appState.routeDistance! / 1000).toStringAsFixed(1)} km', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + ], + + // Action buttons + Row( + children: [ + if (navigationMode == AppNavigationMode.routePreview) ...[ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: const Text('Start'), + onPressed: () { + appState.startRoute(); + }, + ), + ), + const SizedBox(width: 12), + ] else if (navigationMode == AppNavigationMode.routeOverview) ...[ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: const Text('Resume'), + onPressed: () { + appState.returnToActiveRoute(); + }, + ), + ), + const SizedBox(width: 12), + ], + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: const Text('Cancel'), + onPressed: () { + appState.cancelRoute(); + }, + ), + ), + ], + ), + ], + ), + ); + + default: + return const SizedBox.shrink(); + } + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/provisional_pin.dart b/lib/widgets/provisional_pin.dart new file mode 100644 index 0000000..5e08d89 --- /dev/null +++ b/lib/widgets/provisional_pin.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +/// A pin icon for marking provisional locations during search/routing +class ProvisionalPin extends StatelessWidget { + final double size; + final Color color; + + const ProvisionalPin({ + super.key, + this.size = 48.0, + this.color = Colors.red, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + // Pin shadow + Positioned( + bottom: 0, + child: Container( + width: size * 0.3, + height: size * 0.15, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + borderRadius: BorderRadius.circular(size * 0.15), + ), + ), + ), + // Main pin + Icon( + Icons.location_pin, + size: size, + color: color, + ), + // Inner dot + Positioned( + top: size * 0.15, + child: Container( + width: size * 0.25, + height: size * 0.25, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: color.withOpacity(0.8), + width: 2, + ), + ), + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart index fb166f9..5bd7c45 100644 --- a/lib/widgets/search_bar.dart +++ b/lib/widgets/search_bar.dart @@ -7,10 +7,12 @@ import '../widgets/debouncer.dart'; class LocationSearchBar extends StatefulWidget { final void Function(SearchResult)? onResultSelected; + final VoidCallback? onCancel; const LocationSearchBar({ super.key, this.onResultSelected, + this.onCancel, }); @override @@ -50,14 +52,17 @@ class _LocationSearchBarState extends State { }); if (query.isEmpty) { - context.read().clearSearchResults(); + // Clear navigation search results instead of old search state + final appState = context.read(); + appState.clearNavigationSearchResults(); return; } // Debounce search to avoid too many API calls _searchDebouncer(() { if (mounted) { - context.read().search(query); + final appState = context.read(); + appState.searchNavigation(query); } }); } @@ -74,12 +79,22 @@ class _LocationSearchBarState extends State { void _onClear() { _controller.clear(); - context.read().clearSearchResults(); + context.read().clearNavigationSearchResults(); setState(() { _showResults = false; }); } + void _onCancel() { + _controller.clear(); + context.read().clearNavigationSearchResults(); + setState(() { + _showResults = false; + }); + _focusNode.unfocus(); + widget.onCancel?.call(); + } + Widget _buildResultsList(List results, bool isLoading) { if (!_showResults) return const SizedBox.shrink(); @@ -166,12 +181,21 @@ class _LocationSearchBarState extends State { decoration: InputDecoration( hintText: 'Search places or coordinates...', prefixIcon: const Icon(Icons.search), - suffixIcon: _controller.text.isNotEmpty - ? IconButton( + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.text.isNotEmpty) + IconButton( icon: const Icon(Icons.clear), onPressed: _onClear, - ) - : null, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: _onCancel, + tooltip: 'Cancel search', + ), + ], + ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, @@ -186,7 +210,7 @@ class _LocationSearchBarState extends State { onChanged: _onSearchChanged, ), ), - _buildResultsList(appState.searchResults, appState.isSearchLoading), + _buildResultsList(appState.navigationSearchResults, appState.isNavigationSearchLoading), ], ); },