mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
dev mode, imperial units incl. custom scalebar
This commit is contained in:
@@ -104,7 +104,6 @@ cp lib/keys.dart.example lib/keys.dart
|
||||
## Roadmap
|
||||
|
||||
### Needed Bugfixes
|
||||
- Imperial units
|
||||
- Clear search box after selecting first nav point
|
||||
- Make submission guide scarier
|
||||
- Tile cache trimming? Does fluttermap handle?
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"2.6.4": {
|
||||
"content": [
|
||||
"• Added imperial units support (miles, feet) in addition to metric units (km, meters)",
|
||||
"• Moved units setting from Navigation to Language & Region settings page"
|
||||
]
|
||||
},
|
||||
"2.6.3": {
|
||||
"content": [
|
||||
"• Improved first launch experience - location permission is now requested immediately after welcome dialog",
|
||||
"• Notification permission is now requested only when user enables proximity alerts (better UX)",
|
||||
"• Prevent edit submissions where nothing (location, tags, direction) has been changed",
|
||||
"• Allow customizing changeset comment on refine tags page",
|
||||
"• Moved upload queue pause toggle to upload queue screen for better discoverability"
|
||||
@@ -10,12 +15,9 @@
|
||||
},
|
||||
"2.6.2": {
|
||||
"content": [
|
||||
"• Enhanced edit workflow - existing device properties are preserved by default, reducing accidental tag loss during edits",
|
||||
"• New '<Existing tags>' profile when editing nodes; preserves current device tags while allowing direction and location edits",
|
||||
"• Enhanced edit workflow; new '<Existing tags>' profile preserves current tags while allowing direction and location edits",
|
||||
"• New '<Existing operator>' profile when editing nodes with operator tags; preserves operator details automatically",
|
||||
"• Tag pre-population; when switching profiles, existing node values automatically fill empty profile tags to prevent data loss",
|
||||
"• Smart operator matching - existing operator tags automatically match saved operator profiles when possible",
|
||||
"• Operator profile selection now persists across main profile selection changes"
|
||||
"• Tag pre-population; existing node values automatically fill empty profile tags to prevent data loss"
|
||||
]
|
||||
},
|
||||
"2.6.1": {
|
||||
|
||||
@@ -133,6 +133,7 @@ class AppState extends ChangeNotifier {
|
||||
bool get isNavigationSearchLoading => _navigationState.isSearchLoading;
|
||||
List<SearchResult> get navigationSearchResults => _navigationState.searchResults;
|
||||
int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _settingsState.distanceUnit;
|
||||
|
||||
// Profile state
|
||||
List<NodeProfile> get profiles => _profileState.profiles;
|
||||
@@ -738,6 +739,10 @@ class AppState extends ChangeNotifier {
|
||||
await _settingsState.setNavigationAvoidanceDistance(distance);
|
||||
}
|
||||
|
||||
Future<void> setDistanceUnit(DistanceUnit unit) async {
|
||||
await _settingsState.setDistanceUnit(unit);
|
||||
}
|
||||
|
||||
// ---------- Queue Methods ----------
|
||||
void clearQueue() {
|
||||
_uploadQueueState.clearQueue();
|
||||
|
||||
@@ -72,7 +72,7 @@ const Duration kOverpassQueryTimeout = Duration(seconds: 45); // Timeout for Ove
|
||||
const String kSuspectedLocationsCsvUrl = 'https://alprwatch.org/suspected-locations/deflock-latest.csv';
|
||||
|
||||
// Development/testing features - set to false for production builds
|
||||
const bool kEnableDevelopmentModes = true; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
const bool kEnableDevelopmentModes = false; // Set to false to hide sandbox/simulate modes and force production mode
|
||||
|
||||
// Navigation features - set to false to hide navigation UI elements while in development
|
||||
const bool kEnableNavigationFeatures = true; // Hide navigation until fully implemented
|
||||
|
||||
@@ -47,12 +47,16 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"language": "Sprache",
|
||||
"language": "Sprache & Region",
|
||||
"systemDefault": "Systemstandard",
|
||||
"aboutInfo": "Über / Informationen",
|
||||
"aboutThisApp": "Über Diese App",
|
||||
"aboutSubtitle": "App-Informationen und Credits",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
"languageSubtitle": "Wählen Sie Ihre bevorzugte Sprache und Einheiten",
|
||||
"distanceUnit": "Entfernungseinheiten",
|
||||
"distanceUnitSubtitle": "Wählen Sie zwischen metrischen (km/m) oder imperialen (mi/ft) Einheiten",
|
||||
"metricUnits": "Metrisch (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Max. angezeigte Knoten",
|
||||
"maxNodesSubtitle": "Obergrenze für die Anzahl der Knoten auf der Karte festlegen.",
|
||||
"maxNodesWarning": "Sie möchten das wahrscheinlich nicht tun, es sei denn, Sie sind absolut sicher, dass Sie einen guten Grund dafür haben.",
|
||||
@@ -82,8 +86,7 @@
|
||||
"enableNotifications": "Benachrichtigungen Aktivieren",
|
||||
"checkingPermissions": "Berechtigungen prüfen...",
|
||||
"alertDistance": "Warnentfernung: ",
|
||||
"meters": "Meter",
|
||||
"rangeInfo": "Bereich: {}-{} Meter (Standard: {})"
|
||||
"rangeInfo": "Bereich: {}-{} {} (Standard: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Knoten #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"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ß"
|
||||
"searchHistorySubtitle": "Maximale Anzahl kürzlicher Suchen zum Merken"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Verdächtige Standorte",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Koordinaten",
|
||||
"noAddressAvailable": "Keine Adresse verfügbar"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "Meter",
|
||||
"feetLong": "Fuß",
|
||||
"kilometersLong": "Kilometer",
|
||||
"milesLong": "Meilen",
|
||||
"metric": "Metrisch",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Metrisch (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -84,12 +84,16 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"language": "Language",
|
||||
"language": "Language & Region",
|
||||
"systemDefault": "System Default",
|
||||
"aboutInfo": "About / Info",
|
||||
"aboutThisApp": "About This App",
|
||||
"aboutSubtitle": "App information and credits",
|
||||
"languageSubtitle": "Choose your preferred language",
|
||||
"languageSubtitle": "Choose your preferred language and units",
|
||||
"distanceUnit": "Distance Units",
|
||||
"distanceUnitSubtitle": "Choose between metric (km/m) or imperial (mi/ft) units",
|
||||
"metricUnits": "Metric (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Max nodes drawn",
|
||||
"maxNodesSubtitle": "Set an upper limit for the number of nodes on the map.",
|
||||
"maxNodesWarning": "You probably don't want to do that unless you are absolutely sure you have a good reason for it.",
|
||||
@@ -119,8 +123,7 @@
|
||||
"enableNotifications": "Enable Notifications",
|
||||
"checkingPermissions": "Checking permissions...",
|
||||
"alertDistance": "Alert distance: ",
|
||||
"meters": "meters",
|
||||
"rangeInfo": "Range: {}-{} meters (default: {})"
|
||||
"rangeInfo": "Range: {}-{} {} (default: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Node #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"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"
|
||||
"searchHistorySubtitle": "Maximum number of recent searches to remember"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Suspected Locations",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinates",
|
||||
"noAddressAvailable": "No address available"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "meters",
|
||||
"feetLong": "feet",
|
||||
"kilometersLong": "kilometers",
|
||||
"milesLong": "miles",
|
||||
"metric": "Metric",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Metric (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -84,12 +84,16 @@
|
||||
},
|
||||
"settings": {
|
||||
"title": "Configuración",
|
||||
"language": "Idioma",
|
||||
"language": "Idioma y Región",
|
||||
"systemDefault": "Sistema por Defecto",
|
||||
"aboutInfo": "Acerca de / Información",
|
||||
"aboutThisApp": "Acerca de Esta App",
|
||||
"aboutSubtitle": "Información de la aplicación y créditos",
|
||||
"languageSubtitle": "Elige tu idioma preferido",
|
||||
"languageSubtitle": "Elige tu idioma preferido y unidades",
|
||||
"distanceUnit": "Unidades de Distancia",
|
||||
"distanceUnitSubtitle": "Elige entre unidades métricas (km/m) o imperiales (mi/ft)",
|
||||
"metricUnits": "Métrico (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Máx. nodos dibujados",
|
||||
"maxNodesSubtitle": "Establecer un límite superior para el número de nodos en el mapa.",
|
||||
"maxNodesWarning": "Probablemente no quieras hacer eso a menos que estés absolutamente seguro de que tienes una buena razón para ello.",
|
||||
@@ -119,8 +123,7 @@
|
||||
"enableNotifications": "Habilitar Notificaciones",
|
||||
"checkingPermissions": "Verificando permisos...",
|
||||
"alertDistance": "Distancia de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Rango: {}-{} metros (predeterminado: {})"
|
||||
"rangeInfo": "Rango: {}-{} {} (predeterminado: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"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"
|
||||
"searchHistorySubtitle": "Número máximo de búsquedas recientes para recordar"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Ubicaciones Sospechosas",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "No hay dirección disponible"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metros",
|
||||
"feetLong": "pies",
|
||||
"kilometersLong": "kilómetros",
|
||||
"milesLong": "millas",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "À Propos / Informations",
|
||||
"aboutThisApp": "À Propos de Cette App",
|
||||
"aboutSubtitle": "Informations sur l'application et crédits",
|
||||
"languageSubtitle": "Choisissez votre langue préférée",
|
||||
"languageSubtitle": "Choisissez votre langue préférée et unités",
|
||||
"distanceUnit": "Unités de Distance",
|
||||
"distanceUnitSubtitle": "Choisir entre unités métriques (km/m) ou impériales (mi/ft)",
|
||||
"metricUnits": "Métrique (km, m)",
|
||||
"imperialUnits": "Impérial (mi, ft)",
|
||||
"maxNodes": "Max. nœuds dessinés",
|
||||
"maxNodesSubtitle": "Définir une limite supérieure pour le nombre de nœuds sur la carte.",
|
||||
"maxNodesWarning": "Vous ne voulez probablement pas faire cela à moins d'être absolument sûr d'avoir une bonne raison de le faire.",
|
||||
@@ -119,8 +123,7 @@
|
||||
"enableNotifications": "Activer les Notifications",
|
||||
"checkingPermissions": "Vérification des autorisations...",
|
||||
"alertDistance": "Distance d'alerte : ",
|
||||
"meters": "mètres",
|
||||
"rangeInfo": "Plage : {}-{} mètres (par défaut : {})"
|
||||
"rangeInfo": "Plage : {}-{} {} (par défaut : {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nœud #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"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"
|
||||
"searchHistorySubtitle": "Nombre maximum de recherches récentes à retenir"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Emplacements Suspects",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordonnées",
|
||||
"noAddressAvailable": "Aucune adresse disponible"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "mètres",
|
||||
"feetLong": "pieds",
|
||||
"kilometersLong": "kilomètres",
|
||||
"milesLong": "milles",
|
||||
"metric": "Métrique",
|
||||
"imperial": "Impérial",
|
||||
"metricDescription": "Métrique (km, m)",
|
||||
"imperialDescription": "Impérial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "Informazioni",
|
||||
"aboutThisApp": "Informazioni su questa App",
|
||||
"aboutSubtitle": "Informazioni sull'applicazione e crediti",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita",
|
||||
"languageSubtitle": "Scegli la tua lingua preferita e unità",
|
||||
"distanceUnit": "Unità di Distanza",
|
||||
"distanceUnitSubtitle": "Scegli tra unità metriche (km/m) o imperiali (mi/ft)",
|
||||
"metricUnits": "Metrico (km, m)",
|
||||
"imperialUnits": "Imperiale (mi, ft)",
|
||||
"maxNodes": "Max nodi disegnati",
|
||||
"maxNodesSubtitle": "Imposta un limite superiore per il numero di nodi sulla mappa.",
|
||||
"maxNodesWarning": "Probabilmente non vuoi farlo a meno che non sei assolutamente sicuro di avere una buona ragione per farlo.",
|
||||
@@ -119,8 +123,7 @@
|
||||
"enableNotifications": "Abilita Notifiche",
|
||||
"checkingPermissions": "Controllo autorizzazioni...",
|
||||
"alertDistance": "Distanza di avviso: ",
|
||||
"meters": "metri",
|
||||
"rangeInfo": "Intervallo: {}-{} metri (predefinito: {})"
|
||||
"rangeInfo": "Intervallo: {}-{} {} (predefinito: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nodo #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"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"
|
||||
"searchHistorySubtitle": "Numero massimo di ricerche recenti da ricordare"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Posizioni Sospette",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordinate",
|
||||
"noAddressAvailable": "Nessun indirizzo disponibile"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metri",
|
||||
"feetLong": "piedi",
|
||||
"kilometersLong": "chilometri",
|
||||
"milesLong": "miglia",
|
||||
"metric": "Metrico",
|
||||
"imperial": "Imperiale",
|
||||
"metricDescription": "Metrico (km, m)",
|
||||
"imperialDescription": "Imperiale (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "Sobre / Informações",
|
||||
"aboutThisApp": "Sobre este App",
|
||||
"aboutSubtitle": "Informações do aplicativo e créditos",
|
||||
"languageSubtitle": "Escolha seu idioma preferido",
|
||||
"languageSubtitle": "Escolha seu idioma preferido e unidades",
|
||||
"distanceUnit": "Unidades de Distância",
|
||||
"distanceUnitSubtitle": "Escolha entre unidades métricas (km/m) ou imperiais (mi/ft)",
|
||||
"metricUnits": "Métrico (km, m)",
|
||||
"imperialUnits": "Imperial (mi, ft)",
|
||||
"maxNodes": "Máx. de nós desenhados",
|
||||
"maxNodesSubtitle": "Definir um limite superior para o número de nós no mapa.",
|
||||
"maxNodesWarning": "Você provavelmente não quer fazer isso a menos que tenha certeza absoluta de que tem uma boa razão para isso.",
|
||||
@@ -119,8 +123,7 @@
|
||||
"enableNotifications": "Habilitar Notificações",
|
||||
"checkingPermissions": "Verificando permissões...",
|
||||
"alertDistance": "Distância de alerta: ",
|
||||
"meters": "metros",
|
||||
"rangeInfo": "Faixa: {}-{} metros (padrão: {})"
|
||||
"rangeInfo": "Faixa: {}-{} {} (padrão: {})"
|
||||
},
|
||||
"node": {
|
||||
"title": "Nó #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"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"
|
||||
"searchHistorySubtitle": "Número máximo de buscas recentes para lembrar"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "Localizações Suspeitas",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "URL",
|
||||
"coordinates": "Coordenadas",
|
||||
"noAddressAvailable": "Nenhum endereço disponível"
|
||||
},
|
||||
"units": {
|
||||
"meters": "m",
|
||||
"feet": "ft",
|
||||
"kilometers": "km",
|
||||
"miles": "mi",
|
||||
"metersLong": "metros",
|
||||
"feetLong": "pés",
|
||||
"kilometersLong": "quilômetros",
|
||||
"milesLong": "milhas",
|
||||
"metric": "Métrico",
|
||||
"imperial": "Imperial",
|
||||
"metricDescription": "Métrico (km, m)",
|
||||
"imperialDescription": "Imperial (mi, ft)"
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,11 @@
|
||||
"aboutInfo": "关于 / 信息",
|
||||
"aboutThisApp": "关于此应用",
|
||||
"aboutSubtitle": "应用程序信息和鸣谢",
|
||||
"languageSubtitle": "选择您的首选语言",
|
||||
"languageSubtitle": "选择您的首选语言和单位",
|
||||
"distanceUnit": "距离单位",
|
||||
"distanceUnitSubtitle": "选择公制 (公里/米) 或英制 (英里/英尺) 单位",
|
||||
"metricUnits": "公制 (公里, 米)",
|
||||
"imperialUnits": "英制 (英里, 英尺)",
|
||||
"maxNodes": "最大节点绘制数",
|
||||
"maxNodesSubtitle": "设置地图上节点数量的上限。",
|
||||
"maxNodesWarning": "除非您确定有充分的理由,否则您可能不想这样做。",
|
||||
@@ -119,8 +123,7 @@
|
||||
"enableNotifications": "启用通知",
|
||||
"checkingPermissions": "检查权限中...",
|
||||
"alertDistance": "警报距离:",
|
||||
"meters": "米",
|
||||
"rangeInfo": "范围:{}-{} 米(默认:{})"
|
||||
"rangeInfo": "范围:{}-{} {}(默认:{})"
|
||||
},
|
||||
"node": {
|
||||
"title": "节点 #{}",
|
||||
@@ -498,13 +501,7 @@
|
||||
"avoidanceDistance": "回避距离",
|
||||
"avoidanceDistanceSubtitle": "与监控设备保持的最小距离",
|
||||
"searchHistory": "最大搜索历史",
|
||||
"searchHistorySubtitle": "要记住的最近搜索次数",
|
||||
"units": "单位",
|
||||
"unitsSubtitle": "距离和测量的显示单位",
|
||||
"metric": "公制(公里,米)",
|
||||
"imperial": "英制(英里,英尺)",
|
||||
"meters": "米",
|
||||
"feet": "英尺"
|
||||
"searchHistorySubtitle": "要记住的最近搜索次数"
|
||||
},
|
||||
"suspectedLocations": {
|
||||
"title": "疑似位置",
|
||||
@@ -540,5 +537,19 @@
|
||||
"url": "网址",
|
||||
"coordinates": "坐标",
|
||||
"noAddressAvailable": "无可用地址"
|
||||
},
|
||||
"units": {
|
||||
"meters": "米",
|
||||
"feet": "英尺",
|
||||
"kilometers": "公里",
|
||||
"miles": "英里",
|
||||
"metersLong": "米",
|
||||
"feetLong": "英尺",
|
||||
"kilometersLong": "公里",
|
||||
"milesLong": "英里",
|
||||
"metric": "公制",
|
||||
"imperial": "英制",
|
||||
"metricDescription": "公制 (公里, 米)",
|
||||
"imperialDescription": "英制 (英里, 英尺)"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/distance_service.dart';
|
||||
import '../app_state.dart';
|
||||
import '../state/settings_state.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class NavigationSettingsScreen extends StatelessWidget {
|
||||
class NavigationSettingsScreen extends StatefulWidget {
|
||||
const NavigationSettingsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<NavigationSettingsScreen> createState() => _NavigationSettingsScreenState();
|
||||
}
|
||||
|
||||
class _NavigationSettingsScreenState extends State<NavigationSettingsScreen> {
|
||||
late TextEditingController _distanceController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.navigationAvoidanceDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController = TextEditingController(
|
||||
text: displayValue.round().toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_distanceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateDistance(AppState appState, String value) {
|
||||
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 250.0 : 820.0);
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setNavigationAvoidanceDistance(metersValue.round().clamp(0, 2000));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appState = context.watch<AppState>();
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
// Update the text field when the unit or distance changes
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.navigationAvoidanceDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
if (_distanceController.text != displayValue.round().toString()) {
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) => Scaffold(
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(locService.t('navigation.navigationSettings')),
|
||||
),
|
||||
@@ -33,20 +78,18 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.navigationAvoidanceDistance.toString(),
|
||||
child: TextField(
|
||||
controller: _distanceController,
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 250;
|
||||
appState.setNavigationAvoidanceDistance(distance.clamp(0, 2000));
|
||||
}
|
||||
onSubmitted: (value) => _updateDistance(appState, value),
|
||||
onEditingComplete: () => _updateDistance(appState, _distanceController.text),
|
||||
)
|
||||
)
|
||||
),
|
||||
@@ -60,20 +103,13 @@ class NavigationSettingsScreen extends StatelessWidget {
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class LanguageSection extends StatefulWidget {
|
||||
const LanguageSection({super.key});
|
||||
@@ -49,14 +52,18 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
return AnimatedBuilder(
|
||||
animation: LocalizationService.instance,
|
||||
builder: (context, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
return Column(
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Language section
|
||||
// System Default option
|
||||
RadioListTile<String?>(
|
||||
title: Text(locService.t('settings.systemDefault')),
|
||||
@@ -83,7 +90,53 @@ class _LanguageSectionState extends State<LanguageSection> {
|
||||
onChanged: _setLanguage,
|
||||
),
|
||||
),
|
||||
|
||||
// Divider between language and units
|
||||
const SizedBox(height: 24),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Distance Units section
|
||||
Text(
|
||||
locService.t('settings.distanceUnit'),
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('settings.distanceUnitSubtitle'),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Metric option
|
||||
RadioListTile<DistanceUnit>(
|
||||
title: Text(locService.t('units.metricDescription')),
|
||||
value: DistanceUnit.metric,
|
||||
groupValue: appState.distanceUnit,
|
||||
onChanged: (unit) {
|
||||
if (unit != null) {
|
||||
appState.setDistanceUnit(unit);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
// Imperial option
|
||||
RadioListTile<DistanceUnit>(
|
||||
title: Text(locService.t('units.imperialDescription')),
|
||||
value: DistanceUnit.imperial,
|
||||
groupValue: appState.distanceUnit,
|
||||
onChanged: (unit) {
|
||||
if (unit != null) {
|
||||
appState.setDistanceUnit(unit);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/proximity_alert_service.dart';
|
||||
import '../../../services/distance_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
import '../../../dev_config.dart';
|
||||
|
||||
/// Settings section for proximity alerts configuration
|
||||
@@ -25,8 +27,13 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
final appState = context.read<AppState>();
|
||||
// Convert meters to display units for the text field
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController = TextEditingController(
|
||||
text: appState.proximityAlertDistance.toString(),
|
||||
text: displayValue.round().toString(),
|
||||
);
|
||||
_checkNotificationPermissions();
|
||||
}
|
||||
@@ -69,12 +76,18 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
|
||||
void _updateDistance(AppState appState) {
|
||||
final text = _distanceController.text.trim();
|
||||
final distance = int.tryParse(text);
|
||||
if (distance != null) {
|
||||
appState.setProximityAlertDistance(distance);
|
||||
final displayValue = double.tryParse(text);
|
||||
if (displayValue != null) {
|
||||
// Convert from display units back to meters for storage
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setProximityAlertDistance(metersValue.round());
|
||||
} else {
|
||||
// Reset to current value if invalid
|
||||
_distanceController.text = appState.proximityAlertDistance.toString();
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +97,15 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
builder: (context, appState, child) {
|
||||
final locService = LocalizationService.instance;
|
||||
|
||||
// Update the text field when the unit or distance changes
|
||||
final displayValue = DistanceService.convertFromMeters(
|
||||
appState.proximityAlertDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
);
|
||||
if (_distanceController.text != displayValue.round().toString()) {
|
||||
_distanceController.text = displayValue.round().toString();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -199,15 +221,16 @@ class _ProximityAlertsSectionState extends State<ProximityAlertsSection> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(locService.t('proximityAlerts.meters')),
|
||||
Text(locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}')),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
locService.t('proximityAlerts.rangeInfo', params: [
|
||||
kProximityAlertMinDistance.toString(),
|
||||
kProximityAlertMaxDistance.toString(),
|
||||
kProximityAlertDefaultDistance.toString(),
|
||||
DistanceService.convertFromMeters(kProximityAlertMinDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
DistanceService.convertFromMeters(kProximityAlertMaxDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}'),
|
||||
DistanceService.convertFromMeters(kProximityAlertDefaultDistance.toDouble(), appState.distanceUnit).round().toString(),
|
||||
]),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../app_state.dart';
|
||||
import '../../../services/localization_service.dart';
|
||||
import '../../../services/distance_service.dart';
|
||||
import '../../../state/settings_state.dart';
|
||||
|
||||
class SuspectedLocationsSection extends StatefulWidget {
|
||||
const SuspectedLocationsSection({super.key});
|
||||
@@ -188,22 +190,28 @@ class _SuspectedLocationsSectionState extends State<SuspectedLocationsSection> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.social_distance),
|
||||
title: Text(locService.t('suspectedLocations.minimumDistance')),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [appState.suspectedLocationMinDistance.toString()])),
|
||||
subtitle: Text(locService.t('suspectedLocations.minimumDistanceSubtitle', params: [
|
||||
DistanceService.formatDistance(appState.suspectedLocationMinDistance.toDouble(), appState.distanceUnit)
|
||||
])),
|
||||
trailing: SizedBox(
|
||||
width: 80,
|
||||
child: TextFormField(
|
||||
initialValue: appState.suspectedLocationMinDistance.toString(),
|
||||
initialValue: DistanceService.convertFromMeters(
|
||||
appState.suspectedLocationMinDistance.toDouble(),
|
||||
appState.distanceUnit
|
||||
).round().toString(),
|
||||
keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true),
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: OutlineInputBorder(),
|
||||
suffixText: 'm',
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit),
|
||||
),
|
||||
onFieldSubmitted: (value) {
|
||||
final distance = int.tryParse(value) ?? 100;
|
||||
appState.setSuspectedLocationMinDistance(distance.clamp(0, 1000));
|
||||
final displayValue = double.tryParse(value) ?? (appState.distanceUnit == DistanceUnit.metric ? 100.0 : 328.0);
|
||||
final metersValue = DistanceService.convertToMeters(displayValue, appState.distanceUnit, isSmallDistance: true);
|
||||
appState.setSuspectedLocationMinDistance(metersValue.round().clamp(0, 1000));
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
88
lib/services/distance_service.dart
Normal file
88
lib/services/distance_service.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Service for distance unit conversions and formatting
|
||||
///
|
||||
/// Follows brutalist principles: simple, explicit conversions without fancy abstractions.
|
||||
/// All APIs work in metric units (meters/km), this service only handles display formatting.
|
||||
class DistanceService {
|
||||
// Conversion constants
|
||||
static const double _metersToFeet = 3.28084;
|
||||
static const double _metersToMiles = 0.000621371;
|
||||
static const double _kmToMiles = 0.621371;
|
||||
|
||||
/// Format distance for display based on unit preference
|
||||
///
|
||||
/// For metric: uses meters for < 1000m, kilometers for >= 1000m
|
||||
/// For imperial: uses feet for < 5280ft (1 mile), miles for >= 5280ft
|
||||
static String formatDistance(double distanceInMeters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
if (distanceInMeters < 1000) {
|
||||
return '${distanceInMeters.round()} m';
|
||||
} else {
|
||||
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final distanceInFeet = distanceInMeters * _metersToFeet;
|
||||
if (distanceInFeet < 5280) {
|
||||
return '${distanceInFeet.round()} ft';
|
||||
} else {
|
||||
final distanceInMiles = distanceInMeters * _metersToMiles;
|
||||
return '${distanceInMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format large distances (like route distances) for display
|
||||
///
|
||||
/// Always uses the larger unit (km/miles) for routes
|
||||
static String formatRouteDistance(double distanceInMeters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return '${(distanceInMeters / 1000).toStringAsFixed(1)} km';
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final distanceInMiles = distanceInMeters * _metersToMiles;
|
||||
return '${distanceInMiles.toStringAsFixed(1)} mi';
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the unit suffix for small distances (used in form fields, etc.)
|
||||
static String getSmallDistanceUnit(DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return 'm';
|
||||
case DistanceUnit.imperial:
|
||||
return 'ft';
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert displayed distance value back to meters for API usage
|
||||
///
|
||||
/// This is for form fields where users enter values in their preferred units
|
||||
static double convertToMeters(double value, DistanceUnit unit, {bool isSmallDistance = true}) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return isSmallDistance ? value : value * 1000; // m or km to m
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
if (isSmallDistance) {
|
||||
return value / _metersToFeet; // ft to m
|
||||
} else {
|
||||
return value / _metersToMiles; // miles to m
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert meters to the preferred small distance unit for form display
|
||||
static double convertFromMeters(double meters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return meters;
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
return meters * _metersToFeet;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,12 @@ enum FollowMeMode {
|
||||
rotating, // Follow position and rotation based on heading
|
||||
}
|
||||
|
||||
// Enum for distance units
|
||||
enum DistanceUnit {
|
||||
metric, // kilometers, meters
|
||||
imperial, // miles, feet
|
||||
}
|
||||
|
||||
class SettingsState extends ChangeNotifier {
|
||||
static const String _offlineModePrefsKey = 'offline_mode';
|
||||
static const String _maxNodesPrefsKey = 'max_nodes';
|
||||
@@ -30,6 +36,7 @@ class SettingsState extends ChangeNotifier {
|
||||
static const String _suspectedLocationMinDistancePrefsKey = 'suspected_location_min_distance';
|
||||
static const String _pauseQueueProcessingPrefsKey = 'pause_queue_processing';
|
||||
static const String _navigationAvoidanceDistancePrefsKey = 'navigation_avoidance_distance';
|
||||
static const String _distanceUnitPrefsKey = 'distance_unit';
|
||||
|
||||
bool _offlineMode = false;
|
||||
bool _pauseQueueProcessing = false;
|
||||
@@ -43,6 +50,7 @@ class SettingsState extends ChangeNotifier {
|
||||
List<TileProvider> _tileProviders = [];
|
||||
String _selectedTileTypeId = '';
|
||||
int _navigationAvoidanceDistance = 250; // meters
|
||||
DistanceUnit _distanceUnit = DistanceUnit.metric;
|
||||
|
||||
// Getters
|
||||
bool get offlineMode => _offlineMode;
|
||||
@@ -57,6 +65,7 @@ class SettingsState extends ChangeNotifier {
|
||||
List<TileProvider> get tileProviders => List.unmodifiable(_tileProviders);
|
||||
String get selectedTileTypeId => _selectedTileTypeId;
|
||||
int get navigationAvoidanceDistance => _navigationAvoidanceDistance;
|
||||
DistanceUnit get distanceUnit => _distanceUnit;
|
||||
|
||||
/// Get the currently selected tile type
|
||||
TileType? get selectedTileType {
|
||||
@@ -109,6 +118,14 @@ class SettingsState extends ChangeNotifier {
|
||||
_navigationAvoidanceDistance = prefs.getInt(_navigationAvoidanceDistancePrefsKey) ?? 250;
|
||||
}
|
||||
|
||||
// Load distance unit
|
||||
if (prefs.containsKey(_distanceUnitPrefsKey)) {
|
||||
final unitIndex = prefs.getInt(_distanceUnitPrefsKey) ?? 0;
|
||||
if (unitIndex >= 0 && unitIndex < DistanceUnit.values.length) {
|
||||
_distanceUnit = DistanceUnit.values[unitIndex];
|
||||
}
|
||||
}
|
||||
|
||||
// Load proximity alerts settings
|
||||
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
|
||||
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
|
||||
@@ -369,4 +386,14 @@ class SettingsState extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// Set distance unit (metric or imperial)
|
||||
Future<void> setDistanceUnit(DistanceUnit unit) async {
|
||||
if (_distanceUnit != unit) {
|
||||
_distanceUnit = unit;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_distanceUnitPrefsKey, unit.index);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
250
lib/widgets/custom_scale_bar.dart
Normal file
250
lib/widgets/custom_scale_bar.dart
Normal file
@@ -0,0 +1,250 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../state/settings_state.dart';
|
||||
|
||||
/// Custom scale bar widget that respects user's distance unit preference
|
||||
///
|
||||
/// Replaces flutter_map's built-in Scalebar to support metric/imperial units.
|
||||
/// Uses the existing DistanceUnit enum from SettingsState.
|
||||
///
|
||||
/// Based on the brutalist code philosophy: simple, explicit, maintainable.
|
||||
class CustomScaleBar extends StatelessWidget {
|
||||
const CustomScaleBar({
|
||||
super.key,
|
||||
this.maxWidthPx = 120,
|
||||
this.barHeight = 8,
|
||||
this.padding = const EdgeInsets.all(10),
|
||||
this.alignment = Alignment.bottomLeft,
|
||||
this.textStyle,
|
||||
});
|
||||
|
||||
final double maxWidthPx;
|
||||
final double barHeight;
|
||||
final EdgeInsets padding;
|
||||
final Alignment alignment;
|
||||
final TextStyle? textStyle;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<AppState>(
|
||||
builder: (context, appState, child) {
|
||||
final camera = MapCamera.of(context);
|
||||
final center = camera.center;
|
||||
final zoom = camera.zoom;
|
||||
|
||||
// Calculate meters represented by maxWidthPx at current zoom around map center
|
||||
final maxMeters = _metersForPixelSpan(camera, center, zoom, maxWidthPx);
|
||||
|
||||
// Calculate nice intervals in the display unit for better user experience
|
||||
final niceMeters = _niceDistanceInDisplayUnit(maxMeters, appState.distanceUnit);
|
||||
|
||||
// Calculate actual bar width in pixels
|
||||
final metersPerPx = maxMeters / maxWidthPx;
|
||||
final barWidthPx = (niceMeters / metersPerPx).clamp(1.0, maxWidthPx);
|
||||
|
||||
// Format the label based on user's unit preference
|
||||
final label = _formatLabel(niceMeters, appState.distanceUnit);
|
||||
|
||||
// Use styling that matches the original flutter_map scale bar
|
||||
final style = textStyle ??
|
||||
const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
|
||||
return Align(
|
||||
alignment: alignment,
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: style),
|
||||
const SizedBox(height: 2),
|
||||
CustomPaint(
|
||||
size: Size(barWidthPx, barHeight + 6),
|
||||
painter: _ScaleBarPainter(barHeight: barHeight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Calculate the real-world distance represented by a pixel span at the current zoom level
|
||||
///
|
||||
/// Uses a simple approach: calculate the distance between two points that are
|
||||
/// separated by the pixel span at the map center latitude.
|
||||
double _metersForPixelSpan(
|
||||
MapCamera camera,
|
||||
LatLng center,
|
||||
double zoom,
|
||||
double pixelSpan,
|
||||
) {
|
||||
// At the equator, 1 degree of longitude = ~111,320 meters
|
||||
// At other latitudes, it's scaled by cos(latitude)
|
||||
const metersPerDegreeAtEquator = 111320.0;
|
||||
final metersPerDegreeLongitude = metersPerDegreeAtEquator * math.cos(center.latitude * math.pi / 180);
|
||||
|
||||
// Calculate degrees per pixel at this zoom level
|
||||
// Web Mercator: 360 degrees spans 2^zoom tiles, each tile is 256 pixels
|
||||
final tilesAtZoom = math.pow(2, zoom);
|
||||
final pixelsAtZoom = tilesAtZoom * 256;
|
||||
final degreesPerPixel = 360.0 / pixelsAtZoom;
|
||||
|
||||
// Calculate the longitude span represented by our pixel span
|
||||
final longitudeSpan = degreesPerPixel * pixelSpan;
|
||||
|
||||
// Convert to meters
|
||||
return longitudeSpan * metersPerDegreeLongitude;
|
||||
}
|
||||
|
||||
/// Convert a maximum distance to a "nice" rounded distance in the display unit
|
||||
///
|
||||
/// For metric: Nice intervals like 1m, 2m, 5m, 10m, 1km, 2km, 5km
|
||||
/// For imperial: Nice intervals like 1ft, 2ft, 5ft, 10ft, 1mi, 2mi, 5mi
|
||||
double _niceDistanceInDisplayUnit(double maxMeters, DistanceUnit unit) {
|
||||
if (maxMeters <= 0) return 0;
|
||||
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
return _calculateNiceDistance(maxMeters, [
|
||||
// Small metric intervals (meters)
|
||||
1, 2, 5, 10, 20, 50, 100, 200, 500,
|
||||
// Large metric intervals (kilometers, converted to meters)
|
||||
1000, 2000, 5000, 10000, 20000, 50000, 100000, 200000, 500000,
|
||||
1000000, 2000000, 5000000, 10000000,
|
||||
]);
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
const feetToMeters = 0.3048;
|
||||
const milesToMeters = 1609.34;
|
||||
|
||||
return _calculateNiceDistance(maxMeters, [
|
||||
// Small imperial intervals (feet, converted to meters)
|
||||
1 * feetToMeters,
|
||||
2 * feetToMeters,
|
||||
5 * feetToMeters,
|
||||
10 * feetToMeters,
|
||||
20 * feetToMeters,
|
||||
50 * feetToMeters,
|
||||
100 * feetToMeters,
|
||||
200 * feetToMeters,
|
||||
500 * feetToMeters,
|
||||
1000 * feetToMeters,
|
||||
2000 * feetToMeters,
|
||||
5000 * feetToMeters,
|
||||
// Large imperial intervals (miles, converted to meters)
|
||||
1 * milesToMeters,
|
||||
2 * milesToMeters,
|
||||
5 * milesToMeters,
|
||||
10 * milesToMeters,
|
||||
20 * milesToMeters,
|
||||
50 * milesToMeters,
|
||||
100 * milesToMeters,
|
||||
200 * milesToMeters,
|
||||
500 * milesToMeters,
|
||||
1000 * milesToMeters,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the largest "nice" distance that fits within the maximum
|
||||
double _calculateNiceDistance(double maxMeters, List<double> intervals) {
|
||||
// Find the largest interval that's still smaller than our max
|
||||
for (int i = intervals.length - 1; i >= 0; i--) {
|
||||
if (intervals[i] <= maxMeters) {
|
||||
return intervals[i];
|
||||
}
|
||||
}
|
||||
// Fallback to smallest interval if none fit
|
||||
return intervals.first;
|
||||
}
|
||||
|
||||
/// Format the distance label according to the user's unit preference
|
||||
///
|
||||
/// Uses the same logic as DistanceService for consistency:
|
||||
/// - Metric: meters < 1000m, kilometers ≥ 1000m
|
||||
/// - Imperial: feet < 5280ft, miles ≥ 5280ft
|
||||
String _formatLabel(double meters, DistanceUnit unit) {
|
||||
switch (unit) {
|
||||
case DistanceUnit.metric:
|
||||
if (meters >= 1000) {
|
||||
final km = meters / 1000.0;
|
||||
return '${_trim(km)} km';
|
||||
}
|
||||
return '${meters.round()} m';
|
||||
|
||||
case DistanceUnit.imperial:
|
||||
final feet = meters * 3.28084;
|
||||
if (feet >= 5280) {
|
||||
final miles = feet / 5280.0;
|
||||
return '${_trim(miles)} mi';
|
||||
}
|
||||
return '${feet.round()} ft';
|
||||
}
|
||||
}
|
||||
|
||||
/// Trim unnecessary decimal places from distance values
|
||||
String _trim(double v) {
|
||||
if (v >= 100) return v.toStringAsFixed(0);
|
||||
if (v >= 10) return v.toStringAsFixed(1).replaceAll(RegExp(r'\.0$'), '');
|
||||
return v
|
||||
.toStringAsFixed(2)
|
||||
.replaceAll(RegExp(r'0+$'), '')
|
||||
.replaceAll(RegExp(r'\.$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom painter for drawing the scale bar
|
||||
///
|
||||
/// Draws a simple horizontal line with vertical end markers,
|
||||
/// matching the style of the original flutter_map scale bar.
|
||||
class _ScaleBarPainter extends CustomPainter {
|
||||
_ScaleBarPainter({required this.barHeight});
|
||||
|
||||
final double barHeight;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.black
|
||||
..strokeWidth = 3 // Match original scale bar stroke width
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
final yTop = 2.0;
|
||||
final yBottom = yTop + barHeight;
|
||||
|
||||
// Draw horizontal base line
|
||||
canvas.drawLine(Offset(0, yBottom), Offset(size.width, yBottom), paint);
|
||||
|
||||
// Draw left vertical marker
|
||||
canvas.drawLine(Offset(0, yTop), Offset(0, yBottom), paint);
|
||||
|
||||
// Draw right vertical marker
|
||||
canvas.drawLine(Offset(size.width, yTop), Offset(size.width, yBottom), paint);
|
||||
|
||||
// Draw middle marker for longer scales (visual clarity)
|
||||
if (size.width >= 40) {
|
||||
final midX = size.width / 2;
|
||||
canvas.drawLine(
|
||||
Offset(midX, yBottom - barHeight * 0.6),
|
||||
Offset(midX, yBottom),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ScaleBarPainter oldDelegate) =>
|
||||
oldDelegate.barHeight != barHeight;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import 'proximity_alert_banner.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/proximity_alert_service.dart';
|
||||
import 'sheet_aware_map.dart';
|
||||
import 'custom_scale_bar.dart';
|
||||
|
||||
class MapView extends StatefulWidget {
|
||||
final AnimatedMapController controller;
|
||||
@@ -498,20 +499,18 @@ class MapViewState extends State<MapView> {
|
||||
selectedTileType: appState.selectedTileType,
|
||||
),
|
||||
cameraLayers,
|
||||
// Built-in scale bar from flutter_map, positioned relative to button bar with safe area
|
||||
// Custom scale bar that respects user's distance unit preference
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final safeArea = MediaQuery.of(context).padding;
|
||||
return Scalebar(
|
||||
return CustomScaleBar(
|
||||
alignment: Alignment.bottomLeft,
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPositionWithSafeArea(8, safeArea),
|
||||
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
|
||||
),
|
||||
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
|
||||
lineColor: Colors.black,
|
||||
strokeWidth: 3,
|
||||
// backgroundColor removed in flutter_map >=8 (wrap in Container if needed)
|
||||
maxWidthPx: 120,
|
||||
barHeight: 8,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../app_state.dart';
|
||||
import '../dev_config.dart';
|
||||
import '../services/localization_service.dart';
|
||||
import '../services/distance_service.dart';
|
||||
|
||||
class NavigationSheet extends StatelessWidget {
|
||||
final VoidCallback? onStartRoute;
|
||||
@@ -166,7 +167,7 @@ class NavigationSheet extends StatelessWidget {
|
||||
// Show distance from first point
|
||||
if (appState.distanceFromFirstPoint != null) ...[
|
||||
Text(
|
||||
'Distance: ${(appState.distanceFromFirstPoint! / 1000).toStringAsFixed(1)} km',
|
||||
'Distance: ${DistanceService.formatRouteDistance(appState.distanceFromFirstPoint!.toDouble(), appState.distanceUnit)}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -192,7 +193,7 @@ class NavigationSheet extends StatelessWidget {
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Trips longer than ${(kNavigationDistanceWarningThreshold / 1000).toStringAsFixed(0)} km are likely to time out. We are working to improve this; stay tuned.',
|
||||
'Trips longer than ${DistanceService.formatRouteDistance(kNavigationDistanceWarningThreshold.toDouble(), appState.distanceUnit)} are likely to time out. We are working to improve this; stay tuned.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.amber[700],
|
||||
@@ -343,7 +344,7 @@ class NavigationSheet extends StatelessWidget {
|
||||
],
|
||||
if (appState.routeDistance != null) ...[
|
||||
Text(
|
||||
LocalizationService.instance.t('navigation.distance', params: [(appState.routeDistance! / 1000).toStringAsFixed(1)]),
|
||||
'Distance: ${DistanceService.formatRouteDistance(appState.routeDistance!.toDouble(), appState.distanceUnit)}',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: deflockapp
|
||||
description: Map public surveillance infrastructure with OpenStreetMap
|
||||
publish_to: "none"
|
||||
version: 2.6.3+46 # The thing after the + is the version code, incremented with each release
|
||||
version: 2.6.4+47 # The thing after the + is the version code, incremented with each release
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0" # oauth2_client 4.x needs Dart 3.5+
|
||||
|
||||
Reference in New Issue
Block a user