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/android/app/build.gradle.kts b/android/app/build.gradle.kts index 35e082b..6b9f9c4 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -48,17 +48,24 @@ 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") + } else { + // Fall back to debug signing for development builds + signingConfig = signingConfigs.getByName("debug") + } } } } 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/app_state.dart b/lib/app_state.dart index e7846ad..271f3c8 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,35 @@ class AppState extends ChangeNotifier { bool get isLoggedIn => _authState.isLoggedIn; String get username => _authState.username; + // Navigation state - simplified + 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; + bool get showSearchButton => _navigationState.showSearchButton; + bool get showRouteButton => _navigationState.showRouteButton; + 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; + 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; + List get navigationSearchResults => _navigationState.searchResults; + // Profile state List get profiles => _profileState.profiles; List get enabledProfiles => _profileState.enabledProfiles; @@ -250,6 +284,68 @@ class AppState extends ChangeNotifier { _searchState.clearResults(); } + // ---------- Navigation Methods - Simplified ---------- + void enterSearchMode(LatLng mapCenter) { + _navigationState.enterSearchMode(mapCenter); + } + + void cancelNavigation() { + _navigationState.cancel(); + } + + void updateProvisionalPinLocation(LatLng newLocation) { + _navigationState.updateProvisionalPinLocation(newLocation); + } + + void selectSearchResult(SearchResult result) { + _navigationState.selectSearchResult(result); + } + + void startRoutePlanning({required bool thisLocationIsStart}) { + _navigationState.startRoutePlanning(thisLocationIsStart: thisLocationIsStart); + } + + void selectSecondRoutePoint() { + _navigationState.selectSecondRoutePoint(); + } + + 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() { + _navigationState.showRouteOverview(); + } + + void hideRouteOverview() { + _navigationState.hideRouteOverview(); + } + + void cancelRoute() { + _navigationState.cancelRoute(); + } + + // Navigation search methods + Future searchNavigation(String query) async { + await _navigationState.search(query); + } + + void clearNavigationSearchResults() { + _navigationState.clearSearchResults(); + } + + void retryRouteCalculation() { + _navigationState.retryRouteCalculation(); + } + // ---------- Settings Methods ---------- Future setOfflineMode(bool enabled) async { await _settingsState.setOfflineMode(enabled); @@ -347,6 +443,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/dev_config.dart b/lib/dev_config.dart index ffa4c1f..176df52 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -31,15 +31,17 @@ 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 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 // Marker/node interaction const int kCameraMinZoomLevel = 10; // Minimum zoom to show nodes (Overpass) diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 911d1ec..52d41b3 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -299,5 +299,41 @@ "features": "• Offline-fähige Kartierung mit herunterladbaren Bereichen\n• Direkter Upload zu OpenStreetMap mit OAuth2\n• Integrierte Profile für große Hersteller\n• Datenschutzfreundlich - keine Nutzerdaten gesammelt\n• Multiple Kartenanbieter (OSM, Satellitenbilder)", "initiative": "Teil der breiteren DeFlock-Initiative zur Förderung von Überwachungstransparenz.", "footer": "Besuchen Sie: deflock.me\nGebaut mit Flutter • Open Source" + }, + "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 4befcac..f5ed7e3 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -299,5 +299,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 bb68529..099c07b 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -299,5 +299,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 e822187..623a97f 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -299,5 +299,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 31b374f..8cb41ac 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -299,5 +299,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 801f778..57a179a 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -299,5 +299,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 fd57a85..8e0e4f6 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -299,5 +299,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..8c68fed 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,17 +5,22 @@ 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'; 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(); @@ -64,6 +69,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/home_screen.dart b/lib/screens/home_screen.dart index eb3fe7a..f708f53 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,190 @@ class _HomeScreenState extends State with TickerProviderStateMixin { }); } + void _openNavigationSheet() { + final controller = _scaffoldKey.currentState!.showBottomSheet( + (ctx) => MeasuredSheet( + onHeightChanged: (height) { + setState(() { + _navigationSheetHeight = height; + }); + }, + child: NavigationSheet( + onStartRoute: _onStartRoute, + onResumeRoute: _onResumeRoute, + ), + ), + ); + + // Reset height when sheet is dismissed + controller.closed.then((_) { + setState(() { + _navigationSheetHeight = 0.0; + }); + + // Handle different dismissal scenarios + final appState = context.read(); + + 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(); + } + }); + } + + void _onStartRoute() { + final appState = context.read(); + + // Get user location and check if we should auto-enable follow-me + LatLng? userLocation; + bool enableFollowMe = false; + + try { + 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() { + final appState = context.read(); + + debugPrint('[HomeScreen] Navigation button pressed - showRouteButton: ${appState.showRouteButton}, navigationMode: ${appState.navigationMode}'); + + if (appState.showRouteButton) { + // 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'); + try { + final mapCenter = _mapController.mapController.camera.center; + debugPrint('[HomeScreen] Map center: $mapCenter'); + appState.enterSearchMode(mapCenter); + } catch (e) { + // Controller not ready, use fallback location + debugPrint('[HomeScreen] Map controller not ready: $e, using fallback'); + 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 +433,25 @@ class _HomeScreenState extends State with TickerProviderStateMixin { _editSheetShown = 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 final activeSheetHeight = _addSheetHeight > 0 ? _addSheetHeight : (_editSheetHeight > 0 ? _editSheetHeight - : _tagSheetHeight); + : (_navigationSheetHeight > 0 + ? _navigationSheetHeight + : _tagSheetHeight)); return MultiProvider( providers: [ @@ -260,6 +460,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, @@ -290,94 +491,96 @@ 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, + onSearchPressed: kEnableNavigationFeatures ? _onNavigationButtonPressed : null, + 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) - only in dev mode + if (kEnableNavigationFeatures && appState.isInSearchMode) + Positioned( + top: 0, + left: 0, + right: 0, + child: LocationSearchBar( + onResultSelected: _onSearchResultSelected, + onCancel: () => appState.cancelNavigation(), + ), + ), + // 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), + ), + ), + ), ), - ), + ], ), - ], + ), + ), ), ), ], 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..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 { @@ -39,6 +40,18 @@ class SettingsScreen extends StatelessWidget { ), 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, icon: Icons.cloud_off, @@ -79,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/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/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/lib/state/navigation_state.dart b/lib/state/navigation_state.dart new file mode 100644 index 0000000..072fcf9 --- /dev/null +++ b/lib/state/navigation_state.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +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 { + normal, // Regular map view + search, // Search/routing UI active + routeActive, // Following a route +} + +/// 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; + + // Simple flags instead of complex sub-states + bool _isSettingSecondPoint = false; + bool _isCalculating = false; + bool _showingOverview = false; + String? _routingError; + + // Search state + bool _isSearchLoading = false; + List _searchResults = []; + String _lastQuery = ''; + + // Location state + LatLng? _provisionalPinLocation; + String? _provisionalPinAddress; + + // Route state + LatLng? _routeStart; + LatLng? _routeEnd; + String? _routeStartAddress; + String? _routeEndAddress; + List? _routePath; + double? _routeDistance; + 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; + String? get routingError => _routingError; + bool get hasRoutingError => _routingError != null; + + bool get isSearchLoading => _isSearchLoading; + List get searchResults => List.unmodifiable(_searchResults); + String get lastQuery => _lastQuery; + + 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 => _nextPointIsStart; // For sheet display compatibility + + // 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; + + /// BRUTALIST: Single entry point to search mode + void enterSearchMode(LatLng mapCenter) { + debugPrint('[NavigationState] enterSearchMode - current mode: $_mode'); + + if (_mode != AppNavigationMode.normal) { + debugPrint('[NavigationState] Cannot enter search mode - not in normal mode'); + return; + } + + _mode = AppNavigationMode.search; + _provisionalPinLocation = mapCenter; + _provisionalPinAddress = null; + _clearSearchResults(); + + debugPrint('[NavigationState] Entered search mode'); + notifyListeners(); + } + + /// 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; + _routingError = null; + + // Clear search + _clearSearchResults(); + + debugPrint('[NavigationState] Everything cleaned up'); + notifyListeners(); + } + + /// Update provisional pin when map moves + void updateProvisionalPinLocation(LatLng newLocation) { + if (!showProvisionalPin) return; + + _provisionalPinLocation = newLocation; + _provisionalPinAddress = null; // Clear address when location changes + notifyListeners(); + } + + /// Jump to search result + void selectSearchResult(SearchResult result) { + if (_mode != AppNavigationMode.search) return; + + _provisionalPinLocation = result.coordinates; + _provisionalPinAddress = result.displayName; + _clearSearchResults(); + + debugPrint('[NavigationState] Selected search result: ${result.displayName}'); + notifyListeners(); + } + + /// Start route planning - simplified logic + void startRoutePlanning({required bool thisLocationIsStart}) { + if (_mode != AppNavigationMode.search || _provisionalPinLocation == null) return; + + debugPrint('[NavigationState] Starting route planning - thisLocationIsStart: $thisLocationIsStart'); + + // Clear any previous route data + _routeStart = null; + _routeEnd = null; + _routeStartAddress = null; + _routeEndAddress = null; + _routePath = null; + _routeDistance = null; + + // Set the current location as start or end + if (thisLocationIsStart) { + _routeStart = _provisionalPinLocation; + _routeStartAddress = _provisionalPinAddress; + _nextPointIsStart = false; // Next we'll set the END + debugPrint('[NavigationState] Set route start, next setting END'); + } else { + _routeEnd = _provisionalPinLocation; + _routeEndAddress = _provisionalPinAddress; + _nextPointIsStart = true; // Next we'll set the START + debugPrint('[NavigationState] Set route end, next setting START'); + } + + // Enter second point selection mode + _isSettingSecondPoint = true; + notifyListeners(); + } + + /// Select the second route point + void selectSecondRoutePoint() { + if (!_isSettingSecondPoint || _provisionalPinLocation == null) return; + + debugPrint('[NavigationState] Selecting second route point - nextPointIsStart: $_nextPointIsStart'); + + // Set the second point + if (_nextPointIsStart) { + _routeStart = _provisionalPinLocation; + _routeStartAddress = _provisionalPinAddress; + } else { + _routeEnd = _provisionalPinLocation; + _routeEndAddress = _provisionalPinAddress; + } + + _isSettingSecondPoint = false; + _routingError = null; // Clear any previous errors + _calculateRoute(); + } + + /// 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 with OSRM...'); + _isCalculating = true; + _routingError = null; + notifyListeners(); + + _routingService.calculateRoute( + start: _routeStart!, + end: _routeEnd!, + profile: 'driving', // Could make this configurable later + ).then((routeResult) { + if (!_isCalculating) return; // Canceled while calculating + + _routePath = routeResult.waypoints; + _routeDistance = routeResult.distanceMeters; + _isCalculating = false; + _showingOverview = true; + _provisionalPinLocation = null; // Hide provisional pin + + 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(); + }); + } + + /// Start following the route + void startRoute() { + if (_routePath == null) return; + + _mode = AppNavigationMode.routeActive; + _showingOverview = false; + + debugPrint('[NavigationState] Started following route'); + 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; + + _showingOverview = true; + debugPrint('[NavigationState] Showing route overview'); + notifyListeners(); + } + + /// 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 (_mode != AppNavigationMode.routeActive) return; + + debugPrint('[NavigationState] Canceling active route'); + cancel(); // Use the brutalist single cleanup method + } + + /// Search functionality + 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'); + } catch (e) { + debugPrint('[NavigationState] Search failed: $e'); + _searchResults = []; + } + + _setSearchLoading(false); + } + + 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/map_overlays.dart b/lib/widgets/map/map_overlays.dart index 53b39fa..0b357a3 100644 --- a/lib/widgets/map/map_overlays.dart +++ b/lib/widgets/map/map_overlays.dart @@ -1,8 +1,10 @@ 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'; +import '../../services/localization_service.dart'; import '../camera_icon.dart'; import 'layer_selector_button.dart'; @@ -13,6 +15,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 +24,7 @@ class MapOverlays extends StatelessWidget { this.session, this.editSession, this.attribution, + this.onSearchPressed, }); @override @@ -113,45 +117,61 @@ 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: [ + // Navigation button - simplified logic (only show in dev mode) + if (kEnableNavigationFeatures && onSearchPressed != null && (appState.showSearchButton || appState.showRouteButton)) ...[ + FloatingActionButton( + mini: true, + heroTag: "search_nav", + onPressed: onSearchPressed, + tooltip: appState.showRouteButton + ? LocalizationService.instance.t('navigation.routeOverview') + : LocalizationService.instance.t('navigation.searchLocation'), + child: Icon(appState.showRouteButton ? Icons.route : Icons.search), + ), + 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 605a6fb..93d6942 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; @@ -37,6 +38,7 @@ class MapView extends StatefulWidget { this.sheetHeight = 0.0, this.selectedNodeId, this.onNodeTap, + this.onSearchPressed, }); final FollowMeMode followMeMode; @@ -44,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(); @@ -200,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() => @@ -374,10 +382,60 @@ class MapViewState extends State { } } + // Build provisional pin for navigation/search mode + if (appState.showProvisionalPin && appState.provisionalPinLocation != null) { + centerMarkers.add( + Marker( + point: appState.provisionalPinLocation!, + width: 32.0, + height: 32.0, + child: const ProvisionalPin(), + ), + ); + } + + // Build start/end pins for route visualization + if (appState.showingOverview || appState.isInRouteMode || appState.isSettingSecondPoint) { + 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) { + // 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( children: [ PolygonLayer(polygons: overlays), if (editLines.isNotEmpty) PolylineLayer(polylines: editLines), + if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines), MarkerLayer(markers: [...markers, ...centerMarkers]), ], ); @@ -406,6 +464,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(); @@ -468,6 +531,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/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart new file mode 100644 index 0000000..dd948cc --- /dev/null +++ b/lib/widgets/navigation_sheet.dart @@ -0,0 +1,305 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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; + final VoidCallback? onResumeRoute; + + const NavigationSheet({ + super.key, + this.onStartRoute, + this.onResumeRoute, + }); + + 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', + ), + ), + ], + ); + } + + 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( + builder: (context, appState, child) { + final navigationMode = appState.navigationMode; + final provisionalLocation = appState.provisionalPinLocation; + final provisionalAddress = appState.provisionalPinAddress; + + if (provisionalLocation == null && !appState.showingOverview) { + return const SizedBox.shrink(); + } + + 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: 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: 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: Text(LocalizationService.instance.t('navigation.routeFrom')), + 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: LocalizationService.instance.t('navigation.startPoint'), + coordinates: appState.routeStart!, + address: appState.routeStartAddress, + ), + const SizedBox(height: 12), + ], + if (appState.routeEnd != null) ...[ + _buildLocationInfo( + label: LocalizationService.instance.t('navigation.endPoint'), + coordinates: appState.routeEnd!, + address: appState.routeEndAddress, + ), + const SizedBox(height: 12), + ], + + // Show the point we're selecting + _buildLocationInfo( + label: appState.settingRouteStart + ? LocalizationService.instance.t('navigation.startSelect') + : LocalizationService.instance.t('navigation.endSelect'), + coordinates: provisionalLocation, + address: provisionalAddress, + ), + const SizedBox(height: 16), + + ElevatedButton.icon( + icon: const Icon(Icons.check), + label: Text(LocalizationService.instance.t('navigation.selectLocation')), + 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), + Text( + LocalizationService.instance.t('navigation.calculatingRoute'), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => appState.cancelNavigation(), + child: Text(LocalizationService.instance.t('actions.cancel')), + ), + ], + + // 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( + LocalizationService.instance.t('navigation.routeCalculationFailed'), + 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: Text(LocalizationService.instance.t('navigation.retry')), + onPressed: () { + // Retry route calculation + appState.retryRouteCalculation(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: Text(LocalizationService.instance.t('actions.cancel')), + onPressed: () => appState.cancelNavigation(), + ), + ), + ], + ), + ], + + // ROUTE OVERVIEW: Show route details with start/cancel options + if (appState.showingOverview) ...[ + if (appState.routeStart != null) ...[ + _buildLocationInfo( + label: LocalizationService.instance.t('navigation.startPoint'), + coordinates: appState.routeStart!, + address: appState.routeStartAddress, + ), + const SizedBox(height: 12), + ], + if (appState.routeEnd != null) ...[ + _buildLocationInfo( + label: LocalizationService.instance.t('navigation.endPoint'), + coordinates: appState.routeEnd!, + address: appState.routeEndAddress, + ), + const SizedBox(height: 12), + ], + if (appState.routeDistance != null) ...[ + Text( + LocalizationService.instance.t('navigation.distance', params: [(appState.routeDistance! / 1000).toStringAsFixed(1)]), + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ], + + Row( + children: [ + if (navigationMode == AppNavigationMode.search) ...[ + // Route preview mode - start or cancel + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: Text(LocalizationService.instance.t('navigation.start')), + onPressed: onStartRoute ?? () => appState.startRoute(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: Text(LocalizationService.instance.t('actions.cancel')), + onPressed: () => appState.cancelNavigation(), + ), + ), + ] else if (navigationMode == AppNavigationMode.routeActive) ...[ + // Active route overview - resume or cancel + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + label: Text(LocalizationService.instance.t('navigation.resume')), + onPressed: onResumeRoute ?? () => appState.hideRouteOverview(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.close), + label: Text(LocalizationService.instance.t('navigation.endRoute')), + onPressed: () => appState.cancelRoute(), + ), + ), + ], + ], + ), + ], + ], + ), + ); + }, + ); + } +} \ 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..2757dc1 --- /dev/null +++ b/lib/widgets/provisional_pin.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +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; + + const LocationPin({ + super.key, + 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 SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + // Pin shadow + Positioned( + bottom: 2, + child: Container( + width: size * 0.4, + height: size * 0.2, + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.3), + borderRadius: BorderRadius.circular(size * 0.1), + ), + ), + ), + // Main thumbtack pin + Icon( + Icons.push_pin, + size: size, + color: _pinColor, + ), + // Inner dot for better visibility + Positioned( + top: size * 0.2, + child: Container( + width: size * 0.3, + height: size * 0.3, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: _pinColor.withOpacity(0.8), + width: 1.5, + ), + ), + ), + ), + ], + ), + ); + } +} + +// 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 diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart index fb166f9..1771629 100644 --- a/lib/widgets/search_bar.dart +++ b/lib/widgets/search_bar.dart @@ -3,14 +3,17 @@ 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 { final void Function(SearchResult)? onResultSelected; + final VoidCallback? onCancel; const LocationSearchBar({ super.key, this.onResultSelected, + this.onCancel, }); @override @@ -50,14 +53,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 +80,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(); @@ -100,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( @@ -164,12 +180,24 @@ class _LocationSearchBarState extends State { controller: _controller, focusNode: _focusNode, decoration: InputDecoration( - hintText: 'Search places or coordinates...', - prefixIcon: const Icon(Icons.search), + hintText: LocalizationService.instance.t('navigation.searchPlaceholder'), + prefixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.close), + onPressed: _onCancel, + tooltip: LocalizationService.instance.t('navigation.cancelSearch'), + ), + const Icon(Icons.search), + ], + ), + prefixIconConstraints: const BoxConstraints(minWidth: 80), suffixIcon: _controller.text.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: _onClear, + tooltip: LocalizationService.instance.t('actions.clear'), ) : null, border: OutlineInputBorder( @@ -186,7 +214,7 @@ class _LocationSearchBarState extends State { onChanged: _onSearchChanged, ), ), - _buildResultsList(appState.searchResults, appState.isSearchLoading), + _buildResultsList(appState.navigationSearchResults, appState.isNavigationSearchLoading), ], ); }, 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