mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
Merge branch 'vector-tiles' into main
This commit is contained in:
2
.github/workflows/workflow.yml
vendored
2
.github/workflows/workflow.yml
vendored
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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ß"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "英尺"
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
124
lib/screens/navigation_settings_screen.dart
Normal file
124
lib/screens/navigation_settings_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
164
lib/services/routing_service.dart
Normal file
164
lib/services/routing_service.dart
Normal 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';
|
||||
}
|
||||
@@ -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>''';
|
||||
|
||||
68
lib/services/version_service.dart
Normal file
68
lib/services/version_service.dart
Normal 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;
|
||||
}
|
||||
339
lib/state/navigation_state.dart
Normal file
339
lib/state/navigation_state.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
305
lib/widgets/navigation_sheet.dart
Normal file
305
lib/widgets/navigation_sheet.dart
Normal 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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
94
lib/widgets/provisional_pin.dart
Normal file
94
lib/widgets/provisional_pin.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
16
pubspec.lock
16
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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user