dev mode, imperial units incl. custom scalebar

This commit is contained in:
stopflock
2026-02-06 20:28:08 -06:00
parent 8804fdadf4
commit 2620c8758e
21 changed files with 811 additions and 243 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": "英制 (英里, 英尺)"
}
}

View File

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

View File

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

View File

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

View File

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

View 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;
}
}
}

View File

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

View 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;
}

View File

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

View File

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

View File

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