Merge branch 'vector-tiles' into main

This commit is contained in:
stopflock
2025-10-04 11:29:21 -05:00
committed by GitHub
27 changed files with 1953 additions and 149 deletions

View File

@@ -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

View File

@@ -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")
}
}
}
}

View File

@@ -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

View File

@@ -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<LatLng>? 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<SearchResult> get navigationSearchResults => _navigationState.searchResults;
// Profile state
List<NodeProfile> get profiles => _profileState.profiles;
List<NodeProfile> 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<void> searchNavigation(String query) async {
await _navigationState.search(query);
}
void clearNavigationSearchResults() {
_navigationState.clearSearchResults();
}
void retryRouteCalculation() {
_navigationState.retryRouteCalculation();
}
// ---------- Settings Methods ----------
Future<void> 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);

View File

@@ -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)

View File

@@ -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ß"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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": "英尺"
}
}

View File

@@ -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<void> 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(),

View File

@@ -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<HomeScreen> with TickerProviderStateMixin {
final GlobalKey<MapViewState> _mapViewKey = GlobalKey<MapViewState>();
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<HomeScreen> 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<AppState>();
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<AppState>();
// 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<AppState>();
// 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<AppState>();
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<AppState>();
// 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<HomeScreen> 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<HomeScreen> 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<HomeScreen> 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),
),
),
),
),
),
],
),
],
),
),
),
),
],

View File

@@ -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,
),
);
}
}

View File

@@ -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),
),

View File

@@ -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<LatLng> 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<RouteResult> 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<String, dynamic>;
// 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<dynamic>?;
if (routes == null || routes.isEmpty) {
throw RoutingException('No route found between these points');
}
final route = routes[0] as Map<String, dynamic>;
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<LatLng> _decodePolyline(String encoded) {
try {
final List<LatLng> 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';
}

View File

@@ -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 = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName $kClientVersion"/>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="comment" v="$action ${p.profile.name} surveillance node"/>
</changeset>
</osm>''';

View File

@@ -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<void> 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;
}

View File

@@ -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<SearchResult> _searchResults = [];
String _lastQuery = '';
// Location state
LatLng? _provisionalPinLocation;
String? _provisionalPinAddress;
// Route state
LatLng? _routeStart;
LatLng? _routeEnd;
String? _routeStartAddress;
String? _routeEndAddress;
List<LatLng>? _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<SearchResult> 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<LatLng>? 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<void> 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();
}
}
}

View File

@@ -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<AppState>(
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),
),
],
);
},
),
),
],
);
}

View File

@@ -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<MapView> createState() => MapViewState();
@@ -200,6 +203,11 @@ class MapViewState extends State<MapView> {
void retryLocationInit() {
_gpsController.retryLocationInit();
}
/// Get current user location
LatLng? getUserLocation() {
return _gpsController.currentLocation;
}
/// Expose static methods from MapPositionManager for external access
static Future<void> clearStoredMapPosition() =>
@@ -374,10 +382,60 @@ class MapViewState extends State<MapView> {
}
}
// 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 = <Polyline>[];
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<MapView> {
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<MapView> {
session: session,
editSession: editSession,
attribution: appState.selectedTileType?.attribution,
onSearchPressed: widget.onSearchPressed,
),
// Network status indicator (top-left) - conditionally shown

View File

@@ -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<AppState>(
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(),
),
),
],
],
),
],
],
),
);
},
);
}
}

View File

@@ -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);
}
}

View File

@@ -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<LocationSearchBar> {
});
if (query.isEmpty) {
context.read<AppState>().clearSearchResults();
// Clear navigation search results instead of old search state
final appState = context.read<AppState>();
appState.clearNavigationSearchResults();
return;
}
// Debounce search to avoid too many API calls
_searchDebouncer(() {
if (mounted) {
context.read<AppState>().search(query);
final appState = context.read<AppState>();
appState.searchNavigation(query);
}
});
}
@@ -74,12 +80,22 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
void _onClear() {
_controller.clear();
context.read<AppState>().clearSearchResults();
context.read<AppState>().clearNavigationSearchResults();
setState(() {
_showResults = false;
});
}
void _onCancel() {
_controller.clear();
context.read<AppState>().clearNavigationSearchResults();
setState(() {
_showResults = false;
});
_focusNode.unfocus();
widget.onCancel?.call();
}
Widget _buildResultsList(List<SearchResult> results, bool isLoading) {
if (!_showResults) return const SizedBox.shrink();
@@ -100,24 +116,24 @@ class _LocationSearchBarState extends State<LocationSearchBar> {
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<LocationSearchBar> {
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<LocationSearchBar> {
onChanged: _onSearchChanged,
),
),
_buildResultsList(appState.searchResults, appState.isSearchLoading),
_buildResultsList(appState.navigationSearchResults, appState.isNavigationSearchLoading),
],
);
},

View File

@@ -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:

View File

@@ -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