From 2620c8758eefe98a046332309961e25f8ad479cc Mon Sep 17 00:00:00 2001 From: stopflock Date: Fri, 6 Feb 2026 20:28:08 -0600 Subject: [PATCH] dev mode, imperial units incl. custom scalebar --- README.md | 1 - assets/changelog.json | 14 +- lib/app_state.dart | 7 +- lib/dev_config.dart | 2 +- lib/localizations/de.json | 33 ++- lib/localizations/en.json | 33 ++- lib/localizations/es.json | 33 ++- lib/localizations/fr.json | 31 ++- lib/localizations/it.json | 31 ++- lib/localizations/pt.json | 31 ++- lib/localizations/zh.json | 31 ++- lib/screens/navigation_settings_screen.dart | 166 +++++++----- .../settings/sections/language_section.dart | 121 ++++++--- .../sections/proximity_alerts_section.dart | 111 +++++--- .../sections/suspected_locations_section.dart | 24 +- lib/services/distance_service.dart | 88 ++++++ lib/state/settings_state.dart | 27 ++ lib/widgets/custom_scale_bar.dart | 250 ++++++++++++++++++ lib/widgets/map_view.dart | 11 +- lib/widgets/navigation_sheet.dart | 7 +- pubspec.yaml | 2 +- 21 files changed, 811 insertions(+), 243 deletions(-) create mode 100644 lib/services/distance_service.dart create mode 100644 lib/widgets/custom_scale_bar.dart diff --git a/README.md b/README.md index af1f9b9..85527ea 100644 --- a/README.md +++ b/README.md @@ -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? diff --git a/assets/changelog.json b/assets/changelog.json index c3a6524..aff9776 100644 --- a/assets/changelog.json +++ b/assets/changelog.json @@ -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 '' profile when editing nodes; preserves current device tags while allowing direction and location edits", + "• Enhanced edit workflow; new '' profile preserves current tags while allowing direction and location edits", "• New '' 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": { diff --git a/lib/app_state.dart b/lib/app_state.dart index 7c892e1..78ef65d 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -133,6 +133,7 @@ class AppState extends ChangeNotifier { bool get isNavigationSearchLoading => _navigationState.isSearchLoading; List get navigationSearchResults => _navigationState.searchResults; int get navigationAvoidanceDistance => _settingsState.navigationAvoidanceDistance; + DistanceUnit get distanceUnit => _settingsState.distanceUnit; // Profile state List get profiles => _profileState.profiles; @@ -736,7 +737,11 @@ class AppState extends ChangeNotifier { /// Set navigation avoidance distance Future setNavigationAvoidanceDistance(int distance) async { await _settingsState.setNavigationAvoidanceDistance(distance); - } + } + + Future setDistanceUnit(DistanceUnit unit) async { + await _settingsState.setDistanceUnit(unit); + } // ---------- Queue Methods ---------- void clearQueue() { diff --git a/lib/dev_config.dart b/lib/dev_config.dart index 6ffe5e6..ad0ea52 100644 --- a/lib/dev_config.dart +++ b/lib/dev_config.dart @@ -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 diff --git a/lib/localizations/de.json b/lib/localizations/de.json index 51b5740..47b41da 100644 --- a/lib/localizations/de.json +++ b/lib/localizations/de.json @@ -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)" } } \ No newline at end of file diff --git a/lib/localizations/en.json b/lib/localizations/en.json index 64b9cad..fa62994 100644 --- a/lib/localizations/en.json +++ b/lib/localizations/en.json @@ -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)" } } \ No newline at end of file diff --git a/lib/localizations/es.json b/lib/localizations/es.json index 540cf5e..8cfe386 100644 --- a/lib/localizations/es.json +++ b/lib/localizations/es.json @@ -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)" } } \ No newline at end of file diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json index 4ad1eea..2dcb895 100644 --- a/lib/localizations/fr.json +++ b/lib/localizations/fr.json @@ -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)" } } \ No newline at end of file diff --git a/lib/localizations/it.json b/lib/localizations/it.json index fdb5e7d..c61fe7f 100644 --- a/lib/localizations/it.json +++ b/lib/localizations/it.json @@ -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)" } } \ No newline at end of file diff --git a/lib/localizations/pt.json b/lib/localizations/pt.json index 90f6f98..38e611b 100644 --- a/lib/localizations/pt.json +++ b/lib/localizations/pt.json @@ -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)" } } \ No newline at end of file diff --git a/lib/localizations/zh.json b/lib/localizations/zh.json index dd8ca12..ab44684 100644 --- a/lib/localizations/zh.json +++ b/lib/localizations/zh.json @@ -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": "英制 (英里, 英尺)" } } \ No newline at end of file diff --git a/lib/screens/navigation_settings_screen.dart b/lib/screens/navigation_settings_screen.dart index 6e3b218..659fe76 100644 --- a/lib/screens/navigation_settings_screen.dart +++ b/lib/screens/navigation_settings_screen.dart @@ -1,79 +1,115 @@ 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 createState() => _NavigationSettingsScreenState(); +} + +class _NavigationSettingsScreenState extends State { + late TextEditingController _distanceController; + + @override + void initState() { + super.initState(); + final appState = context.read(); + 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(); - final locService = LocalizationService.instance; - - return AnimatedBuilder( - animation: LocalizationService.instance, - builder: (context, child) => Scaffold( - appBar: AppBar( - title: Text(locService.t('navigation.navigationSettings')), - ), - body: Padding( - padding: EdgeInsets.fromLTRB( - 16, - 16, - 16, - 16 + MediaQuery.of(context).padding.bottom, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ListTile( - leading: const Icon(Icons.social_distance), - title: Text(locService.t('navigation.avoidanceDistance')), - subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')), - trailing: SizedBox( - width: 80, - child: TextFormField( - initialValue: appState.navigationAvoidanceDistance.toString(), - keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false), - textInputAction: TextInputAction.done, - decoration: const InputDecoration( - isDense: true, - contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8), - border: OutlineInputBorder(), - suffixText: 'm', + return Consumer( + 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) { + return Scaffold( + appBar: AppBar( + title: Text(locService.t('navigation.navigationSettings')), + ), + body: Padding( + padding: EdgeInsets.fromLTRB( + 16, + 16, + 16, + 16 + MediaQuery.of(context).padding.bottom, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.social_distance), + title: Text(locService.t('navigation.avoidanceDistance')), + subtitle: Text(locService.t('navigation.avoidanceDistanceSubtitle')), + trailing: SizedBox( + width: 80, + child: TextField( + controller: _distanceController, + keyboardType: const TextInputType.numberWithOptions(signed: false, decimal: false), + textInputAction: TextInputAction.done, + decoration: InputDecoration( + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8), + border: const OutlineInputBorder(), + suffixText: DistanceService.getSmallDistanceUnit(appState.distanceUnit), + ), + onSubmitted: (value) => _updateDistance(appState, value), + onEditingComplete: () => _updateDistance(appState, _distanceController.text), + ) + ) ), - onFieldSubmitted: (value) { - final distance = int.tryParse(value) ?? 250; - appState.setNavigationAvoidanceDistance(distance.clamp(0, 2000)); - } - ) - ) + + 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.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'), - ), - ], - ), - ), - ), + ); + }, + ); + }, ); } diff --git a/lib/screens/settings/sections/language_section.dart b/lib/screens/settings/sections/language_section.dart index d5df05a..6dbb42d 100644 --- a/lib/screens/settings/sections/language_section.dart +++ b/lib/screens/settings/sections/language_section.dart @@ -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,41 +52,91 @@ class _LanguageSectionState extends State { @override Widget build(BuildContext context) { - return AnimatedBuilder( - animation: LocalizationService.instance, - builder: (context, child) { - final locService = LocalizationService.instance; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // System Default option - RadioListTile( - title: Text(locService.t('settings.systemDefault')), - value: null, - groupValue: _selectedLanguage, - onChanged: _setLanguage, - ), - // English always appears second (if available) - if (locService.availableLanguages.contains('en')) - RadioListTile( - title: Text(_languageNames['en'] ?? 'English'), - value: 'en', - groupValue: _selectedLanguage, - onChanged: _setLanguage, + return Consumer( + builder: (context, appState, child) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final locService = LocalizationService.instance; + + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Language section + // System Default option + RadioListTile( + title: Text(locService.t('settings.systemDefault')), + value: null, + groupValue: _selectedLanguage, + onChanged: _setLanguage, + ), + // English always appears second (if available) + if (locService.availableLanguages.contains('en')) + RadioListTile( + title: Text(_languageNames['en'] ?? 'English'), + value: 'en', + groupValue: _selectedLanguage, + onChanged: _setLanguage, + ), + // Other language options (excluding English since it's already shown) + ...locService.availableLanguages + .where((langCode) => langCode != 'en') + .map((langCode) => + RadioListTile( + title: Text(_languageNames[langCode] ?? langCode.toUpperCase()), + value: langCode, + groupValue: _selectedLanguage, + 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( + title: Text(locService.t('units.metricDescription')), + value: DistanceUnit.metric, + groupValue: appState.distanceUnit, + onChanged: (unit) { + if (unit != null) { + appState.setDistanceUnit(unit); + } + }, + ), + + // Imperial option + RadioListTile( + title: Text(locService.t('units.imperialDescription')), + value: DistanceUnit.imperial, + groupValue: appState.distanceUnit, + onChanged: (unit) { + if (unit != null) { + appState.setDistanceUnit(unit); + } + }, + ), + ], ), - // Other language options (excluding English since it's already shown) - ...locService.availableLanguages - .where((langCode) => langCode != 'en') - .map((langCode) => - RadioListTile( - title: Text(_languageNames[langCode] ?? langCode.toUpperCase()), - value: langCode, - groupValue: _selectedLanguage, - onChanged: _setLanguage, - ), - ), - ], + ); + }, ); }, ); diff --git a/lib/screens/settings/sections/proximity_alerts_section.dart b/lib/screens/settings/sections/proximity_alerts_section.dart index 2994ce2..44acf5e 100644 --- a/lib/screens/settings/sections/proximity_alerts_section.dart +++ b/lib/screens/settings/sections/proximity_alerts_section.dart @@ -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 { void initState() { super.initState(); final appState = context.read(); + // 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 { 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 { 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: [ @@ -171,49 +193,50 @@ class _ProximityAlertsSectionState extends State { ), ], - // Distance setting (only show when enabled) - if (appState.proximityAlertsEnabled) ...[ - const SizedBox(height: 12), - Row( - children: [ - Text(locService.t('proximityAlerts.alertDistance')), - SizedBox( - width: 80, - child: TextField( - controller: _distanceController, - keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), - textInputAction: TextInputAction.done, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], - decoration: const InputDecoration( - isDense: true, - contentPadding: EdgeInsets.symmetric( - horizontal: 8, - vertical: 8, + // Distance setting (only show when enabled) + if (appState.proximityAlertsEnabled) ...[ + const SizedBox(height: 12), + Row( + children: [ + Text(locService.t('proximityAlerts.alertDistance')), + SizedBox( + width: 80, + child: TextField( + controller: _distanceController, + keyboardType: const TextInputType.numberWithOptions(signed: true, decimal: true), + textInputAction: TextInputAction.done, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + border: OutlineInputBorder(), ), - border: OutlineInputBorder(), + onSubmitted: (_) => _updateDistance(appState), + onEditingComplete: () => _updateDistance(appState), ), - onSubmitted: (_) => _updateDistance(appState), - onEditingComplete: () => _updateDistance(appState), ), - ), - const SizedBox(width: 8), - Text(locService.t('proximityAlerts.meters')), - ], - ), - const SizedBox(height: 8), - Text( - locService.t('proximityAlerts.rangeInfo', params: [ - kProximityAlertMinDistance.toString(), - kProximityAlertMaxDistance.toString(), - kProximityAlertDefaultDistance.toString(), - ]), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6), + const SizedBox(width: 8), + Text(locService.t('units.${appState.distanceUnit == DistanceUnit.metric ? 'metersLong' : 'feetLong'}')), + ], ), - ), - ], + const SizedBox(height: 8), + Text( + locService.t('proximityAlerts.rangeInfo', params: [ + 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), + ), + ), + ], ], ); }, diff --git a/lib/screens/settings/sections/suspected_locations_section.dart b/lib/screens/settings/sections/suspected_locations_section.dart index f1d8b3b..7b63900 100644 --- a/lib/screens/settings/sections/suspected_locations_section.dart +++ b/lib/screens/settings/sections/suspected_locations_section.dart @@ -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 { 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)); }, ), ), diff --git a/lib/services/distance_service.dart b/lib/services/distance_service.dart new file mode 100644 index 0000000..898fe57 --- /dev/null +++ b/lib/services/distance_service.dart @@ -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; + } + } +} \ No newline at end of file diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 8b0eb15..c2529b5 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -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 _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 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 setDistanceUnit(DistanceUnit unit) async { + if (_distanceUnit != unit) { + _distanceUnit = unit; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_distanceUnitPrefsKey, unit.index); + notifyListeners(); + } + } + } diff --git a/lib/widgets/custom_scale_bar.dart b/lib/widgets/custom_scale_bar.dart new file mode 100644 index 0000000..a8c23b7 --- /dev/null +++ b/lib/widgets/custom_scale_bar.dart @@ -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( + 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 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; +} \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 6158d9a..808e249 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -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 { 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, ); }, ), diff --git a/lib/widgets/navigation_sheet.dart b/lib/widgets/navigation_sheet.dart index a783331..312a952 100644 --- a/lib/widgets/navigation_sheet.dart +++ b/lib/widgets/navigation_sheet.dart @@ -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), diff --git a/pubspec.yaml b/pubspec.yaml index e53cc67..4cff551 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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+