From 408b52cdb049d06f3aeb89e526814d76a7f8b0a0 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 16:50:07 -0500 Subject: [PATCH 01/12] UX bones --- lib/app_state.dart | 78 ++++++++ lib/screens/home_screen.dart | 232 ++++++++++++++-------- lib/state/navigation_state.dart | 282 +++++++++++++++++++++++++++ lib/widgets/map_view.dart | 29 +++ lib/widgets/navigation_sheet.dart | 314 ++++++++++++++++++++++++++++++ lib/widgets/provisional_pin.dart | 60 ++++++ lib/widgets/search_bar.dart | 40 +++- 7 files changed, 945 insertions(+), 90 deletions(-) create mode 100644 lib/state/navigation_state.dart create mode 100644 lib/widgets/navigation_sheet.dart create mode 100644 lib/widgets/provisional_pin.dart 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), ], ); }, From 763fa31266cf3c779e69e54865fba5e7b96c0755 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 17:23:17 -0500 Subject: [PATCH 02/12] Moving the right direction I think --- lib/screens/home_screen.dart | 11 +--- lib/state/navigation_state.dart | 42 +++++++++++++-- lib/widgets/map/map_overlays.dart | 90 ++++++++++++++++++------------- lib/widgets/map_view.dart | 3 ++ lib/widgets/search_bar.dart | 17 +++--- 5 files changed, 106 insertions(+), 57 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index ce3d77a..0854610 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -356,6 +356,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { sheetHeight: activeSheetHeight, selectedNodeId: _selectedNodeId, onNodeTap: openNodeTagSheet, + onSearchPressed: _onNavigationButtonPressed, onUserGesture: () { if (appState.followMeMode != FollowMeMode.off) { appState.setFollowMeMode(FollowMeMode.off); @@ -438,16 +439,6 @@ class _HomeScreenState extends State with TickerProviderStateMixin { ), ), ), - // 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 index 18a58c9..41cdbea 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -109,7 +109,17 @@ class NavigationState extends ChangeNotifier { _provisionalPinLocation = null; _provisionalPinAddress = null; _clearSearchResults(); - debugPrint('[NavigationState] Cancelled search mode'); + + // Also clear any partial route data + if (_routeStart != null && _routeEnd == null) { + _routeStart = null; + _routeStartAddress = null; + } else if (_routeEnd != null && _routeStart == null) { + _routeEnd = null; + _routeEndAddress = null; + } + + debugPrint('[NavigationState] Cancelled search mode - cleaned up provisional pin'); notifyListeners(); } @@ -137,15 +147,32 @@ class NavigationState extends ChangeNotifier { /// Start route setup (user clicked "route to" or "route from") void startRouteSetup({required bool settingStart}) { - if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) return; + debugPrint('[NavigationState] startRouteSetup called - settingStart: $settingStart, mode: $_mode, location: $_provisionalPinLocation'); + + if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) { + debugPrint('[NavigationState] startRouteSetup - early return'); + return; + } + + // Clear any previous route data + _routeStart = null; + _routeEnd = null; + _routeStartAddress = null; + _routeEndAddress = null; + _routePath = null; + _routeDistance = null; _settingRouteStart = settingStart; if (settingStart) { + // "Route From" - this location is the START, we need to pick END _routeStart = _provisionalPinLocation; _routeStartAddress = _provisionalPinAddress; + debugPrint('[NavigationState] Set route start: $_routeStart'); } else { + // "Route To" - this location is the END, we need to pick START _routeEnd = _provisionalPinLocation; _routeEndAddress = _provisionalPinAddress; + debugPrint('[NavigationState] Set route end: $_routeEnd'); } _mode = AppNavigationMode.routeSetup; @@ -156,16 +183,25 @@ class NavigationState extends ChangeNotifier { /// Lock in second route location void selectRouteLocation() { - if (_mode != AppNavigationMode.routeSetup || _provisionalPinLocation == null) return; + debugPrint('[NavigationState] selectRouteLocation called - mode: $_mode, provisional: $_provisionalPinLocation'); + + if (_mode != AppNavigationMode.routeSetup || _provisionalPinLocation == null) { + debugPrint('[NavigationState] selectRouteLocation - early return (mode: $_mode, location: $_provisionalPinLocation)'); + return; + } if (_settingRouteStart) { _routeStart = _provisionalPinLocation; _routeStartAddress = _provisionalPinAddress; + debugPrint('[NavigationState] Set route start: $_routeStart'); } else { _routeEnd = _provisionalPinLocation; _routeEndAddress = _provisionalPinAddress; + debugPrint('[NavigationState] Set route end: $_routeEnd'); } + debugPrint('[NavigationState] Route points - start: $_routeStart, end: $_routeEnd'); + // Start route calculation _calculateRoute(); } diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 53b39fa..c357116 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:provider/provider.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; @@ -13,6 +14,7 @@ class MapOverlays extends StatelessWidget { final AddNodeSession? session; final EditNodeSession? editSession; final String? attribution; // Attribution for current tile provider + final VoidCallback? onSearchPressed; // Callback for search button const MapOverlays({ super.key, @@ -21,6 +23,7 @@ class MapOverlays extends StatelessWidget { this.session, this.editSession, this.attribution, + this.onSearchPressed, }); @override @@ -113,45 +116,58 @@ class MapOverlays extends StatelessWidget { Positioned( bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, MediaQuery.of(context).padding.bottom), right: 16, - child: Column( - children: [ - // Layer selector button - const LayerSelectorButton(), - const SizedBox(height: 8), - // Zoom in button - FloatingActionButton( - mini: true, - heroTag: "zoom_in", - onPressed: () { - try { - final zoom = mapController.camera.zoom; - mapController.move(mapController.camera.center, zoom + 1); - } catch (_) { - // Map controller not ready yet - } - }, - child: const Icon(Icons.add), - ), - const SizedBox(height: 8), - // Zoom out button - FloatingActionButton( - mini: true, - heroTag: "zoom_out", - onPressed: () { - try { - final zoom = mapController.camera.zoom; - mapController.move(mapController.camera.center, zoom - 1); - } catch (_) { - // Map controller not ready yet - } - }, - child: const Icon(Icons.remove), - ), - ], + child: Consumer( + builder: (context, appState, child) { + return Column( + children: [ + // Search/Route button (top of controls) + if (onSearchPressed != null) + FloatingActionButton( + mini: true, + heroTag: "search_nav", + onPressed: onSearchPressed, + tooltip: appState.hasActiveRoute ? 'Route Overview' : 'Search Location', + child: Icon(appState.hasActiveRoute ? Icons.route : Icons.search), + ), + if (onSearchPressed != null) const SizedBox(height: 8), + + // Layer selector button + const LayerSelectorButton(), + const SizedBox(height: 8), + // Zoom in button + FloatingActionButton( + mini: true, + heroTag: "zoom_in", + onPressed: () { + try { + final zoom = mapController.camera.zoom; + mapController.move(mapController.camera.center, zoom + 1); + } catch (_) { + // Map controller not ready yet + } + }, + child: const Icon(Icons.add), + ), + const SizedBox(height: 8), + // Zoom out button + FloatingActionButton( + mini: true, + heroTag: "zoom_out", + onPressed: () { + try { + final zoom = mapController.camera.zoom; + mapController.move(mapController.camera.center, zoom - 1); + } catch (_) { + // Map controller not ready yet + } + }, + child: const Icon(Icons.remove), + ), + ], + ); + }, ), ), - - ], ); } diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 10530fc..ef7f573 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -38,6 +38,7 @@ class MapView extends StatefulWidget { this.sheetHeight = 0.0, this.selectedNodeId, this.onNodeTap, + this.onSearchPressed, }); final FollowMeMode followMeMode; @@ -45,6 +46,7 @@ class MapView extends StatefulWidget { final double sheetHeight; final int? selectedNodeId; final void Function(OsmNode)? onNodeTap; + final VoidCallback? onSearchPressed; @override State createState() => MapViewState(); @@ -497,6 +499,7 @@ class MapViewState extends State { session: session, editSession: editSession, attribution: appState.selectedTileType?.attribution, + onSearchPressed: widget.onSearchPressed, ), // Network status indicator (top-left) - conditionally shown diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart index 5bd7c45..e5c6941 100644 --- a/lib/widgets/search_bar.dart +++ b/lib/widgets/search_bar.dart @@ -180,22 +180,25 @@ class _LocationSearchBarState extends State { focusNode: _focusNode, decoration: InputDecoration( hintText: 'Search places or coordinates...', - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( + prefixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ - if (_controller.text.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: _onClear, - ), IconButton( icon: const Icon(Icons.close), onPressed: _onCancel, tooltip: 'Cancel search', ), + const Icon(Icons.search), ], ), + prefixIconConstraints: const BoxConstraints(minWidth: 80), + suffixIcon: _controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: _onClear, + tooltip: 'Clear text', + ) + : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none, From bac033528cd5a45dc613a745beff03e4084997e8 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 17:39:29 -0500 Subject: [PATCH 03/12] UX actually close --- lib/screens/home_screen.dart | 8 ++++++- lib/state/navigation_state.dart | 38 +++++++++++++++++++------------ lib/widgets/map/map_overlays.dart | 7 +++--- lib/widgets/navigation_sheet.dart | 2 ++ 4 files changed, 36 insertions(+), 19 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0854610..df2a8fe 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -189,16 +189,22 @@ class _HomeScreenState extends State with TickerProviderStateMixin { void _onNavigationButtonPressed() { final appState = context.read(); + debugPrint('[HomeScreen] Navigation button pressed - hasActiveRoute: ${appState.hasActiveRoute}, navigationMode: ${appState.navigationMode}'); + if (appState.hasActiveRoute) { // Route button - view route overview + debugPrint('[HomeScreen] Viewing route overview'); appState.viewRouteOverview(); } else { // Search button - enter search mode + debugPrint('[HomeScreen] Entering search mode'); try { final mapCenter = _mapController.mapController.camera.center; + debugPrint('[HomeScreen] Map center: $mapCenter'); appState.enterSearchMode(mapCenter); - } catch (_) { + } catch (e) { // Controller not ready, use fallback location + debugPrint('[HomeScreen] Map controller not ready: $e, using fallback'); appState.enterSearchMode(LatLng(37.7749, -122.4194)); } } diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index 41cdbea..efb63cf 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -73,7 +73,12 @@ class NavigationState extends ChangeNotifier { /// Enter search mode with provisional pin at current map center void enterSearchMode(LatLng mapCenter) { - if (_mode != AppNavigationMode.normal) return; + debugPrint('[NavigationState] enterSearchMode called - current mode: $_mode, mapCenter: $mapCenter'); + + if (_mode != AppNavigationMode.normal) { + debugPrint('[NavigationState] Cannot enter search mode - current mode is $_mode (not normal)'); + return; + } _mode = AppNavigationMode.search; _provisionalPinLocation = mapCenter; @@ -103,6 +108,8 @@ class NavigationState extends ChangeNotifier { /// Cancel search mode and return to normal void cancelSearchMode() { + debugPrint('[NavigationState] cancelSearchMode called - mode: $_mode, isInSearch: $isInSearchMode, isInRoute: $isInRouteMode'); + if (!isInSearchMode && _mode != AppNavigationMode.routeSetup) return; _mode = AppNavigationMode.normal; @@ -110,16 +117,16 @@ class NavigationState extends ChangeNotifier { _provisionalPinAddress = null; _clearSearchResults(); - // Also clear any partial route data - if (_routeStart != null && _routeEnd == null) { - _routeStart = null; - _routeStartAddress = null; - } else if (_routeEnd != null && _routeStart == null) { - _routeEnd = null; - _routeEndAddress = null; - } + // Clear ALL route data when canceling + _routeStart = null; + _routeEnd = null; + _routeStartAddress = null; + _routeEndAddress = null; + _routePath = null; + _routeDistance = null; + _settingRouteStart = true; - debugPrint('[NavigationState] Cancelled search mode - cleaned up provisional pin'); + debugPrint('[NavigationState] Cancelled search mode - cleaned up all data'); notifyListeners(); } @@ -162,17 +169,18 @@ class NavigationState extends ChangeNotifier { _routePath = null; _routeDistance = null; - _settingRouteStart = settingStart; if (settingStart) { - // "Route From" - this location is the START, we need to pick END + // "Route From" - this location is the START, now we need to pick END _routeStart = _provisionalPinLocation; _routeStartAddress = _provisionalPinAddress; - debugPrint('[NavigationState] Set route start: $_routeStart'); + _settingRouteStart = false; // Next, we'll be setting the END + debugPrint('[NavigationState] Set route start: $_routeStart, next will set END'); } else { - // "Route To" - this location is the END, we need to pick START + // "Route To" - this location is the END, now we need to pick START _routeEnd = _provisionalPinLocation; _routeEndAddress = _provisionalPinAddress; - debugPrint('[NavigationState] Set route end: $_routeEnd'); + _settingRouteStart = true; // Next, we'll be setting the START + debugPrint('[NavigationState] Set route end: $_routeEnd, next will set START'); } _mode = AppNavigationMode.routeSetup; diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index c357116..49f0d75 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -120,8 +120,8 @@ class MapOverlays extends StatelessWidget { builder: (context, appState, child) { return Column( children: [ - // Search/Route button (top of controls) - if (onSearchPressed != null) + // Search/Route button (top of controls) - hide when in search/route modes + if (onSearchPressed != null && !appState.isInSearchMode && !appState.isInRouteMode) FloatingActionButton( mini: true, heroTag: "search_nav", @@ -129,7 +129,8 @@ class MapOverlays extends StatelessWidget { tooltip: appState.hasActiveRoute ? 'Route Overview' : 'Search Location', child: Icon(appState.hasActiveRoute ? Icons.route : Icons.search), ), - if (onSearchPressed != null) const SizedBox(height: 8), + if (onSearchPressed != null && !appState.isInSearchMode && !appState.isInRouteMode) + const SizedBox(height: 8), // Layer selector button const LayerSelectorButton(), diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index 23e6a3c..6dddb81 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:latlong2/latlong.dart'; @@ -171,6 +172,7 @@ class NavigationSheet extends StatelessWidget { icon: const Icon(Icons.check), label: const Text('Select Location'), onPressed: () { + debugPrint('[NavigationSheet] Select Location button pressed'); appState.selectRouteLocation(); }, ), From 19de2324840c637cb0c237946f0dff006cd73736 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 18:29:17 -0500 Subject: [PATCH 04/12] Better pins, bugfixes --- lib/app_state.dart | 37 +-- lib/screens/home_screen.dart | 20 +- lib/state/navigation_state.dart | 250 +++++++++---------- lib/widgets/map/map_overlays.dart | 10 +- lib/widgets/map_view.dart | 31 ++- lib/widgets/navigation_sheet.dart | 388 ++++++++++++------------------ lib/widgets/provisional_pin.dart | 76 ++++-- 7 files changed, 391 insertions(+), 421 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index c3fd170..17df856 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -73,7 +73,7 @@ class AppState extends ChangeNotifier { bool get isLoggedIn => _authState.isLoggedIn; String get username => _authState.username; - // Navigation state + // Navigation state - simplified AppNavigationMode get navigationMode => _navigationState.mode; LatLng? get provisionalPinLocation => _navigationState.provisionalPinLocation; String? get provisionalPinAddress => _navigationState.provisionalPinAddress; @@ -81,6 +81,8 @@ class AppState extends ChangeNotifier { bool get isInSearchMode => _navigationState.isInSearchMode; bool get isInRouteMode => _navigationState.isInRouteMode; bool get hasActiveRoute => _navigationState.hasActiveRoute; + bool get showSearchButton => _navigationState.showSearchButton; + bool get showRouteButton => _navigationState.showRouteButton; List? get routePath => _navigationState.routePath; // Route state @@ -90,6 +92,9 @@ class AppState extends ChangeNotifier { String? get routeEndAddress => _navigationState.routeEndAddress; double? get routeDistance => _navigationState.routeDistance; bool get settingRouteStart => _navigationState.settingRouteStart; + bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint; + bool get isCalculating => _navigationState.isCalculating; + bool get showingOverview => _navigationState.showingOverview; // Navigation search state bool get isNavigationSearchLoading => _navigationState.isSearchLoading; @@ -277,13 +282,13 @@ class AppState extends ChangeNotifier { _searchState.clearResults(); } - // ---------- Navigation Methods ---------- + // ---------- Navigation Methods - Simplified ---------- void enterSearchMode(LatLng mapCenter) { _navigationState.enterSearchMode(mapCenter); } - void cancelSearchMode() { - _navigationState.cancelSearchMode(); + void cancelNavigation() { + _navigationState.cancel(); } void updateProvisionalPinLocation(LatLng newLocation) { @@ -294,30 +299,30 @@ class AppState extends ChangeNotifier { _navigationState.selectSearchResult(result); } - void startRouteSetup({required bool settingStart}) { - _navigationState.startRouteSetup(settingStart: settingStart); + void startRoutePlanning({required bool thisLocationIsStart}) { + _navigationState.startRoutePlanning(thisLocationIsStart: thisLocationIsStart); } - void selectRouteLocation() { - _navigationState.selectRouteLocation(); + void selectSecondRoutePoint() { + _navigationState.selectSecondRoutePoint(); } void startRoute() { _navigationState.startRoute(); } + void showRouteOverview() { + _navigationState.showRouteOverview(); + } + + void hideRouteOverview() { + _navigationState.hideRouteOverview(); + } + void cancelRoute() { _navigationState.cancelRoute(); } - void viewRouteOverview() { - _navigationState.viewRouteOverview(); - } - - void returnToActiveRoute() { - _navigationState.returnToActiveRoute(); - } - // Navigation search methods Future searchNavigation(String query) async { await _navigationState.search(query); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index df2a8fe..1fefa29 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -189,12 +189,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin { void _onNavigationButtonPressed() { final appState = context.read(); - debugPrint('[HomeScreen] Navigation button pressed - hasActiveRoute: ${appState.hasActiveRoute}, navigationMode: ${appState.navigationMode}'); + debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}'); - if (appState.hasActiveRoute) { - // Route button - view route overview - debugPrint('[HomeScreen] Viewing route overview'); - appState.viewRouteOverview(); + if (appState.showRouteButton) { + // Route button - show route overview + debugPrint('[HomeScreen] Showing route overview'); + appState.showRouteOverview(); } else { // Search button - enter search mode debugPrint('[HomeScreen] Entering search mode'); @@ -299,11 +299,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _editSheetShown = false; } - // Auto-open navigation sheet during search/route modes - if ((appState.isInSearchMode || appState.isInRouteMode) && !_navigationSheetShown) { + // Auto-open navigation sheet when needed - simplified logic + final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview; + if (shouldShowNavSheet && !_navigationSheetShown) { _navigationSheetShown = true; WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet()); - } else if (!appState.isInSearchMode && !appState.isInRouteMode) { + } else if (!shouldShowNavSheet) { _navigationSheetShown = false; } @@ -323,6 +324,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { child: Scaffold( key: _scaffoldKey, appBar: AppBar( + automaticallyImplyLeading: false, // Disable automatic back button title: SvgPicture.asset( 'assets/deflock-logo.svg', height: 28, @@ -377,7 +379,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { right: 0, child: LocationSearchBar( onResultSelected: _onSearchResultSelected, - onCancel: () => appState.cancelSearchMode(), + onCancel: () => appState.cancelNavigation(), ), ), // Bottom button bar (restored to original) diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index efb63cf..7bc5339 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -4,31 +4,31 @@ import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; import '../services/search_service.dart'; -/// Navigation modes for routing and search functionality +/// Simplified navigation modes - brutalist approach 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 + normal, // Regular map view + search, // Search/routing UI active + routeActive, // Following a route } -/// Manages all navigation, search, and routing state +/// Simplified navigation state - fewer modes, clearer logic class NavigationState extends ChangeNotifier { final SearchService _searchService = SearchService(); + // Core state - just 3 modes AppNavigationMode _mode = AppNavigationMode.normal; + // Simple flags instead of complex sub-states + bool _isSettingSecondPoint = false; + bool _isCalculating = false; + bool _showingOverview = false; + // Search state bool _isSearchLoading = false; List _searchResults = []; String _lastQuery = ''; - List _searchHistory = []; - // Provisional pin state (for route planning) + // Location state LatLng? _provisionalPinLocation; String? _provisionalPinAddress; @@ -39,14 +39,17 @@ class NavigationState extends ChangeNotifier { String? _routeEndAddress; List? _routePath; double? _routeDistance; - bool _settingRouteStart = true; // true = setting start, false = setting end + bool _nextPointIsStart = false; // What we're setting next // Getters AppNavigationMode get mode => _mode; + bool get isSettingSecondPoint => _isSettingSecondPoint; + bool get isCalculating => _isCalculating; + bool get showingOverview => _showingOverview; + 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; @@ -57,26 +60,22 @@ class NavigationState extends ChangeNotifier { String? get routeEndAddress => _routeEndAddress; List? get routePath => _routePath != null ? List.unmodifiable(_routePath!) : null; double? get routeDistance => _routeDistance; - bool get settingRouteStart => _settingRouteStart; + bool get settingRouteStart => _nextPointIsStart; // For sheet display compatibility - // 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); + // Simplified convenience getters + bool get isInSearchMode => _mode == AppNavigationMode.search; + bool get isInRouteMode => _mode == AppNavigationMode.routeActive; + bool get hasActiveRoute => _routePath != null && _mode == AppNavigationMode.routeActive; + bool get showProvisionalPin => _provisionalPinLocation != null && (_mode == AppNavigationMode.search); + bool get showSearchButton => _mode == AppNavigationMode.normal; + bool get showRouteButton => _mode == AppNavigationMode.routeActive; - /// Enter search mode with provisional pin at current map center + /// BRUTALIST: Single entry point to search mode void enterSearchMode(LatLng mapCenter) { - debugPrint('[NavigationState] enterSearchMode called - current mode: $_mode, mapCenter: $mapCenter'); + debugPrint('[NavigationState] enterSearchMode - current mode: $_mode'); if (_mode != AppNavigationMode.normal) { - debugPrint('[NavigationState] Cannot enter search mode - current mode is $_mode (not normal)'); + debugPrint('[NavigationState] Cannot enter search mode - not in normal mode'); return; } @@ -84,82 +83,70 @@ class NavigationState extends ChangeNotifier { _provisionalPinLocation = mapCenter; _provisionalPinAddress = null; _clearSearchResults(); - debugPrint('[NavigationState] Entered search mode at $mapCenter'); + + debugPrint('[NavigationState] Entered search mode'); 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() { - debugPrint('[NavigationState] cancelSearchMode called - mode: $_mode, isInSearch: $isInSearchMode, isInRoute: $isInRouteMode'); - - if (!isInSearchMode && _mode != AppNavigationMode.routeSetup) return; + /// BRUTALIST: Single cancellation method - cleans up EVERYTHING + void cancel() { + debugPrint('[NavigationState] cancel() - cleaning up all state'); _mode = AppNavigationMode.normal; + + // Clear ALL provisional data _provisionalPinLocation = null; _provisionalPinAddress = null; + + // Clear ALL route data (except active route) + if (_mode != AppNavigationMode.routeActive) { + _routeStart = null; + _routeEnd = null; + _routeStartAddress = null; + _routeEndAddress = null; + _routePath = null; + _routeDistance = null; + } + + // Reset ALL flags + _isSettingSecondPoint = false; + _isCalculating = false; + _showingOverview = false; + _nextPointIsStart = false; + + // Clear search _clearSearchResults(); - // Clear ALL route data when canceling - _routeStart = null; - _routeEnd = null; - _routeStartAddress = null; - _routeEndAddress = null; - _routePath = null; - _routeDistance = null; - _settingRouteStart = true; - - debugPrint('[NavigationState] Cancelled search mode - cleaned up all data'); + debugPrint('[NavigationState] Everything cleaned up'); notifyListeners(); } - /// Update provisional pin location (when map moves during search) + /// Update provisional pin when map moves void updateProvisionalPinLocation(LatLng newLocation) { if (!showProvisionalPin) return; _provisionalPinLocation = newLocation; - // Clear address since location changed - _provisionalPinAddress = null; + _provisionalPinAddress = null; // Clear address when location changes notifyListeners(); } - /// Jump to search result and update provisional pin + /// Jump to search result void selectSearchResult(SearchResult result) { - if (!isInSearchMode) return; + if (_mode != AppNavigationMode.search) 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}) { - debugPrint('[NavigationState] startRouteSetup called - settingStart: $settingStart, mode: $_mode, location: $_provisionalPinLocation'); + /// Start route planning - simplified logic + void startRoutePlanning({required bool thisLocationIsStart}) { + if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) return; - if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) { - debugPrint('[NavigationState] startRouteSetup - early return'); - return; - } + debugPrint('[NavigationState] Starting route planning - thisLocationIsStart: $thisLocationIsStart'); // Clear any previous route data _routeStart = null; @@ -169,118 +156,104 @@ class NavigationState extends ChangeNotifier { _routePath = null; _routeDistance = null; - if (settingStart) { - // "Route From" - this location is the START, now we need to pick END + // Set the current location as start or end + if (thisLocationIsStart) { _routeStart = _provisionalPinLocation; _routeStartAddress = _provisionalPinAddress; - _settingRouteStart = false; // Next, we'll be setting the END - debugPrint('[NavigationState] Set route start: $_routeStart, next will set END'); + _nextPointIsStart = false; // Next we'll set the END + debugPrint('[NavigationState] Set route start, next setting END'); } else { - // "Route To" - this location is the END, now we need to pick START _routeEnd = _provisionalPinLocation; _routeEndAddress = _provisionalPinAddress; - _settingRouteStart = true; // Next, we'll be setting the START - debugPrint('[NavigationState] Set route end: $_routeEnd, next will set START'); + _nextPointIsStart = true; // Next we'll set the START + debugPrint('[NavigationState] Set route end, next setting START'); } - _mode = AppNavigationMode.routeSetup; - // Keep provisional pin active for second location - debugPrint('[NavigationState] Started route setup (setting ${settingStart ? 'start' : 'end'})'); + // Enter second point selection mode + _isSettingSecondPoint = true; notifyListeners(); } - /// Lock in second route location - void selectRouteLocation() { - debugPrint('[NavigationState] selectRouteLocation called - mode: $_mode, provisional: $_provisionalPinLocation'); + /// Select the second route point + void selectSecondRoutePoint() { + if (!_isSettingSecondPoint || _provisionalPinLocation == null) return; - if (_mode != AppNavigationMode.routeSetup || _provisionalPinLocation == null) { - debugPrint('[NavigationState] selectRouteLocation - early return (mode: $_mode, location: $_provisionalPinLocation)'); - return; - } + debugPrint('[NavigationState] Selecting second route point - nextPointIsStart: $_nextPointIsStart'); - if (_settingRouteStart) { + // Set the second point + if (_nextPointIsStart) { _routeStart = _provisionalPinLocation; _routeStartAddress = _provisionalPinAddress; - debugPrint('[NavigationState] Set route start: $_routeStart'); } else { _routeEnd = _provisionalPinLocation; _routeEndAddress = _provisionalPinAddress; - debugPrint('[NavigationState] Set route end: $_routeEnd'); } - debugPrint('[NavigationState] Route points - start: $_routeStart, end: $_routeEnd'); - - // Start route calculation + _isSettingSecondPoint = false; _calculateRoute(); } - /// Calculate route (mock implementation for now) + /// Calculate route void _calculateRoute() { if (_routeStart == null || _routeEnd == null) return; - _mode = AppNavigationMode.routeCalculating; + debugPrint('[NavigationState] Calculating route...'); + _isCalculating = true; notifyListeners(); - // Mock route calculation with delay - Future.delayed(const Duration(seconds: 2), () { - if (_mode != AppNavigationMode.routeCalculating) return; + // Mock route calculation + Future.delayed(const Duration(seconds: 1), () { + if (!_isCalculating) return; // Canceled - // Create simple straight line route for now _routePath = [_routeStart!, _routeEnd!]; _routeDistance = const Distance().as(LengthUnit.Meter, _routeStart!, _routeEnd!); - - _mode = AppNavigationMode.routePreview; + _isCalculating = false; + _showingOverview = true; _provisionalPinLocation = null; // Hide provisional pin - debugPrint('[NavigationState] Route calculated: ${_routeDistance! / 1000.0} km'); + + debugPrint('[NavigationState] Route calculated: ${(_routeDistance! / 1000).toStringAsFixed(1)} km'); notifyListeners(); }); } /// Start following the route void startRoute() { - if (_mode != AppNavigationMode.routePreview || _routePath == null) return; + if (_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; + _showingOverview = false; - _mode = AppNavigationMode.routeOverview; - debugPrint('[NavigationState] Viewing route overview'); + debugPrint('[NavigationState] Started following route'); notifyListeners(); } - /// Return to active route from overview - void returnToActiveRoute() { - if (_mode != AppNavigationMode.routeOverview) return; + /// Show route overview (from route button during active navigation) + void showRouteOverview() { + if (_mode != AppNavigationMode.routeActive) return; - _mode = AppNavigationMode.routeActive; - debugPrint('[NavigationState] Returned to active route'); + _showingOverview = true; + debugPrint('[NavigationState] Showing route overview'); notifyListeners(); } - /// Cancel route and return to normal mode + /// Hide route overview (back to active navigation) + void hideRouteOverview() { + if (_mode != AppNavigationMode.routeActive) return; + + _showingOverview = false; + debugPrint('[NavigationState] Hiding route overview'); + notifyListeners(); + } + + /// Cancel active route and return to normal void cancelRoute() { - if (!isInRouteMode) return; + if (_mode != AppNavigationMode.routeActive) 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(); + debugPrint('[NavigationState] Canceling active route'); + cancel(); // Use the brutalist single cleanup method } - /// Search functionality (delegates to existing search service) + /// Search functionality Future search(String query) async { if (query.trim().isEmpty) { _clearSearchResults(); @@ -295,7 +268,7 @@ class NavigationState extends ChangeNotifier { try { final results = await _searchService.search(query.trim()); _searchResults = results; - debugPrint('[NavigationState] Found ${results.length} results for "$query"'); + debugPrint('[NavigationState] Found ${results.length} results'); } catch (e) { debugPrint('[NavigationState] Search failed: $e'); _searchResults = []; @@ -304,7 +277,6 @@ class NavigationState extends ChangeNotifier { _setSearchLoading(false); } - /// Clear search results void clearSearchResults() { _clearSearchResults(); } diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 49f0d75..b945ce5 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -120,16 +120,16 @@ class MapOverlays extends StatelessWidget { builder: (context, appState, child) { return Column( children: [ - // Search/Route button (top of controls) - hide when in search/route modes - if (onSearchPressed != null && !appState.isInSearchMode && !appState.isInRouteMode) + // Navigation button - simplified logic + if (onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) FloatingActionButton( mini: true, heroTag: "search_nav", onPressed: onSearchPressed, - tooltip: appState.hasActiveRoute ? 'Route Overview' : 'Search Location', - child: Icon(appState.hasActiveRoute ? Icons.route : Icons.search), + tooltip: appState.showRouteButton ? 'Route Overview' : 'Search Location', + child: Icon(appState.showRouteButton ? Icons.route : Icons.search), ), - if (onSearchPressed != null && !appState.isInSearchMode && !appState.isInRouteMode) + if (onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) const SizedBox(height: 8), // Layer selector button diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index ef7f573..965e5e0 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -382,16 +382,41 @@ class MapViewState extends State { centerMarkers.add( Marker( point: appState.provisionalPinLocation!, - width: 48.0, - height: 48.0, + width: 32.0, + height: 32.0, child: const ProvisionalPin(), ), ); } + // Build start/end pins for route visualization + if (appState.showingOverview || appState.isInRouteMode) { + if (appState.routeStart != null) { + centerMarkers.add( + Marker( + point: appState.routeStart!, + width: 32.0, + height: 32.0, + child: const LocationPin(type: PinType.start), + ), + ); + } + if (appState.routeEnd != null) { + centerMarkers.add( + Marker( + point: appState.routeEnd!, + width: 32.0, + height: 32.0, + child: const LocationPin(type: PinType.end), + ), + ); + } + } + // Build route path visualization final routeLines = []; - if (appState.routePath != null && appState.routePath!.length > 1) { + if (appState.routePath != null && appState.routePath!.length > 1 && + (appState.showingOverview || appState.isInRouteMode)) { routeLines.add(Polyline( points: appState.routePath!, color: Colors.blue, diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index 6dddb81..eff872e 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -50,6 +50,20 @@ class NavigationSheet extends StatelessWidget { ); } + Widget _buildDragHandle() { + return Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + @override Widget build(BuildContext context) { return Consumer( @@ -58,258 +72,176 @@ class NavigationSheet extends StatelessWidget { final provisionalLocation = appState.provisionalPinLocation; final provisionalAddress = appState.provisionalPinAddress; - if (provisionalLocation == null) { + if (provisionalLocation == null && !appState.showingOverview) { 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), + return Container( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + + // SEARCH MODE: Initial location with route options + if (navigationMode == AppNavigationMode.search && !appState.isSettingSecondPoint && !appState.isCalculating && !appState.showingOverview && provisionalLocation != null) ...[ + _buildLocationInfo( + label: 'Location', + coordinates: provisionalLocation, + address: provisionalAddress, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.directions), + label: const Text('Route To'), + onPressed: () { + appState.startRoutePlanning(thisLocationIsStart: false); + }, ), ), - ), - - // Location info + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.my_location), + label: const Text('Route From'), + onPressed: () { + appState.startRoutePlanning(thisLocationIsStart: true); + }, + ), + ), + ], + ), + ], + + // SETTING SECOND POINT: Show both points and select button + if (appState.isSettingSecondPoint && provisionalLocation != null) ...[ + // Show existing route points + if (appState.routeStart != null) ...[ _buildLocationInfo( - label: 'Location', - coordinates: provisionalLocation, - address: provisionalAddress, + 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), + ], + + // Show the point we're selecting + _buildLocationInfo( + label: appState.settingRouteStart ? 'Start (select)' : 'End (select)', + coordinates: provisionalLocation, + address: provisionalAddress, + ), + const SizedBox(height: 16), + + ElevatedButton.icon( + icon: const Icon(Icons.check), + label: const Text('Select Location'), + onPressed: () { + debugPrint('[NavigationSheet] Select Location button pressed'); + appState.selectSecondRoutePoint(); + }, + ), + ], + + // CALCULATING: Show loading + if (appState.isCalculating) ...[ + const Center( + child: SizedBox( + width: 40, + height: 40, + child: CircularProgressIndicator(), + ), + ), + const SizedBox(height: 16), + const Text('Calculating route...', textAlign: TextAlign.center), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => appState.cancelNavigation(), + child: const Text('Cancel'), + ), + ], + + // ROUTE OVERVIEW: Show route details with start/cancel options + if (appState.showingOverview) ...[ + 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), + ], + 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: [ + ], + + Row( + children: [ + if (navigationMode == AppNavigationMode.search) ...[ + // Route preview mode - start or cancel Expanded( child: ElevatedButton.icon( - icon: const Icon(Icons.directions), - label: const Text('Route To'), - onPressed: () { - appState.startRouteSetup(settingStart: false); - }, + icon: const Icon(Icons.play_arrow), + label: const Text('Start'), + onPressed: () => appState.startRoute(), ), ), const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( - icon: const Icon(Icons.my_location), - label: const Text('Route From'), - onPressed: () { - appState.startRouteSetup(settingStart: true); - }, + icon: const Icon(Icons.close), + label: const Text('Cancel'), + onPressed: () => appState.cancelNavigation(), ), ), - ], - ), - ], - ), - ); - - 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: () { - debugPrint('[NavigationSheet] Select Location button pressed'); - 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(); - }, - ), + ] else if (navigationMode == AppNavigationMode.routeActive) ...[ + // Active route overview - resume or cancel + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: const Text('Resume'), + onPressed: () => appState.hideRouteOverview(), ), - 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), - ], + ), + const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.close), - label: const Text('Cancel'), - onPressed: () { - appState.cancelRoute(); - }, + label: const Text('End Route'), + onPressed: () => appState.cancelRoute(), ), ), ], - ), - ], - ), - ); - - default: - return const SizedBox.shrink(); - } + ], + ), + ], + ], + ), + ); }, ); } diff --git a/lib/widgets/provisional_pin.dart b/lib/widgets/provisional_pin.dart index 5e08d89..2757dc1 100644 --- a/lib/widgets/provisional_pin.dart +++ b/lib/widgets/provisional_pin.dart @@ -1,19 +1,36 @@ import 'package:flutter/material.dart'; -/// A pin icon for marking provisional locations during search/routing -class ProvisionalPin extends StatelessWidget { +enum PinType { + provisional, // Orange - current selection + start, // Green - route start + end, // Red - route end +} + +/// A thumbtack-style pin for marking locations during search/routing +class LocationPin extends StatelessWidget { + final PinType type; final double size; - final Color color; - const ProvisionalPin({ + const LocationPin({ super.key, - this.size = 48.0, - this.color = Colors.red, + required this.type, + this.size = 32.0, // Smaller than before }); - + + Color get _pinColor { + switch (type) { + case PinType.provisional: + return Colors.orange; + case PinType.start: + return Colors.green; + case PinType.end: + return Colors.red; + } + } + @override Widget build(BuildContext context) { - return Container( + return SizedBox( width: size, height: size, child: Stack( @@ -21,34 +38,34 @@ class ProvisionalPin extends StatelessWidget { children: [ // Pin shadow Positioned( - bottom: 0, + bottom: 2, child: Container( - width: size * 0.3, - height: size * 0.15, + width: size * 0.4, + height: size * 0.2, decoration: BoxDecoration( color: Colors.black.withOpacity(0.3), - borderRadius: BorderRadius.circular(size * 0.15), + borderRadius: BorderRadius.circular(size * 0.1), ), ), ), - // Main pin + // Main thumbtack pin Icon( - Icons.location_pin, + Icons.push_pin, size: size, - color: color, + color: _pinColor, ), - // Inner dot + // Inner dot for better visibility Positioned( - top: size * 0.15, + top: size * 0.2, child: Container( - width: size * 0.25, - height: size * 0.25, + width: size * 0.3, + height: size * 0.3, decoration: BoxDecoration( color: Colors.white, shape: BoxShape.circle, border: Border.all( - color: color.withOpacity(0.8), - width: 2, + color: _pinColor.withOpacity(0.8), + width: 1.5, ), ), ), @@ -57,4 +74,21 @@ class ProvisionalPin extends StatelessWidget { ), ); } +} + +// Legacy widget name for compatibility +class ProvisionalPin extends StatelessWidget { + final double size; + final Color color; + + const ProvisionalPin({ + super.key, + this.size = 32.0, + this.color = Colors.orange, + }); + + @override + Widget build(BuildContext context) { + return LocationPin(type: PinType.provisional, size: size); + } } \ No newline at end of file From 40c78ab3b7f335ead3168d48720812513fe23b87 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 18:51:52 -0500 Subject: [PATCH 05/12] Edge cases - UX bugs --- lib/app_state.dart | 8 ++++++++ lib/screens/home_screen.dart | 29 ++++++++++++++++++++++++++++- lib/state/navigation_state.dart | 11 +++++++++++ lib/widgets/map_view.dart | 23 +++++++++++++++-------- lib/widgets/navigation_sheet.dart | 9 +++++++-- 5 files changed, 69 insertions(+), 11 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 17df856..6a18e6a 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -309,6 +309,14 @@ class AppState extends ChangeNotifier { void startRoute() { _navigationState.startRoute(); + + // Auto-enable follow-me if user is near the start point + // We need to get user location from the GPS controller + // This will be handled in HomeScreen where we have access to MapView + } + + bool shouldAutoEnableFollowMe(LatLng? userLocation) { + return _navigationState.shouldAutoEnableFollowMe(userLocation); } void showRouteOverview() { diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 1fefa29..b918627 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -174,7 +174,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _navigationSheetHeight = height; }); }, - child: const NavigationSheet(), + child: NavigationSheet( + onStartRoute: _onStartRoute, + ), ), ); @@ -183,9 +185,34 @@ class _HomeScreenState extends State with TickerProviderStateMixin { setState(() { _navigationSheetHeight = 0.0; }); + + // If we're in route active mode and showing overview, reset the overview flag + // This fixes the stuck route button issue + final appState = context.read(); + if (appState.isInRouteMode && appState.showingOverview) { + debugPrint('[HomeScreen] Sheet dismissed during route overview - hiding overview'); + appState.hideRouteOverview(); + } }); } + void _onStartRoute() { + final appState = context.read(); + + // Get user location from MapView GPS controller and check if we should auto-enable follow-me + try { + final userLocation = _mapViewKey.currentState?.getUserLocation(); + if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) { + debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start'); + appState.setFollowMeMode(FollowMeMode.northUp); + } + } catch (e) { + debugPrint('[HomeScreen] Could not get user location for auto follow-me: $e'); + } + + appState.startRoute(); + } + void _onNavigationButtonPressed() { final appState = context.read(); diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index 7bc5339..e5af71a 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -227,6 +227,17 @@ class NavigationState extends ChangeNotifier { notifyListeners(); } + /// Check if user should auto-enable follow-me (called from outside with user location) + bool shouldAutoEnableFollowMe(LatLng? userLocation) { + if (userLocation == null || _routeStart == null) return false; + + final distanceToStart = const Distance().as(LengthUnit.Meter, userLocation, _routeStart!); + final shouldEnable = distanceToStart <= 1000; // Within 1km + + debugPrint('[NavigationState] Distance to start: ${distanceToStart.toStringAsFixed(0)}m, auto follow-me: $shouldEnable'); + return shouldEnable; + } + /// Show route overview (from route button during active navigation) void showRouteOverview() { if (_mode != AppNavigationMode.routeActive) return; diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 965e5e0..93d6942 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -203,6 +203,11 @@ class MapViewState extends State { void retryLocationInit() { _gpsController.retryLocationInit(); } + + /// Get current user location + LatLng? getUserLocation() { + return _gpsController.currentLocation; + } /// Expose static methods from MapPositionManager for external access static Future clearStoredMapPosition() => @@ -390,7 +395,7 @@ class MapViewState extends State { } // Build start/end pins for route visualization - if (appState.showingOverview || appState.isInRouteMode) { + if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) { if (appState.routeStart != null) { centerMarkers.add( Marker( @@ -415,13 +420,15 @@ class MapViewState extends State { // Build route path visualization final routeLines = []; - if (appState.routePath != null && appState.routePath!.length > 1 && - (appState.showingOverview || appState.isInRouteMode)) { - routeLines.add(Polyline( - points: appState.routePath!, - color: Colors.blue, - strokeWidth: 4.0, - )); + if (appState.routePath != null && appState.routePath!.length > 1) { + // Show route line during overview or active route + if (appState.showingOverview || appState.isInRouteMode) { + routeLines.add(Polyline( + points: appState.routePath!, + color: Colors.blue, + strokeWidth: 4.0, + )); + } } return Stack( diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index eff872e..dafd217 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -6,7 +6,12 @@ import 'package:latlong2/latlong.dart'; import '../app_state.dart'; class NavigationSheet extends StatelessWidget { - const NavigationSheet({super.key}); + final VoidCallback? onStartRoute; + + const NavigationSheet({ + super.key, + this.onStartRoute, + }); String _formatCoordinates(LatLng coordinates) { return '${coordinates.latitude.toStringAsFixed(6)}, ${coordinates.longitude.toStringAsFixed(6)}'; @@ -207,7 +212,7 @@ class NavigationSheet extends StatelessWidget { child: ElevatedButton.icon( icon: const Icon(Icons.play_arrow), label: const Text('Start'), - onPressed: () => appState.startRoute(), + onPressed: onStartRoute ?? () => appState.startRoute(), ), ), const SizedBox(width: 12), From c6db4396e4dcec2754088075f0004e1ba5426fe5 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 19:19:36 -0500 Subject: [PATCH 06/12] follow-me, sheet dismissal, zoom/centering on start --- lib/screens/home_screen.dart | 119 ++++++++++++++++++++++++++++-- lib/widgets/navigation_sheet.dart | 4 +- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index b918627..77aca96 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -176,6 +176,7 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }, child: NavigationSheet( onStartRoute: _onStartRoute, + onResumeRoute: _onResumeRoute, ), ), ); @@ -186,10 +187,15 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _navigationSheetHeight = 0.0; }); - // If we're in route active mode and showing overview, reset the overview flag - // This fixes the stuck route button issue + // Handle different dismissal scenarios final appState = context.read(); - if (appState.isInRouteMode && appState.showingOverview) { + + if (appState.isSettingSecondPoint) { + // If user dismisses sheet while setting second point, cancel everything + debugPrint('[HomeScreen] Sheet dismissed during second point selection - canceling navigation'); + appState.cancelNavigation(); + } else if (appState.isInRouteMode && appState.showingOverview) { + // If we're in route active mode and showing overview, just hide the overview debugPrint('[HomeScreen] Sheet dismissed during route overview - hiding overview'); appState.hideRouteOverview(); } @@ -199,18 +205,116 @@ class _HomeScreenState extends State with TickerProviderStateMixin { void _onStartRoute() { final appState = context.read(); - // Get user location from MapView GPS controller and check if we should auto-enable follow-me + // Get user location and check if we should auto-enable follow-me + LatLng? userLocation; + bool enableFollowMe = false; + try { - final userLocation = _mapViewKey.currentState?.getUserLocation(); + userLocation = _mapViewKey.currentState?.getUserLocation(); if (userLocation != null && appState.shouldAutoEnableFollowMe(userLocation)) { debugPrint('[HomeScreen] Auto-enabling follow-me mode - user within 1km of start'); appState.setFollowMeMode(FollowMeMode.northUp); + enableFollowMe = true; } } catch (e) { debugPrint('[HomeScreen] Could not get user location for auto follow-me: $e'); } + // Start the route appState.startRoute(); + + // Zoom to level 14 and center appropriately + _zoomAndCenterForRoute(enableFollowMe, userLocation, appState.routeStart); + } + + void _zoomAndCenterForRoute(bool followMeEnabled, LatLng? userLocation, LatLng? routeStart) { + try { + LatLng centerLocation; + + if (followMeEnabled && userLocation != null) { + // Center on user if follow-me is enabled + centerLocation = userLocation; + debugPrint('[HomeScreen] Centering on user location for route start'); + } else if (routeStart != null) { + // Center on start pin if user is far away or no GPS + centerLocation = routeStart; + debugPrint('[HomeScreen] Centering on route start pin'); + } else { + debugPrint('[HomeScreen] No valid location to center on'); + return; + } + + // Animate to zoom 14 and center location + _mapController.animateTo( + dest: centerLocation, + zoom: 14.0, + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOut, + ); + } catch (e) { + debugPrint('[HomeScreen] Could not zoom/center for route: $e'); + } + } + + void _onResumeRoute() { + final appState = context.read(); + + // Hide the overview + appState.hideRouteOverview(); + + // Zoom and center for resumed route + // For resume, we always center on user if GPS is available, otherwise start pin + LatLng? userLocation; + try { + userLocation = _mapViewKey.currentState?.getUserLocation(); + } catch (e) { + debugPrint('[HomeScreen] Could not get user location for route resume: $e'); + } + + _zoomAndCenterForRoute( + appState.followMeMode != FollowMeMode.off, // Use current follow-me state + userLocation, + appState.routeStart + ); + } + + void _zoomToShowFullRoute(AppState appState) { + if (appState.routeStart == null || appState.routeEnd == null) return; + + try { + // Calculate the bounds of the route + final start = appState.routeStart!; + final end = appState.routeEnd!; + + // Find the center point between start and end + final centerLat = (start.latitude + end.latitude) / 2; + final centerLng = (start.longitude + end.longitude) / 2; + final center = LatLng(centerLat, centerLng); + + // Calculate distance between points to determine appropriate zoom + final distance = const Distance().as(LengthUnit.Meter, start, end); + double zoom; + if (distance < 500) { + zoom = 16.0; + } else if (distance < 2000) { + zoom = 14.0; + } else if (distance < 10000) { + zoom = 12.0; + } else { + zoom = 10.0; + } + + debugPrint('[HomeScreen] Zooming to show full route - distance: ${distance.toStringAsFixed(0)}m, zoom: $zoom'); + + _mapController.animateTo( + dest: center, + zoom: zoom, + duration: const Duration(milliseconds: 800), + curve: Curves.easeInOut, + ); + } catch (e) { + debugPrint('[HomeScreen] Could not zoom to show full route: $e'); + } } void _onNavigationButtonPressed() { @@ -219,9 +323,12 @@ class _HomeScreenState extends State with TickerProviderStateMixin { debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}'); if (appState.showRouteButton) { - // Route button - show route overview + // Route button - show route overview and zoom to show route debugPrint('[HomeScreen] Showing route overview'); appState.showRouteOverview(); + + // Zoom out a bit to show the full route when viewing overview + _zoomToShowFullRoute(appState); } else { // Search button - enter search mode debugPrint('[HomeScreen] Entering search mode'); diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index dafd217..819b0de 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -7,10 +7,12 @@ import '../app_state.dart'; class NavigationSheet extends StatelessWidget { final VoidCallback? onStartRoute; + final VoidCallback? onResumeRoute; const NavigationSheet({ super.key, this.onStartRoute, + this.onResumeRoute, }); String _formatCoordinates(LatLng coordinates) { @@ -229,7 +231,7 @@ class NavigationSheet extends StatelessWidget { child: ElevatedButton.icon( icon: const Icon(Icons.play_arrow), label: const Text('Resume'), - onPressed: () => appState.hideRouteOverview(), + onPressed: onResumeRoute ?? () => appState.hideRouteOverview(), ), ), const SizedBox(width: 12), From dfb8eceaade1e535f97d3d31cf0ad59a8e75daa1 Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 20:08:21 -0500 Subject: [PATCH 07/12] Basic routing. Still some bugs. --- lib/app_state.dart | 6 ++ lib/services/routing_service.dart | 164 ++++++++++++++++++++++++++++++ lib/state/navigation_state.dart | 46 +++++++-- lib/widgets/navigation_sheet.dart | 44 ++++++++ 4 files changed, 252 insertions(+), 8 deletions(-) create mode 100644 lib/services/routing_service.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index 6a18e6a..271f3c8 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -95,6 +95,8 @@ class AppState extends ChangeNotifier { bool get isSettingSecondPoint => _navigationState.isSettingSecondPoint; bool get isCalculating => _navigationState.isCalculating; bool get showingOverview => _navigationState.showingOverview; + String? get routingError => _navigationState.routingError; + bool get hasRoutingError => _navigationState.hasRoutingError; // Navigation search state bool get isNavigationSearchLoading => _navigationState.isSearchLoading; @@ -340,6 +342,10 @@ class AppState extends ChangeNotifier { _navigationState.clearSearchResults(); } + void retryRouteCalculation() { + _navigationState.retryRouteCalculation(); + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); diff --git a/lib/services/routing_service.dart b/lib/services/routing_service.dart new file mode 100644 index 0000000..71afcc5 --- /dev/null +++ b/lib/services/routing_service.dart @@ -0,0 +1,164 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +class RouteResult { + final List waypoints; + final double distanceMeters; + final double durationSeconds; + + const RouteResult({ + required this.waypoints, + required this.distanceMeters, + required this.durationSeconds, + }); + + @override + String toString() { + return 'RouteResult(waypoints: ${waypoints.length}, distance: ${(distanceMeters/1000).toStringAsFixed(1)}km, duration: ${(durationSeconds/60).toStringAsFixed(1)}min)'; + } +} + +class RoutingService { + static const String _baseUrl = 'https://router.project-osrm.org'; + static const String _userAgent = 'DeFlock/1.0 (OSM surveillance mapping app)'; + static const Duration _timeout = Duration(seconds: 15); + + /// Calculate route between two points using OSRM + Future calculateRoute({ + required LatLng start, + required LatLng end, + String profile = 'driving', // driving, walking, cycling + }) async { + debugPrint('[RoutingService] Calculating route from $start to $end'); + + // OSRM uses lng,lat order (opposite of LatLng) + final startCoord = '${start.longitude},${start.latitude}'; + final endCoord = '${end.longitude},${end.latitude}'; + + final uri = Uri.parse('$_baseUrl/route/v1/$profile/$startCoord;$endCoord') + .replace(queryParameters: { + 'overview': 'full', // Get full geometry + 'geometries': 'polyline', // Use polyline encoding (more compact) + 'steps': 'false', // Don't need turn-by-turn for now + }); + + debugPrint('[RoutingService] OSRM request: $uri'); + + try { + final response = await http.get( + uri, + headers: { + 'User-Agent': _userAgent, + }, + ).timeout(_timeout); + + if (response.statusCode != 200) { + throw RoutingException('HTTP ${response.statusCode}: ${response.reasonPhrase}'); + } + + final data = json.decode(response.body) as Map; + + // Check OSRM response status + final code = data['code'] as String?; + if (code != 'Ok') { + final message = data['message'] as String? ?? 'Unknown routing error'; + throw RoutingException('OSRM error ($code): $message'); + } + + final routes = data['routes'] as List?; + if (routes == null || routes.isEmpty) { + throw RoutingException('No route found between these points'); + } + + final route = routes[0] as Map; + final geometry = route['geometry'] as String?; + final distance = (route['distance'] as num?)?.toDouble() ?? 0.0; + final duration = (route['duration'] as num?)?.toDouble() ?? 0.0; + + if (geometry == null) { + throw RoutingException('Route geometry missing from response'); + } + + // Decode polyline geometry to waypoints + final waypoints = _decodePolyline(geometry); + + if (waypoints.isEmpty) { + throw RoutingException('Failed to decode route geometry'); + } + + final result = RouteResult( + waypoints: waypoints, + distanceMeters: distance, + durationSeconds: duration, + ); + + debugPrint('[RoutingService] Route calculated: $result'); + return result; + + } catch (e) { + debugPrint('[RoutingService] Route calculation failed: $e'); + if (e is RoutingException) { + rethrow; + } else { + throw RoutingException('Network error: $e'); + } + } + } + + /// Decode OSRM polyline geometry to LatLng waypoints + List _decodePolyline(String encoded) { + try { + final List points = []; + int index = 0; + int lat = 0; + int lng = 0; + + while (index < encoded.length) { + int b; + int shift = 0; + int result = 0; + + // Decode latitude + do { + b = encoded.codeUnitAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + + final deltaLat = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1)); + lat += deltaLat; + + shift = 0; + result = 0; + + // Decode longitude + do { + b = encoded.codeUnitAt(index++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20); + + final deltaLng = ((result & 1) != 0 ? ~(result >> 1) : (result >> 1)); + lng += deltaLng; + + points.add(LatLng(lat / 1E5, lng / 1E5)); + } + + return points; + } catch (e) { + debugPrint('[RoutingService] Manual polyline decoding failed: $e'); + return []; + } + } +} + +class RoutingException implements Exception { + final String message; + + const RoutingException(this.message); + + @override + String toString() => 'RoutingException: $message'; +} \ No newline at end of file diff --git a/lib/state/navigation_state.dart b/lib/state/navigation_state.dart index e5af71a..072fcf9 100644 --- a/lib/state/navigation_state.dart +++ b/lib/state/navigation_state.dart @@ -3,6 +3,7 @@ import 'package:latlong2/latlong.dart'; import '../models/search_result.dart'; import '../services/search_service.dart'; +import '../services/routing_service.dart'; /// Simplified navigation modes - brutalist approach enum AppNavigationMode { @@ -14,6 +15,7 @@ enum AppNavigationMode { /// Simplified navigation state - fewer modes, clearer logic class NavigationState extends ChangeNotifier { final SearchService _searchService = SearchService(); + final RoutingService _routingService = RoutingService(); // Core state - just 3 modes AppNavigationMode _mode = AppNavigationMode.normal; @@ -22,6 +24,7 @@ class NavigationState extends ChangeNotifier { bool _isSettingSecondPoint = false; bool _isCalculating = false; bool _showingOverview = false; + String? _routingError; // Search state bool _isSearchLoading = false; @@ -46,6 +49,8 @@ class NavigationState extends ChangeNotifier { bool get isSettingSecondPoint => _isSettingSecondPoint; bool get isCalculating => _isCalculating; bool get showingOverview => _showingOverview; + String? get routingError => _routingError; + bool get hasRoutingError => _routingError != null; bool get isSearchLoading => _isSearchLoading; List get searchResults => List.unmodifiable(_searchResults); @@ -113,6 +118,7 @@ class NavigationState extends ChangeNotifier { _isCalculating = false; _showingOverview = false; _nextPointIsStart = false; + _routingError = null; // Clear search _clearSearchResults(); @@ -190,28 +196,52 @@ class NavigationState extends ChangeNotifier { } _isSettingSecondPoint = false; + _routingError = null; // Clear any previous errors _calculateRoute(); } - /// Calculate route + /// Retry route calculation (for error recovery) + void retryRouteCalculation() { + if (_routeStart == null || _routeEnd == null) return; + + debugPrint('[NavigationState] Retrying route calculation'); + _routingError = null; + _calculateRoute(); + } + + /// Calculate route using OSRM void _calculateRoute() { if (_routeStart == null || _routeEnd == null) return; - debugPrint('[NavigationState] Calculating route...'); + debugPrint('[NavigationState] Calculating route with OSRM...'); _isCalculating = true; + _routingError = null; notifyListeners(); - // Mock route calculation - Future.delayed(const Duration(seconds: 1), () { - if (!_isCalculating) return; // Canceled + _routingService.calculateRoute( + start: _routeStart!, + end: _routeEnd!, + profile: 'driving', // Could make this configurable later + ).then((routeResult) { + if (!_isCalculating) return; // Canceled while calculating - _routePath = [_routeStart!, _routeEnd!]; - _routeDistance = const Distance().as(LengthUnit.Meter, _routeStart!, _routeEnd!); + _routePath = routeResult.waypoints; + _routeDistance = routeResult.distanceMeters; _isCalculating = false; _showingOverview = true; _provisionalPinLocation = null; // Hide provisional pin - debugPrint('[NavigationState] Route calculated: ${(_routeDistance! / 1000).toStringAsFixed(1)} km'); + debugPrint('[NavigationState] OSRM route calculated: ${routeResult.toString()}'); + notifyListeners(); + + }).catchError((error) { + if (!_isCalculating) return; // Canceled while calculating + + debugPrint('[NavigationState] Route calculation failed: $error'); + _isCalculating = false; + _routingError = error.toString().replaceAll('RoutingException: ', ''); + + // Don't show overview on error, stay in current state notifyListeners(); }); } diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index 819b0de..a0ed8df 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -180,6 +180,50 @@ class NavigationSheet extends StatelessWidget { ), ], + // ROUTING ERROR: Show error with retry option + if (appState.hasRoutingError && !appState.isCalculating) ...[ + Icon( + Icons.error_outline, + size: 48, + color: Colors.red[400], + ), + const SizedBox(height: 16), + Text( + 'Route calculation failed', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + appState.routingError ?? 'Unknown error', + style: TextStyle(color: Colors.grey[600]), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + onPressed: () { + // Retry route calculation + appState.retryRouteCalculation(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: const Text('Cancel'), + onPressed: () => appState.cancelNavigation(), + ), + ), + ], + ), + ], + // ROUTE OVERVIEW: Show route details with start/cancel options if (appState.showingOverview) ...[ if (appState.routeStart != null) ...[ From 8b4b9722c4c4542ea33c28f4796e04c77ad8accc Mon Sep 17 00:00:00 2001 From: stopflock Date: Thu, 2 Oct 2025 22:47:15 -0500 Subject: [PATCH 08/12] Navigation settings --- lib/localizations/de.json | 36 ++++++ lib/localizations/en.json | 36 ++++++ lib/localizations/es.json | 36 ++++++ lib/localizations/fr.json | 36 ++++++ lib/localizations/it.json | 36 ++++++ lib/localizations/pt.json | 36 ++++++ lib/localizations/zh.json | 36 ++++++ lib/main.dart | 2 + lib/screens/navigation_settings_screen.dart | 124 ++++++++++++++++++++ lib/screens/settings_screen.dart | 9 ++ lib/widgets/map/map_overlays.dart | 5 +- lib/widgets/navigation_sheet.dart | 74 ++++++------ lib/widgets/search_bar.dart | 23 ++-- 13 files changed, 443 insertions(+), 46 deletions(-) create mode 100644 lib/screens/navigation_settings_screen.dart diff --git a/lib/localizations/de.json b/lib/localizations/de.json index b946259..45edf19 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "Netzwerkstatus-Anzeige anzeigen", "showIndicatorSubtitle": "Netzwerk-Ladestatus und Fehlerstatus auf der Karte anzeigen" + }, + "navigation": { + "searchLocation": "Ort suchen", + "searchPlaceholder": "Orte oder Koordinaten suchen...", + "routeTo": "Route zu", + "routeFrom": "Route von", + "selectLocation": "Ort auswählen", + "calculatingRoute": "Route wird berechnet...", + "routeCalculationFailed": "Routenberechnung fehlgeschlagen", + "start": "Start", + "resume": "Fortsetzen", + "endRoute": "Route beenden", + "routeOverview": "Routenübersicht", + "retry": "Wiederholen", + "cancelSearch": "Suche abbrechen", + "noResultsFound": "Keine Ergebnisse gefunden", + "searching": "Suche...", + "location": "Standort", + "startPoint": "Start", + "endPoint": "Ende", + "startSelect": "Start (auswählen)", + "endSelect": "Ende (auswählen)", + "distance": "Entfernung: {} km", + "routeActive": "Route aktiv", + "navigationSettings": "Navigation", + "navigationSettingsSubtitle": "Routenplanung und Vermeidungseinstellungen", + "avoidanceDistance": "Vermeidungsabstand", + "avoidanceDistanceSubtitle": "Mindestabstand zu Überwachungsgeräten", + "searchHistory": "Max. Suchverlauf", + "searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken", + "units": "Einheiten", + "unitsSubtitle": "Anzeigeeinheiten für Entfernungen und Messungen", + "metric": "Metrisch (km, m)", + "imperial": "Britisch (mi, ft)", + "meters": "Meter", + "feet": "Fuß" } } \ No newline at end of file diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 56e9d5c..6a1efa3 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "Show network status indicator", "showIndicatorSubtitle": "Display network loading and error status on the map" + }, + "navigation": { + "searchLocation": "Search Location", + "searchPlaceholder": "Search places or coordinates...", + "routeTo": "Route To", + "routeFrom": "Route From", + "selectLocation": "Select Location", + "calculatingRoute": "Calculating route...", + "routeCalculationFailed": "Route calculation failed", + "start": "Start", + "resume": "Resume", + "endRoute": "End Route", + "routeOverview": "Route Overview", + "retry": "Retry", + "cancelSearch": "Cancel search", + "noResultsFound": "No results found", + "searching": "Searching...", + "location": "Location", + "startPoint": "Start", + "endPoint": "End", + "startSelect": "Start (select)", + "endSelect": "End (select)", + "distance": "Distance: {} km", + "routeActive": "Route active", + "navigationSettings": "Navigation", + "navigationSettingsSubtitle": "Route planning and avoidance settings", + "avoidanceDistance": "Avoidance Distance", + "avoidanceDistanceSubtitle": "Minimum distance to stay away from surveillance devices", + "searchHistory": "Max Search History", + "searchHistorySubtitle": "Maximum number of recent searches to remember", + "units": "Units", + "unitsSubtitle": "Display units for distances and measurements", + "metric": "Metric (km, m)", + "imperial": "Imperial (mi, ft)", + "meters": "meters", + "feet": "feet" } } \ No newline at end of file diff --git a/lib/localizations/es.json b/lib/localizations/es.json index ad7b9be..f321c3e 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "Mostrar indicador de estado de red", "showIndicatorSubtitle": "Mostrar estado de carga y errores de red en el mapa" + }, + "navigation": { + "searchLocation": "Buscar ubicación", + "searchPlaceholder": "Buscar lugares o coordenadas...", + "routeTo": "Ruta a", + "routeFrom": "Ruta desde", + "selectLocation": "Seleccionar ubicación", + "calculatingRoute": "Calculando ruta...", + "routeCalculationFailed": "Falló el cálculo de ruta", + "start": "Iniciar", + "resume": "Continuar", + "endRoute": "Finalizar ruta", + "routeOverview": "Vista de ruta", + "retry": "Reintentar", + "cancelSearch": "Cancelar búsqueda", + "noResultsFound": "No se encontraron resultados", + "searching": "Buscando...", + "location": "Ubicación", + "startPoint": "Inicio", + "endPoint": "Fin", + "startSelect": "Inicio (seleccionar)", + "endSelect": "Fin (seleccionar)", + "distance": "Distancia: {} km", + "routeActive": "Ruta activa", + "navigationSettings": "Navegación", + "navigationSettingsSubtitle": "Configuración de planificación de rutas y evitación", + "avoidanceDistance": "Distancia de evitación", + "avoidanceDistanceSubtitle": "Distancia mínima para mantenerse alejado de dispositivos de vigilancia", + "searchHistory": "Historial máximo de búsqueda", + "searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar", + "units": "Unidades", + "unitsSubtitle": "Unidades de visualización para distancias y medidas", + "metric": "Métrico (km, m)", + "imperial": "Imperial (mi, ft)", + "meters": "metros", + "feet": "pies" } } \ No newline at end of file diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index e219741..72b414f 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "Afficher l'indicateur de statut réseau", "showIndicatorSubtitle": "Afficher l'état de chargement et d'erreur réseau sur la carte" + }, + "navigation": { + "searchLocation": "Rechercher lieu", + "searchPlaceholder": "Rechercher lieux ou coordonnées...", + "routeTo": "Itinéraire vers", + "routeFrom": "Itinéraire depuis", + "selectLocation": "Sélectionner lieu", + "calculatingRoute": "Calcul de l'itinéraire...", + "routeCalculationFailed": "Échec du calcul d'itinéraire", + "start": "Démarrer", + "resume": "Reprendre", + "endRoute": "Terminer l'itinéraire", + "routeOverview": "Vue d'ensemble", + "retry": "Réessayer", + "cancelSearch": "Annuler recherche", + "noResultsFound": "Aucun résultat trouvé", + "searching": "Recherche...", + "location": "Lieu", + "startPoint": "Début", + "endPoint": "Fin", + "startSelect": "Début (sélectionner)", + "endSelect": "Fin (sélectionner)", + "distance": "Distance: {} km", + "routeActive": "Itinéraire actif", + "navigationSettings": "Navigation", + "navigationSettingsSubtitle": "Paramètres de planification d'itinéraire et d'évitement", + "avoidanceDistance": "Distance d'évitement", + "avoidanceDistanceSubtitle": "Distance minimale pour éviter les dispositifs de surveillance", + "searchHistory": "Historique de recherche max", + "searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir", + "units": "Unités", + "unitsSubtitle": "Unités d'affichage pour distances et mesures", + "metric": "Métrique (km, m)", + "imperial": "Impérial (mi, ft)", + "meters": "mètres", + "feet": "pieds" } } \ No newline at end of file diff --git a/lib/localizations/it.json b/lib/localizations/it.json index 97413c4..7bf7a9c 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "Mostra indicatore di stato di rete", "showIndicatorSubtitle": "Visualizza lo stato di caricamento e errori di rete sulla mappa" + }, + "navigation": { + "searchLocation": "Cerca posizione", + "searchPlaceholder": "Cerca luoghi o coordinate...", + "routeTo": "Percorso verso", + "routeFrom": "Percorso da", + "selectLocation": "Seleziona posizione", + "calculatingRoute": "Calcolo percorso...", + "routeCalculationFailed": "Calcolo percorso fallito", + "start": "Inizia", + "resume": "Riprendi", + "endRoute": "Termina percorso", + "routeOverview": "Panoramica percorso", + "retry": "Riprova", + "cancelSearch": "Annulla ricerca", + "noResultsFound": "Nessun risultato trovato", + "searching": "Ricerca in corso...", + "location": "Posizione", + "startPoint": "Inizio", + "endPoint": "Fine", + "startSelect": "Inizio (seleziona)", + "endSelect": "Fine (seleziona)", + "distance": "Distanza: {} km", + "routeActive": "Percorso attivo", + "navigationSettings": "Navigazione", + "navigationSettingsSubtitle": "Impostazioni pianificazione percorso ed evitamento", + "avoidanceDistance": "Distanza di evitamento", + "avoidanceDistanceSubtitle": "Distanza minima da mantenere dai dispositivi di sorveglianza", + "searchHistory": "Cronologia ricerca max", + "searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare", + "units": "Unità", + "unitsSubtitle": "Unità di visualizzazione per distanze e misure", + "metric": "Metrico (km, m)", + "imperial": "Imperiale (mi, ft)", + "meters": "metri", + "feet": "piedi" } } \ No newline at end of file diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 943d280..913c447 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "Exibir indicador de status de rede", "showIndicatorSubtitle": "Mostrar status de carregamento e erro de rede no mapa" + }, + "navigation": { + "searchLocation": "Buscar localização", + "searchPlaceholder": "Buscar locais ou coordenadas...", + "routeTo": "Rota para", + "routeFrom": "Rota de", + "selectLocation": "Selecionar localização", + "calculatingRoute": "Calculando rota...", + "routeCalculationFailed": "Falha no cálculo da rota", + "start": "Iniciar", + "resume": "Continuar", + "endRoute": "Terminar rota", + "routeOverview": "Visão geral da rota", + "retry": "Tentar novamente", + "cancelSearch": "Cancelar busca", + "noResultsFound": "Nenhum resultado encontrado", + "searching": "Buscando...", + "location": "Localização", + "startPoint": "Início", + "endPoint": "Fim", + "startSelect": "Início (selecionar)", + "endSelect": "Fim (selecionar)", + "distance": "Distância: {} km", + "routeActive": "Rota ativa", + "navigationSettings": "Navegação", + "navigationSettingsSubtitle": "Configurações de planejamento de rota e evasão", + "avoidanceDistance": "Distância de evasão", + "avoidanceDistanceSubtitle": "Distância mínima para ficar longe de dispositivos de vigilância", + "searchHistory": "Histórico máximo de busca", + "searchHistorySubtitle": "Número máximo de buscas recentes para lembrar", + "units": "Unidades", + "unitsSubtitle": "Unidades de exibição para distâncias e medidas", + "metric": "Métrico (km, m)", + "imperial": "Imperial (mi, ft)", + "meters": "metros", + "feet": "pés" } } \ No newline at end of file diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index bd19cc8..be6b26b 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -292,5 +292,41 @@ "networkStatus": { "showIndicator": "显示网络状态指示器", "showIndicatorSubtitle": "在地图上显示网络加载和错误状态" + }, + "navigation": { + "searchLocation": "搜索位置", + "searchPlaceholder": "搜索地点或坐标...", + "routeTo": "路线至", + "routeFrom": "路线从", + "selectLocation": "选择位置", + "calculatingRoute": "计算路线中...", + "routeCalculationFailed": "路线计算失败", + "start": "开始", + "resume": "继续", + "endRoute": "结束路线", + "routeOverview": "路线概览", + "retry": "重试", + "cancelSearch": "取消搜索", + "noResultsFound": "未找到结果", + "searching": "搜索中...", + "location": "位置", + "startPoint": "起点", + "endPoint": "终点", + "startSelect": "起点(选择)", + "endSelect": "终点(选择)", + "distance": "距离:{} 公里", + "routeActive": "路线活跃", + "navigationSettings": "导航", + "navigationSettingsSubtitle": "路线规划和回避设置", + "avoidanceDistance": "回避距离", + "avoidanceDistanceSubtitle": "与监控设备保持的最小距离", + "searchHistory": "最大搜索历史", + "searchHistorySubtitle": "要记住的最近搜索次数", + "units": "单位", + "unitsSubtitle": "距离和测量的显示单位", + "metric": "公制(公里,米)", + "imperial": "英制(英里,英尺)", + "meters": "米", + "feet": "英尺" } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index b54ea75..bc875a5 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'app_state.dart'; import 'screens/home_screen.dart'; import 'screens/settings_screen.dart'; import 'screens/profiles_settings_screen.dart'; +import 'screens/navigation_settings_screen.dart'; import 'screens/offline_settings_screen.dart'; import 'screens/advanced_settings_screen.dart'; import 'screens/language_settings_screen.dart'; @@ -64,6 +65,7 @@ class DeFlockApp extends StatelessWidget { '/': (context) => const HomeScreen(), '/settings': (context) => const SettingsScreen(), '/settings/profiles': (context) => const ProfilesSettingsScreen(), + '/settings/navigation': (context) => const NavigationSettingsScreen(), '/settings/offline': (context) => const OfflineSettingsScreen(), '/settings/advanced': (context) => const AdvancedSettingsScreen(), '/settings/language': (context) => const LanguageSettingsScreen(), diff --git a/lib/screens/navigation_settings_screen.dart b/lib/screens/navigation_settings_screen.dart new file mode 100644 index 0000000..27ac209 --- /dev/null +++ b/lib/screens/navigation_settings_screen.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import '../services/localization_service.dart'; +import '../app_state.dart'; +import 'package:provider/provider.dart'; + +class NavigationSettingsScreen extends StatelessWidget { + const NavigationSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final locService = LocalizationService.instance; + + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => Scaffold( + appBar: AppBar( + title: Text(locService.t('navigation.navigationSettings')), + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Coming soon message + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.info_outline, color: Colors.blue), + const SizedBox(width: 8), + Text( + 'Navigation Features', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + 'Navigation and routing settings will be available here. Coming soon:\n\n' + '• Surveillance avoidance distance\n' + '• Route planning preferences\n' + '• Search history management\n' + '• Distance units (metric/imperial)', + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ), + + const SizedBox(height: 24), + + // Placeholder settings (disabled for now) + _buildDisabledSetting( + context, + icon: Icons.warning_outlined, + title: locService.t('navigation.avoidanceDistance'), + subtitle: locService.t('navigation.avoidanceDistanceSubtitle'), + value: '100 ${locService.t('navigation.meters')}', + ), + + const Divider(), + + _buildDisabledSetting( + context, + icon: Icons.history, + title: locService.t('navigation.searchHistory'), + subtitle: locService.t('navigation.searchHistorySubtitle'), + value: '10 searches', + ), + + const Divider(), + + _buildDisabledSetting( + context, + icon: Icons.straighten, + title: locService.t('navigation.units'), + subtitle: locService.t('navigation.unitsSubtitle'), + value: locService.t('navigation.metric'), + ), + ], + ), + ), + ), + ); + } + + Widget _buildDisabledSetting( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required String value, + }) { + return Opacity( + opacity: 0.5, + child: ListTile( + leading: Icon(icon), + title: Text(title), + subtitle: Text(subtitle), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, size: 16), + ], + ), + enabled: false, + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 1eeb06a..7bbc30c 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -39,6 +39,15 @@ class SettingsScreen extends StatelessWidget { ), const Divider(), + _buildNavigationTile( + context, + icon: Icons.navigation, + title: locService.t('navigation.navigationSettings'), + subtitle: locService.t('navigation.navigationSettingsSubtitle'), + onTap: () => Navigator.pushNamed(context, '/settings/navigation'), + ), + const Divider(), + _buildNavigationTile( context, icon: Icons.cloud_off, diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index b945ce5..4279b93 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../../app_state.dart'; import '../../dev_config.dart'; +import '../../services/localization_service.dart'; import '../camera_icon.dart'; import 'layer_selector_button.dart'; @@ -126,7 +127,9 @@ class MapOverlays extends StatelessWidget { mini: true, heroTag: "search_nav", onPressed: onSearchPressed, - tooltip: appState.showRouteButton ? 'Route Overview' : 'Search Location', + tooltip: appState.showRouteButton + ? LocalizationService.instance.t('navigation.routeOverview') + : LocalizationService.instance.t('navigation.searchLocation'), child: Icon(appState.showRouteButton ? Icons.route : Icons.search), ), if (onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index a0ed8df..dd948cc 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'package:latlong2/latlong.dart'; import '../app_state.dart'; +import '../services/localization_service.dart'; class NavigationSheet extends StatelessWidget { final VoidCallback? onStartRoute; @@ -94,32 +95,32 @@ class NavigationSheet extends StatelessWidget { // SEARCH MODE: Initial location with route options if (navigationMode == AppNavigationMode.search && !appState.isSettingSecondPoint && !appState.isCalculating && !appState.showingOverview && provisionalLocation != null) ...[ _buildLocationInfo( - label: 'Location', + label: LocalizationService.instance.t('navigation.location'), coordinates: provisionalLocation, address: provisionalAddress, ), const SizedBox(height: 16), Row( children: [ - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.directions), - label: const Text('Route To'), - onPressed: () { - appState.startRoutePlanning(thisLocationIsStart: false); - }, + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.directions), + label: Text(LocalizationService.instance.t('navigation.routeTo')), + onPressed: () { + appState.startRoutePlanning(thisLocationIsStart: false); + }, + ), ), - ), - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - icon: const Icon(Icons.my_location), - label: const Text('Route From'), - onPressed: () { - appState.startRoutePlanning(thisLocationIsStart: true); - }, + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.my_location), + label: Text(LocalizationService.instance.t('navigation.routeFrom')), + onPressed: () { + appState.startRoutePlanning(thisLocationIsStart: true); + }, + ), ), - ), ], ), ], @@ -129,7 +130,7 @@ class NavigationSheet extends StatelessWidget { // Show existing route points if (appState.routeStart != null) ...[ _buildLocationInfo( - label: 'Start', + label: LocalizationService.instance.t('navigation.startPoint'), coordinates: appState.routeStart!, address: appState.routeStartAddress, ), @@ -137,7 +138,7 @@ class NavigationSheet extends StatelessWidget { ], if (appState.routeEnd != null) ...[ _buildLocationInfo( - label: 'End', + label: LocalizationService.instance.t('navigation.endPoint'), coordinates: appState.routeEnd!, address: appState.routeEndAddress, ), @@ -146,7 +147,9 @@ class NavigationSheet extends StatelessWidget { // Show the point we're selecting _buildLocationInfo( - label: appState.settingRouteStart ? 'Start (select)' : 'End (select)', + label: appState.settingRouteStart + ? LocalizationService.instance.t('navigation.startSelect') + : LocalizationService.instance.t('navigation.endSelect'), coordinates: provisionalLocation, address: provisionalAddress, ), @@ -154,7 +157,7 @@ class NavigationSheet extends StatelessWidget { ElevatedButton.icon( icon: const Icon(Icons.check), - label: const Text('Select Location'), + label: Text(LocalizationService.instance.t('navigation.selectLocation')), onPressed: () { debugPrint('[NavigationSheet] Select Location button pressed'); appState.selectSecondRoutePoint(); @@ -172,11 +175,14 @@ class NavigationSheet extends StatelessWidget { ), ), const SizedBox(height: 16), - const Text('Calculating route...', textAlign: TextAlign.center), + Text( + LocalizationService.instance.t('navigation.calculatingRoute'), + textAlign: TextAlign.center, + ), const SizedBox(height: 16), ElevatedButton( onPressed: () => appState.cancelNavigation(), - child: const Text('Cancel'), + child: Text(LocalizationService.instance.t('actions.cancel')), ), ], @@ -189,7 +195,7 @@ class NavigationSheet extends StatelessWidget { ), const SizedBox(height: 16), Text( - 'Route calculation failed', + LocalizationService.instance.t('navigation.routeCalculationFailed'), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), @@ -205,7 +211,7 @@ class NavigationSheet extends StatelessWidget { Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.refresh), - label: const Text('Retry'), + label: Text(LocalizationService.instance.t('navigation.retry')), onPressed: () { // Retry route calculation appState.retryRouteCalculation(); @@ -216,7 +222,7 @@ class NavigationSheet extends StatelessWidget { Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.close), - label: const Text('Cancel'), + label: Text(LocalizationService.instance.t('actions.cancel')), onPressed: () => appState.cancelNavigation(), ), ), @@ -228,7 +234,7 @@ class NavigationSheet extends StatelessWidget { if (appState.showingOverview) ...[ if (appState.routeStart != null) ...[ _buildLocationInfo( - label: 'Start', + label: LocalizationService.instance.t('navigation.startPoint'), coordinates: appState.routeStart!, address: appState.routeStartAddress, ), @@ -236,7 +242,7 @@ class NavigationSheet extends StatelessWidget { ], if (appState.routeEnd != null) ...[ _buildLocationInfo( - label: 'End', + label: LocalizationService.instance.t('navigation.endPoint'), coordinates: appState.routeEnd!, address: appState.routeEndAddress, ), @@ -244,7 +250,7 @@ class NavigationSheet extends StatelessWidget { ], if (appState.routeDistance != null) ...[ Text( - 'Distance: ${(appState.routeDistance! / 1000).toStringAsFixed(1)} km', + LocalizationService.instance.t('navigation.distance', params: [(appState.routeDistance! / 1000).toStringAsFixed(1)]), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), @@ -257,7 +263,7 @@ class NavigationSheet extends StatelessWidget { Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.play_arrow), - label: const Text('Start'), + label: Text(LocalizationService.instance.t('navigation.start')), onPressed: onStartRoute ?? () => appState.startRoute(), ), ), @@ -265,7 +271,7 @@ class NavigationSheet extends StatelessWidget { Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.close), - label: const Text('Cancel'), + label: Text(LocalizationService.instance.t('actions.cancel')), onPressed: () => appState.cancelNavigation(), ), ), @@ -274,7 +280,7 @@ class NavigationSheet extends StatelessWidget { Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.play_arrow), - label: const Text('Resume'), + label: Text(LocalizationService.instance.t('navigation.resume')), onPressed: onResumeRoute ?? () => appState.hideRouteOverview(), ), ), @@ -282,7 +288,7 @@ class NavigationSheet extends StatelessWidget { Expanded( child: ElevatedButton.icon( icon: const Icon(Icons.close), - label: const Text('End Route'), + label: Text(LocalizationService.instance.t('navigation.endRoute')), onPressed: () => appState.cancelRoute(), ), ), diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart index e5c6941..1771629 100644 --- a/lib/widgets/search_bar.dart +++ b/lib/widgets/search_bar.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../models/search_result.dart'; +import '../services/localization_service.dart'; import '../widgets/debouncer.dart'; class LocationSearchBar extends StatefulWidget { @@ -115,24 +116,24 @@ class _LocationSearchBarState extends State { mainAxisSize: MainAxisSize.min, children: [ if (isLoading) - const Padding( - padding: EdgeInsets.all(16), + Padding( + padding: const EdgeInsets.all(16), child: Row( children: [ - SizedBox( + const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ), - SizedBox(width: 12), - Text('Searching...'), + const SizedBox(width: 12), + Text(LocalizationService.instance.t('navigation.searching')), ], ), ) else if (results.isEmpty && _controller.text.isNotEmpty) - const Padding( - padding: EdgeInsets.all(16), - child: Text('No results found'), + Padding( + padding: const EdgeInsets.all(16), + child: Text(LocalizationService.instance.t('navigation.noResultsFound')), ) else ...results.map((result) => ListTile( @@ -179,14 +180,14 @@ class _LocationSearchBarState extends State { controller: _controller, focusNode: _focusNode, decoration: InputDecoration( - hintText: 'Search places or coordinates...', + hintText: LocalizationService.instance.t('navigation.searchPlaceholder'), prefixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.close), onPressed: _onCancel, - tooltip: 'Cancel search', + tooltip: LocalizationService.instance.t('navigation.cancelSearch'), ), const Icon(Icons.search), ], @@ -196,7 +197,7 @@ class _LocationSearchBarState extends State { ? IconButton( icon: const Icon(Icons.clear), onPressed: _onClear, - tooltip: 'Clear text', + tooltip: LocalizationService.instance.t('actions.clear'), ) : null, border: OutlineInputBorder( From fd47813bdf9ad43e0914688b48cba92523142e54 Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 3 Oct 2025 00:09:25 -0500 Subject: [PATCH 09/12] Optional navigation features --- lib/dev_config.dart | 3 +++ lib/screens/home_screen.dart | 22 ++++++++++++---------- lib/screens/settings_screen.dart | 19 +++++++++++-------- lib/widgets/map/map_overlays.dart | 6 +++--- 4 files changed, 29 insertions(+), 21 deletions(-) diff --git a/lib/dev_config.dart b/lib/dev_config.dart index ffa4c1f..fc34336 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -41,6 +41,9 @@ const String kClientVersion = '1.0.2'; // Development/testing features - set to false for production builds const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode +// Navigation features - set to false to hide navigation UI elements while in development +const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented + // Marker/node interaction const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) const int kOsmApiMinZoomLevel = 13; // Minimum zoom for OSM API bbox queries (sandbox mode) diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 77aca96..f708f53 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -433,13 +433,15 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _editSheetShown = false; } - // Auto-open navigation sheet when needed - simplified logic - final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview; - if (shouldShowNavSheet && !_navigationSheetShown) { - _navigationSheetShown = true; - WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet()); - } else if (!shouldShowNavSheet) { - _navigationSheetShown = false; + // Auto-open navigation sheet when needed - simplified logic (only in dev mode) + if (kEnableNavigationFeatures) { + final shouldShowNavSheet = appState.isInSearchMode || appState.showingOverview; + if (shouldShowNavSheet && !_navigationSheetShown) { + _navigationSheetShown = true; + WidgetsBinding.instance.addPostFrameCallback((_) => _openNavigationSheet()); + } else if (!shouldShowNavSheet) { + _navigationSheetShown = false; + } } // Pass the active sheet height directly to the map @@ -498,15 +500,15 @@ class _HomeScreenState extends State with TickerProviderStateMixin { sheetHeight: activeSheetHeight, selectedNodeId: _selectedNodeId, onNodeTap: openNodeTagSheet, - onSearchPressed: _onNavigationButtonPressed, + onSearchPressed: kEnableNavigationFeatures ? _onNavigationButtonPressed : null, onUserGesture: () { if (appState.followMeMode != FollowMeMode.off) { appState.setFollowMeMode(FollowMeMode.off); } }, ), - // Search bar (slides in when in search mode) - if (appState.isInSearchMode) + // Search bar (slides in when in search mode) - only in dev mode + if (kEnableNavigationFeatures && appState.isInSearchMode) Positioned( top: 0, left: 0, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7bbc30c..f63c4c8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -39,14 +39,17 @@ class SettingsScreen extends StatelessWidget { ), const Divider(), - _buildNavigationTile( - context, - icon: Icons.navigation, - title: locService.t('navigation.navigationSettings'), - subtitle: locService.t('navigation.navigationSettingsSubtitle'), - onTap: () => Navigator.pushNamed(context, '/settings/navigation'), - ), - const Divider(), + // Only show navigation settings in development builds + if (kEnableNavigationFeatures) ...[ + _buildNavigationTile( + context, + icon: Icons.navigation, + title: locService.t('navigation.navigationSettings'), + subtitle: locService.t('navigation.navigationSettingsSubtitle'), + onTap: () => Navigator.pushNamed(context, '/settings/navigation'), + ), + const Divider(), + ], _buildNavigationTile( context, diff --git a/lib/widgets/map/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 4279b93..0b357a3 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -121,8 +121,8 @@ class MapOverlays extends StatelessWidget { builder: (context, appState, child) { return Column( children: [ - // Navigation button - simplified logic - if (onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) + // Navigation button - simplified logic (only show in dev mode) + if (kEnableNavigationFeatures && onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) ...[ FloatingActionButton( mini: true, heroTag: "search_nav", @@ -132,8 +132,8 @@ class MapOverlays extends StatelessWidget { : LocalizationService.instance.t('navigation.searchLocation'), child: Icon(appState.showRouteButton ? Icons.route : Icons.search), ), - if (onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) const SizedBox(height: 8), + ], // Layer selector button const LayerSelectorButton(), From 02c66b37853e5dc506d819882ed1b1d155c3b08f Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 3 Oct 2025 10:44:33 -0500 Subject: [PATCH 10/12] App version from pubspec only. Bump version. --- .github/workflows/workflow.yml | 2 +- do_builds.sh | 2 +- lib/dev_config.dart | 6 +-- lib/main.dart | 4 ++ lib/screens/settings_screen.dart | 3 +- lib/services/uploader.dart | 3 +- lib/services/version_service.dart | 68 +++++++++++++++++++++++++++++++ pubspec.lock | 16 ++++++++ pubspec.yaml | 3 +- 9 files changed, 99 insertions(+), 8 deletions(-) create mode 100644 lib/services/version_service.dart diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 4519df1..f65db03 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -14,7 +14,7 @@ jobs: - name: Get version from lib/dev_config.dart id: set-version run: | - echo version=$(grep "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ") >> $GITHUB_OUTPUT + echo version=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ') >> $GITHUB_OUTPUT # - name: Extract version from pubspec.yaml # id: extract_version diff --git a/do_builds.sh b/do_builds.sh index 8e3f548..3601b30 100755 --- a/do_builds.sh +++ b/do_builds.sh @@ -23,7 +23,7 @@ for arg in "$@"; do esac done -appver=$(grep "kClientVersion" lib/dev_config.dart | cut -d '=' -f 2 | tr -d ';' | tr -d "\'" | tr -d " ") +appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ') echo echo "Building app version ${appver}..." echo diff --git a/lib/dev_config.dart b/lib/dev_config.dart index fc34336..80eb388 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -34,12 +34,12 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea // Add Camera icon vertical offset (no offset needed since circle is centered) const double kAddPinYOffset = 0.0; -// Client name and version for OSM uploads ("created_by" tag) +// Client name for OSM uploads ("created_by" tag) const String kClientName = 'DeFlock'; -const String kClientVersion = '1.0.2'; +// Note: Version is now dynamically retrieved from VersionService // Development/testing features - set to false for production builds -const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode +const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode // Navigation features - set to false to hide navigation UI elements while in development const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented diff --git a/lib/main.dart b/lib/main.dart index bc875a5..8c68fed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,12 +11,16 @@ import 'screens/advanced_settings_screen.dart'; import 'screens/language_settings_screen.dart'; import 'screens/about_screen.dart'; import 'services/localization_service.dart'; +import 'services/version_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + // Initialize version service + await VersionService().init(); + // Initialize localization service await LocalizationService.instance.init(); diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f63c4c8..e2a0301 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -3,6 +3,7 @@ import 'settings/sections/auth_section.dart'; import 'settings/sections/upload_mode_section.dart'; import 'settings/sections/queue_section.dart'; import '../services/localization_service.dart'; +import '../services/version_service.dart'; import '../dev_config.dart'; class SettingsScreen extends StatelessWidget { @@ -91,7 +92,7 @@ class SettingsScreen extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( - 'Version: $kClientVersion', + 'Version: ${VersionService().version}', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6), ), diff --git a/lib/services/uploader.dart b/lib/services/uploader.dart index 59f61da..2b6c4e5 100644 --- a/lib/services/uploader.dart +++ b/lib/services/uploader.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import '../models/pending_upload.dart'; import '../dev_config.dart'; +import 'version_service.dart'; import '../app_state.dart'; class Uploader { @@ -32,7 +33,7 @@ class Uploader { final csXml = ''' - + '''; diff --git a/lib/services/version_service.dart b/lib/services/version_service.dart new file mode 100644 index 0000000..5e5b744 --- /dev/null +++ b/lib/services/version_service.dart @@ -0,0 +1,68 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +/// Service for getting app version information from pubspec.yaml. +/// This ensures we have a single source of truth for version info. +class VersionService { + static final VersionService _instance = VersionService._internal(); + factory VersionService() => _instance; + VersionService._internal(); + + PackageInfo? _packageInfo; + bool _initialized = false; + + /// Initialize the service by loading package info + Future init() async { + if (_initialized) return; + + try { + _packageInfo = await PackageInfo.fromPlatform(); + _initialized = true; + debugPrint('[VersionService] Loaded version: ${_packageInfo!.version}'); + } catch (e) { + debugPrint('[VersionService] Failed to load package info: $e'); + _initialized = false; + } + } + + /// Get the app version (e.g., "1.0.2") + String get version { + if (!_initialized || _packageInfo == null) { + debugPrint('[VersionService] Warning: Service not initialized, returning fallback version'); + return 'unknown'; // Fallback for development/testing + } + return _packageInfo!.version; + } + + /// Get the app name + String get appName { + if (!_initialized || _packageInfo == null) { + return 'DeFlock'; // Fallback + } + return _packageInfo!.appName; + } + + /// Get the package name/bundle ID + String get packageName { + if (!_initialized || _packageInfo == null) { + return 'me.deflock.deflockapp'; // Fallback + } + return _packageInfo!.packageName; + } + + /// Get the build number + String get buildNumber { + if (!_initialized || _packageInfo == null) { + return '1'; // Fallback + } + return _packageInfo!.buildNumber; + } + + /// Get full version string with build number (e.g., "1.0.2+1") + String get fullVersion { + return '$version+$buildNumber'; + } + + /// Check if the service is properly initialized + bool get isInitialized => _initialized && _packageInfo != null; +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 2285fa5..2bf35ab 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -443,6 +443,22 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1945444..6811c3f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: deflockapp description: Map public surveillance infrastructure with OpenStreetMap publish_to: "none" -version: 1.0.2 +version: 1.0.7 environment: sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+ @@ -29,6 +29,7 @@ dependencies: # Persistence shared_preferences: ^2.2.2 uuid: ^4.0.0 + package_info_plus: ^8.0.0 dev_dependencies: flutter_launcher_icons: ^0.14.4 From 0bc420efca611f5214864cc386c8c414d2104da8 Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 3 Oct 2025 11:06:46 -0500 Subject: [PATCH 11/12] Remove old offset var from dev_config, fix android builds --- android/app/build.gradle.kts | 16 ++++++++++------ lib/dev_config.dart | 3 +-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2a4bb50..cdbbce9 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -48,17 +48,21 @@ android { } signingConfigs { - create("release") { - keyAlias = keystoreProperties["keyAlias"] as String - keyPassword = keystoreProperties["keyPassword"] as String - storeFile = keystoreProperties["storeFile"]?.let { file(it) } - storePassword = keystoreProperties["storePassword"] as String + if (keystorePropertiesFile.exists()) { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = keystoreProperties["storeFile"]?.let { file(it) } + storePassword = keystoreProperties["storePassword"] as String + } } } buildTypes { release { - signingConfig = signingConfigs.getByName("release") + if (keystorePropertiesFile.exists()) { + signingConfig = signingConfigs.getByName("release") + } } } } diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 80eb388..176df52 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -31,8 +31,7 @@ double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeArea return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar; } -// Add Camera icon vertical offset (no offset needed since circle is centered) -const double kAddPinYOffset = 0.0; + // Client name for OSM uploads ("created_by" tag) const String kClientName = 'DeFlock'; From dbe667ee8b693d240daa49222609b8c260cd9e7a Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 3 Oct 2025 11:34:15 -0500 Subject: [PATCH 12/12] if/else for android signing keys --- android/app/build.gradle.kts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index cdbbce9..dfd0c4c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -62,6 +62,9 @@ android { release { if (keystorePropertiesFile.exists()) { signingConfig = signingConfigs.getByName("release") + } else { + // Fall back to debug signing for development builds + signingConfig = signingConfigs.getByName("debug") } } }