Compare commits

...

5 Commits

39 changed files with 1052 additions and 177 deletions

View File

@@ -98,16 +98,27 @@ cp lib/keys.dart.example lib/keys.dart
## Roadmap
### Needed Bugfixes
- Decide what to do for extracting nodes attached to a way/relation:
- Auto extract (how?)
- Leave it alone (wrong answer unless user chooses intentionally)
- Manual cleanup (cognitive load for users)
- Delete the old one (also wrong answer unless user chooses intentionally)
- Give multiple of these options??
- Two nodes too close together warning
- Nav start+end too close together error (warning + disable submit button?)
- Improve/retune tile fetching backoff/retry
- Disable deletes on nodes belonging to ways/relations
- Support FOV range notation: 0-360, 90-270, 10-45;90-125
- Add some builtin satellite tile provider
- Link to "my changes" on osm (username edit history)
- Are offline areas preferred for fast loading even when online? Check working.
- Fix network indicator - only done when fetch queue is empty!
### Current Development
- Add some builtin satellite tile provider
- Option to pull in profiles from NSI (man_made=surveillance only)
- Persistent cache for MY submissions: clean up when we see that node appear in overpass results or when older than 24h
- Dropdown on "refine tags" page to select acceptable options for camera:mount=
- Tutorial / info guide before submitting first node
- Link to "my changes" on osm (username edit history)
- Persistent cache for MY submissions: clean up when we see that node appear in overpass/OSM results or when older than 24h
- Dropdown on "refine tags" page to select acceptable options for camera:mount= (is this a boolean property of a profile?)
- Tutorial / info guide before submitting first node, info and links before creating first profile
- Option to pull in profiles from NSI (man_made=surveillance only?)
### On Pause
- Suspected locations expansion to more regions

View File

@@ -1,4 +1,16 @@
{
"1.4.2": {
"content": [
"• NEW: Dedicated 'Upload Queue' page - queue items are now shown in a proper list view instead of a popup",
"• NEW: 'Clear Upload Queue' button is always visible at the top of queue page, greyed out when empty",
"• NEW: 'OpenStreetMap Account' page for managing OSM login and account settings",
"• NEW: 'View My Edits on OSM' button takes you directly to your edit history on OpenStreetMap",
"• IMPROVED: Settings page organization with dedicated pages for upload management and OSM account",
"• IMPROVED: Better empty queue state with helpful messaging",
"• UX: Cleaner settings page layout with auth and queue sections moved to their own dedicated pages",
"• UX: Added informational content about OpenStreetMap on the account page"
]
},
"1.4.1": {
"content": [
"• NEW: 'Extract node from way/relation' option for constrained nodes",

View File

@@ -2,36 +2,125 @@
import 'package:flutter/material.dart';
/// Developer/build-time configuration for global/non-user-tunable constants.
/// Single source of truth with typed maps for settings auto-generation.
// Fallback tile storage estimate (KB per tile), used when no preview tile data is available
const double kFallbackTileEstimateKb = 25.0;
// Typed configuration maps - single definition of each constant
const Map<String, bool> _boolConfig = {
'kEnableDevelopmentModes': true,
'kEnableNodeEdits': true,
'kEnableNodeExtraction': false,
};
// Preview tile coordinates for tile provider previews and size estimates
const int kPreviewTileZoom = 18;
const int kPreviewTileY = 101300;
const int kPreviewTileX = 41904;
const Map<String, int> _intConfig = {
'kPreviewTileZoom': 18,
'kPreviewTileY': 101300,
'kPreviewTileX': 41904,
'kNodeMinZoomLevel': 10,
'kOsmApiMinZoomLevel': 13,
'kPreFetchZoomLevel': 10,
'kMaxPreFetchSplitDepth': 3,
'kDataRefreshIntervalSeconds': 60,
'kProximityAlertDefaultDistance': 400,
'kProximityAlertMinDistance': 50,
'kProximityAlertMaxDistance': 1600,
'kTileFetchMaxAttempts': 16,
'kTileFetchInitialDelayMs': 500,
'kTileFetchMaxDelayMs': 10000,
'kTileFetchRandomJitterMs': 250,
'kMaxUserDownloadZoomSpan': 7,
'kMaxReasonableTileCount': 20000,
'kAbsoluteMaxTileCount': 50000,
'kAbsoluteMaxZoom': 23,
};
// Direction cone for map view
const double kDirectionConeHalfAngle = 35.0; // degrees
const double kDirectionConeBaseLength = 5; // multiplier
const Color kDirectionConeColor = Color(0xD0767474); // FOV cone color
const double kDirectionConeOpacity = 0.5; // Fill opacity for FOV cones
// Base values for thickness - use helper functions below for pixel-ratio scaling
const double _kDirectionConeBorderWidthBase = 1.6;
const Map<String, double> _doubleConfig = {
'kFallbackTileEstimateKb': 25.0,
'kDirectionConeHalfAngle': 35.0,
'kDirectionConeBaseLength': 5.0,
'kDirectionConeOpacity': 0.5,
'_kDirectionConeBorderWidthBase': 1.6,
'kBottomButtonBarOffset': 4.0,
'kButtonBarHeight': 60.0,
'kAttributionSpacingAboveButtonBar': 10.0,
'kZoomIndicatorSpacingAboveButtonBar': 40.0,
'kScaleBarSpacingAboveButtonBar': 70.0,
'kZoomControlsSpacingAboveButtonBar': 20.0,
'kPreFetchAreaExpansionMultiplier': 3.0,
'kMinSpeedForRotationMps': 1.0,
'kMaxTagListHeightRatioPortrait': 0.3,
'kMaxTagListHeightRatioLandscape': 0.2,
'kNodeDoubleTapZoomDelta': 1.0,
'kScrollWheelVelocity': 0.01,
'kPinchZoomThreshold': 0.2,
'kPinchMoveThreshold': 30.0,
'kRotationThreshold': 6.0,
'kNodeIconDiameter': 18.0,
'_kNodeRingThicknessBase': 2.5,
'kNodeDotOpacity': 0.3,
'kDirectionButtonMinWidth': 22.0,
'kDirectionButtonMinHeight': 32.0,
'kTileFetchBackoffMultiplier': 1.5,
};
// Bottom button bar positioning
const double kBottomButtonBarOffset = 4.0; // Distance from screen bottom (above safe area)
const double kButtonBarHeight = 60.0; // Button height (48) + padding (12)
const Map<String, String> _stringConfig = {
'kClientName': 'DeFlock', // Read-only in settings
'kSuspectedLocationsCsvUrl': 'https://stopflock.com/app/flock_utilities_mini_latest.csv',
};
// Map overlay spacing relative to button bar top
const double kAttributionSpacingAboveButtonBar = 10.0; // Attribution above button bar top
const double kZoomIndicatorSpacingAboveButtonBar = 40.0; // Zoom indicator above button bar top
const double kScaleBarSpacingAboveButtonBar = 70.0; // Scale bar above button bar top
const double kZoomControlsSpacingAboveButtonBar = 20.0; // Zoom controls above button bar top
const Map<String, Color> _colorConfig = {
'kDirectionConeColor': Color(0xD0767474),
'kNodeRingColorReal': Color(0xFF3036F0),
'kNodeRingColorMock': Color(0xD0FFFFFF),
'kNodeRingColorPending': Color(0xD09C27B0),
'kNodeRingColorEditing': Color(0xD0FF9800),
'kNodeRingColorPendingEdit': Color(0xD0757575),
'kNodeRingColorPendingDeletion': Color(0xC0F44336),
};
const Map<String, Duration> _durationConfig = {
'kMarkerTapTimeout': Duration(milliseconds: 250),
'kDebounceCameraRefresh': Duration(milliseconds: 500),
'kFollowMeAnimationDuration': Duration(milliseconds: 600),
'kProximityAlertCooldown': Duration(minutes: 10),
};
// Dynamic accessor class
class _DevConfig {
@override
dynamic noSuchMethod(Invocation invocation) {
final name = invocation.memberName.toString().replaceAll('Symbol("', '').replaceAll('")', '');
// Check each typed map
if (_boolConfig.containsKey(name)) return _boolConfig[name];
if (_intConfig.containsKey(name)) return _intConfig[name];
if (_doubleConfig.containsKey(name)) return _doubleConfig[name];
if (_stringConfig.containsKey(name)) return _stringConfig[name];
if (_colorConfig.containsKey(name)) return _colorConfig[name];
if (_durationConfig.containsKey(name)) return _durationConfig[name];
throw NoSuchMethodError.withInvocation(this, invocation);
}
}
// Global accessor
final dynamic dev = _DevConfig();
// For settings page - combine all maps
Map<String, dynamic> get devConfigForSettings => {
..._boolConfig,
..._intConfig,
..._doubleConfig,
..._stringConfig,
..._colorConfig,
..._durationConfig,
};
// Computed constants
bool get kEnableNavigationFeatures => dev.kEnableDevelopmentModes;
// Helper to calculate bottom position relative to button bar
double bottomPositionFromButtonBar(double spacingAboveButtonBar, double safeAreaBottom) {
return safeAreaBottom + kBottomButtonBarOffset + kButtonBarHeight + spacingAboveButtonBar;
return safeAreaBottom + dev.kBottomButtonBarOffset + dev.kButtonBarHeight + spacingAboveButtonBar;
}
// Helper to get left positioning that accounts for safe area (for landscape mode)
@@ -49,25 +138,9 @@ double topPositionWithSafeArea(double baseTop, EdgeInsets safeArea) {
return baseTop + safeArea.top;
}
// Client name for OSM uploads ("created_by" tag)
const String kClientName = 'DeFlock';
// Note: Version is now dynamically retrieved from VersionService
// Suspected locations CSV URL
const String kSuspectedLocationsCsvUrl = 'https://stopflock.com/app/flock_utilities_mini_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
// Navigation features - set to false to hide navigation UI elements while in development
const bool kEnableNavigationFeatures = kEnableDevelopmentModes; // Hide navigation until fully implemented
// Node editing features - set to false to temporarily disable editing
const bool kEnableNodeEdits = true; // Set to false to temporarily disable node editing
/// Navigation availability: only dev builds, and only when online
bool enableNavigationFeatures({required bool offlineMode}) {
if (!kEnableDevelopmentModes) {
if (!dev.kEnableDevelopmentModes) {
return false; // Release builds: never allow navigation
} else {
return !offlineMode; // Dev builds: only when online
@@ -104,9 +177,9 @@ double getTagListHeightRatio(BuildContext context) {
}
// Proximity alerts configuration
const int kProximityAlertDefaultDistance = 200; // meters
const int kProximityAlertDefaultDistance = 400; // meters
const int kProximityAlertMinDistance = 50; // meters
const int kProximityAlertMaxDistance = 1000; // meters
const int kProximityAlertMaxDistance = 1600; // meters
const Duration kProximityAlertCooldown = Duration(minutes: 10); // Cooldown between alerts for same node
// Map interaction configuration
@@ -148,11 +221,11 @@ const double kDirectionButtonMinHeight = 32.0;
// Helper functions for pixel-ratio scaling
double getDirectionConeBorderWidth(BuildContext context) {
// return _kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
return _kDirectionConeBorderWidthBase;
// return dev._kDirectionConeBorderWidthBase * MediaQuery.of(context).devicePixelRatio;
return dev._kDirectionConeBorderWidthBase;
}
double getNodeRingThickness(BuildContext context) {
// return _kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
return _kNodeRingThicknessBase;
// return dev._kNodeRingThicknessBase * MediaQuery.of(context).devicePixelRatio;
return dev._kNodeRingThicknessBase;
}

View File

@@ -129,6 +129,8 @@
"simulateDescription": "Uploads simulieren (kontaktiert OSM-Server nicht)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap-Konto",
"osmAccountSubtitle": "Ihr OSM-Login verwalten und Ihre Beiträge einsehen",
"loggedInAs": "Angemeldet als {}",
"loginToOSM": "Bei OpenStreetMap anmelden",
"tapToLogout": "Zum Abmelden antippen",
@@ -138,6 +140,11 @@
"testConnectionSubtitle": "OSM-Anmeldedaten überprüfen",
"connectionOK": "Verbindung OK - Anmeldedaten sind gültig",
"connectionFailed": "Verbindung fehlgeschlagen - bitte erneut anmelden",
"viewMyEdits": "Meine Änderungen bei OSM Anzeigen",
"viewMyEditsSubtitle": "Ihr Bearbeitungsverlauf bei OpenStreetMap einsehen",
"aboutOSM": "Über OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap ist ein gemeinschaftliches Open-Source-Kartenprojekt, bei dem Mitwirkende eine kostenlose, bearbeitbare Karte der Welt erstellen und pflegen. Ihre Beiträge zu Überwachungsgeräten helfen dabei, diese Infrastruktur sichtbar und durchsuchbar zu machen.",
"visitOSM": "OpenStreetMap Besuchen",
"deleteAccount": "OSM-Konto Löschen",
"deleteAccountSubtitle": "Ihr OpenStreetMap-Konto verwalten",
"deleteAccountExplanation": "Um Ihr OpenStreetMap-Konto zu löschen, müssen Sie die OpenStreetMap-Website besuchen. Dies entfernt dauerhaft Ihr OSM-Konto und alle zugehörigen Daten.",
@@ -145,7 +152,11 @@
"goToOSM": "Zu OpenStreetMap gehen"
},
"queue": {
"title": "Upload-Warteschlange",
"subtitle": "Ausstehende Überwachungsgeräte-Uploads verwalten",
"pendingUploads": "Ausstehende Uploads: {}",
"pendingItemsCount": "Ausstehende Elemente: {}",
"nothingInQueue": "Warteschlange ist leer",
"simulateModeEnabled": "Simulationsmodus aktiviert Uploads simuliert",
"sandboxMode": "Sandbox-Modus Uploads gehen an OSM Sandbox",
"tapToViewQueue": "Zum Anzeigen der Warteschlange antippen",

View File

@@ -147,6 +147,8 @@
"simulateDescription": "Simulate uploads (does not contact OSM servers)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap Account",
"osmAccountSubtitle": "Manage your OSM login and view your contributions",
"loggedInAs": "Logged in as {}",
"loginToOSM": "Log in to OpenStreetMap",
"tapToLogout": "Tap to logout",
@@ -156,6 +158,11 @@
"testConnectionSubtitle": "Verify OSM credentials are working",
"connectionOK": "Connection OK - credentials are valid",
"connectionFailed": "Connection failed - please re-login",
"viewMyEdits": "View My Edits on OSM",
"viewMyEditsSubtitle": "See your edit history on OpenStreetMap",
"aboutOSM": "About OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap is a collaborative, open-source mapping project where contributors create and maintain a free, editable map of the world. Your surveillance device contributions help make this infrastructure visible and searchable.",
"visitOSM": "Visit OpenStreetMap",
"deleteAccount": "Delete OSM Account",
"deleteAccountSubtitle": "Manage your OpenStreetMap account",
"deleteAccountExplanation": "To delete your OpenStreetMap account, you'll need to visit the OpenStreetMap website. This will permanently remove your OSM account and all associated data.",
@@ -163,7 +170,11 @@
"goToOSM": "Go to OpenStreetMap"
},
"queue": {
"title": "Upload Queue",
"subtitle": "Manage pending surveillance device uploads",
"pendingUploads": "Pending uploads: {}",
"pendingItemsCount": "Pending Items: {}",
"nothingInQueue": "Nothing in queue",
"simulateModeEnabled": "Simulate mode enabled uploads simulated",
"sandboxMode": "Sandbox mode uploads go to OSM Sandbox",
"tapToViewQueue": "Tap to view queue",

View File

@@ -147,6 +147,8 @@
"simulateDescription": "Simular subidas (no contacta servidores OSM)"
},
"auth": {
"osmAccountTitle": "Cuenta de OpenStreetMap",
"osmAccountSubtitle": "Gestionar tu login de OSM y ver tus contribuciones",
"loggedInAs": "Conectado como {}",
"loginToOSM": "Iniciar sesión en OpenStreetMap",
"tapToLogout": "Toque para cerrar sesión",
@@ -156,6 +158,11 @@
"testConnectionSubtitle": "Verificar que las credenciales de OSM funcionen",
"connectionOK": "Conexión OK - las credenciales son válidas",
"connectionFailed": "Conexión falló - por favor, inicie sesión nuevamente",
"viewMyEdits": "Ver Mis Ediciones en OSM",
"viewMyEditsSubtitle": "Ver tu historial de ediciones en OpenStreetMap",
"aboutOSM": "Acerca de OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap es un proyecto de mapeo colaborativo de código abierto donde los contribuyentes crean y mantienen un mapa gratuito y editable del mundo. Tus contribuciones de dispositivos de vigilancia ayudan a hacer visible y buscable esta infraestructura.",
"visitOSM": "Visitar OpenStreetMap",
"deleteAccount": "Eliminar Cuenta OSM",
"deleteAccountSubtitle": "Gestiona tu cuenta de OpenStreetMap",
"deleteAccountExplanation": "Para eliminar tu cuenta de OpenStreetMap, necesitarás visitar el sitio web de OpenStreetMap. Esto eliminará permanentemente tu cuenta OSM y todos los datos asociados.",
@@ -163,7 +170,11 @@
"goToOSM": "Ir a OpenStreetMap"
},
"queue": {
"title": "Cola de Subida",
"subtitle": "Gestionar subidas pendientes de dispositivos de vigilancia",
"pendingUploads": "Subidas pendientes: {}",
"pendingItemsCount": "Elementos Pendientes: {}",
"nothingInQueue": "No hay nada en la cola",
"simulateModeEnabled": "Modo simulación activado subidas simuladas",
"sandboxMode": "Modo sandbox subidas van al Sandbox OSM",
"tapToViewQueue": "Toque para ver cola",

View File

@@ -147,6 +147,8 @@
"simulateDescription": "Simuler les téléchargements (ne contacte pas les serveurs OSM)"
},
"auth": {
"osmAccountTitle": "Compte OpenStreetMap",
"osmAccountSubtitle": "Gérer votre connexion OSM et voir vos contributions",
"loggedInAs": "Connecté en tant que {}",
"loginToOSM": "Se connecter à OpenStreetMap",
"tapToLogout": "Appuyer pour se déconnecter",
@@ -156,6 +158,11 @@
"testConnectionSubtitle": "Vérifier que les identifiants OSM fonctionnent",
"connectionOK": "Connexion OK - les identifiants sont valides",
"connectionFailed": "Connexion échouée - veuillez vous reconnecter",
"viewMyEdits": "Voir Mes Modifications sur OSM",
"viewMyEditsSubtitle": "Voir votre historique de modifications sur OpenStreetMap",
"aboutOSM": "À Propos d'OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap est un projet cartographique collaboratif open source où les contributeurs créent et maintiennent une carte gratuite et modifiable du monde. Vos contributions de dispositifs de surveillance aident à rendre cette infrastructure visible et consultable.",
"visitOSM": "Visiter OpenStreetMap",
"deleteAccount": "Supprimer Compte OSM",
"deleteAccountSubtitle": "Gérez votre compte OpenStreetMap",
"deleteAccountExplanation": "Pour supprimer votre compte OpenStreetMap, vous devrez visiter le site web OpenStreetMap. Cela supprimera définitivement votre compte OSM et toutes les données associées.",
@@ -163,7 +170,11 @@
"goToOSM": "Aller à OpenStreetMap"
},
"queue": {
"title": "File de Téléchargement",
"subtitle": "Gérer les téléchargements de dispositifs de surveillance en attente",
"pendingUploads": "Téléchargements en attente: {}",
"pendingItemsCount": "Éléments en Attente: {}",
"nothingInQueue": "Rien dans la file",
"simulateModeEnabled": "Mode simulation activé téléchargements simulés",
"sandboxMode": "Mode sandbox téléchargements vont vers OSM Sandbox",
"tapToViewQueue": "Appuyer pour voir la file",

View File

@@ -147,6 +147,8 @@
"simulateDescription": "Simula upload (non contatta i server OSM)"
},
"auth": {
"osmAccountTitle": "Account OpenStreetMap",
"osmAccountSubtitle": "Gestisci il tuo login OSM e visualizza i tuoi contributi",
"loggedInAs": "Loggato come {}",
"loginToOSM": "Accedi a OpenStreetMap",
"tapToLogout": "Tocca per disconnetterti",
@@ -156,6 +158,11 @@
"testConnectionSubtitle": "Verifica che le credenziali OSM funzionino",
"connectionOK": "Connessione OK - le credenziali sono valide",
"connectionFailed": "Connessione fallita - per favore accedi di nuovo",
"viewMyEdits": "Visualizza le Mie Modifiche su OSM",
"viewMyEditsSubtitle": "Visualizza la cronologia delle tue modifiche su OpenStreetMap",
"aboutOSM": "Informazioni su OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap è un progetto cartografico collaborativo open source dove i contributori creano e mantengono una mappa gratuita e modificabile del mondo. I tuoi contributi sui dispositivi di sorveglianza aiutano a rendere visibile e ricercabile questa infrastruttura.",
"visitOSM": "Visita OpenStreetMap",
"deleteAccount": "Elimina Account OSM",
"deleteAccountSubtitle": "Gestisci il tuo account OpenStreetMap",
"deleteAccountExplanation": "Per eliminare il tuo account OpenStreetMap, dovrai visitare il sito web di OpenStreetMap. Questo rimuoverà permanentemente il tuo account OSM e tutti i dati associati.",
@@ -163,7 +170,11 @@
"goToOSM": "Vai a OpenStreetMap"
},
"queue": {
"title": "Coda di Upload",
"subtitle": "Gestisci gli upload di dispositivi di sorveglianza in sospeso",
"pendingUploads": "Upload in sospeso: {}",
"pendingItemsCount": "Elementi in Sospeso: {}",
"nothingInQueue": "Niente in coda",
"simulateModeEnabled": "Modalità simulazione abilitata upload simulati",
"sandboxMode": "Modalità sandbox upload vanno alla Sandbox OSM",
"tapToViewQueue": "Tocca per vedere la coda",

View File

@@ -147,6 +147,8 @@
"simulateDescription": "Simular uploads (não contacta servidores OSM)"
},
"auth": {
"osmAccountTitle": "Conta OpenStreetMap",
"osmAccountSubtitle": "Gerencie seu login OSM e visualize suas contribuições",
"loggedInAs": "Logado como {}",
"loginToOSM": "Fazer login no OpenStreetMap",
"tapToLogout": "Toque para sair",
@@ -156,6 +158,11 @@
"testConnectionSubtitle": "Verificar se as credenciais OSM estão funcionando",
"connectionOK": "Conexão OK - credenciais são válidas",
"connectionFailed": "Conexão falhou - por favor, faça login novamente",
"viewMyEdits": "Ver Minhas Edições no OSM",
"viewMyEditsSubtitle": "Ver seu histórico de edições no OpenStreetMap",
"aboutOSM": "Sobre OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap é um projeto de mapeamento colaborativo de código aberto onde os contribuintes criam e mantêm um mapa gratuito e editável do mundo. Suas contribuições de dispositivos de vigilância ajudam a tornar esta infraestrutura visível e pesquisável.",
"visitOSM": "Visitar OpenStreetMap",
"deleteAccount": "Excluir Conta OSM",
"deleteAccountSubtitle": "Gerencie sua conta OpenStreetMap",
"deleteAccountExplanation": "Para excluir sua conta OpenStreetMap, você precisará visitar o site do OpenStreetMap. Isso removerá permanentemente sua conta OSM e todos os dados associados.",
@@ -163,7 +170,11 @@
"goToOSM": "Ir para OpenStreetMap"
},
"queue": {
"title": "Fila de Upload",
"subtitle": "Gerenciar uploads pendentes de dispositivos de vigilância",
"pendingUploads": "Uploads pendentes: {}",
"pendingItemsCount": "Itens Pendentes: {}",
"nothingInQueue": "Nada na fila",
"simulateModeEnabled": "Modo simulação ativado uploads simulados",
"sandboxMode": "Modo sandbox uploads vão para o Sandbox OSM",
"tapToViewQueue": "Toque para ver a fila",

View File

@@ -147,6 +147,8 @@
"simulateDescription": "模拟上传(不联系 OSM 服务器)"
},
"auth": {
"osmAccountTitle": "OpenStreetMap 账户",
"osmAccountSubtitle": "管理您的 OSM 登录并查看您的贡献",
"loggedInAs": "已登录为 {}",
"loginToOSM": "登录 OpenStreetMap",
"tapToLogout": "点击登出",
@@ -156,6 +158,11 @@
"testConnectionSubtitle": "验证 OSM 凭据是否有效",
"connectionOK": "连接正常 - 凭据有效",
"connectionFailed": "连接失败 - 请重新登录",
"viewMyEdits": "在 OSM 上查看我的编辑",
"viewMyEditsSubtitle": "查看您在 OpenStreetMap 上的编辑历史",
"aboutOSM": "关于 OpenStreetMap",
"aboutOSMDescription": "OpenStreetMap 是一个协作的开源地图项目,贡献者创建和维护一个免费的、可编辑的世界地图。您的监控设备贡献有助于使这种基础设施可见和可搜索。",
"visitOSM": "访问 OpenStreetMap",
"deleteAccount": "删除 OSM 账户",
"deleteAccountSubtitle": "管理您的 OpenStreetMap 账户",
"deleteAccountExplanation": "要删除您的 OpenStreetMap 账户,您需要访问 OpenStreetMap 网站。这将永久删除您的 OSM 账户和所有相关数据。",
@@ -163,7 +170,11 @@
"goToOSM": "前往 OpenStreetMap"
},
"queue": {
"title": "上传队列",
"subtitle": "管理待上传的监控设备",
"pendingUploads": "待上传:{}",
"pendingItemsCount": "待处理项目:{}",
"nothingInQueue": "队列中没有内容",
"simulateModeEnabled": "模拟模式已启用 上传已模拟",
"sandboxMode": "沙盒模式 上传到 OSM 沙盒",
"tapToViewQueue": "点击查看队列",

View File

@@ -8,9 +8,12 @@ import 'screens/profiles_settings_screen.dart';
import 'screens/navigation_settings_screen.dart';
import 'screens/offline_settings_screen.dart';
import 'screens/advanced_settings_screen.dart';
import 'screens/developer_settings_screen.dart';
import 'screens/language_settings_screen.dart';
import 'screens/about_screen.dart';
import 'screens/release_notes_screen.dart';
import 'screens/osm_account_screen.dart';
import 'screens/upload_queue_screen.dart';
import 'services/localization_service.dart';
import 'services/version_service.dart';
@@ -69,10 +72,13 @@ class DeFlockApp extends StatelessWidget {
routes: {
'/': (context) => const HomeScreen(),
'/settings': (context) => const SettingsScreen(),
'/settings/osm-account': (context) => const OSMAccountScreen(),
'/settings/queue': (context) => const UploadQueueScreen(),
'/settings/profiles': (context) => const ProfilesSettingsScreen(),
'/settings/navigation': (context) => const NavigationSettingsScreen(),
'/settings/offline': (context) => const OfflineSettingsScreen(),
'/settings/advanced': (context) => const AdvancedSettingsScreen(),
'/settings/developer': (context) => const DeveloperSettingsScreen(),
'/settings/language': (context) => const LanguageSettingsScreen(),
'/settings/about': (context) => const AboutScreen(),
'/settings/release-notes': (context) => const ReleaseNotesScreen(),

View File

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../dev_config.dart';
class DeveloperSettingsScreen extends StatefulWidget {
const DeveloperSettingsScreen({super.key});
@override
State<DeveloperSettingsScreen> createState() => _DeveloperSettingsScreenState();
}
class _DeveloperSettingsScreenState extends State<DeveloperSettingsScreen> {
final Map<String, TextEditingController> _controllers = {};
final Map<String, dynamic> _overrides = {};
@override
void initState() {
super.initState();
_initializeControllers();
}
@override
void dispose() {
for (final controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
void _initializeControllers() {
for (final entry in devConfigForSettings.entries) {
if (entry.value is String) {
_controllers[entry.key] = TextEditingController(text: entry.value);
} else if (entry.value is int) {
_controllers[entry.key] = TextEditingController(text: entry.value.toString());
} else if (entry.value is double) {
_controllers[entry.key] = TextEditingController(text: entry.value.toString());
} else if (entry.value is Color) {
final color = entry.value as Color;
final hex = color.value.toRadixString(16).padLeft(8, '0').toUpperCase();
_controllers[entry.key] = TextEditingController(text: hex);
} else if (entry.value is Duration) {
final duration = entry.value as Duration;
_controllers[entry.key] = TextEditingController(text: duration.inMilliseconds.toString());
}
}
}
void _saveAndRestart() {
// For now, just show a dialog - actual restart would require platform channels
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Restart Required'),
content: const Text('Changes saved. Please restart the app to apply new settings.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
),
);
}
Widget _buildSettingWidget(String key, dynamic defaultValue) {
if (key == 'kClientName') {
// Special read-only case
return ListTile(
title: Text(key),
subtitle: Text(defaultValue.toString()),
trailing: const Text('READ ONLY'),
);
}
if (defaultValue is bool) {
return SwitchListTile(
title: Text(key),
value: _overrides[key] ?? defaultValue,
onChanged: (value) {
setState(() {
_overrides[key] = value;
});
},
);
} else if (defaultValue is int || defaultValue is double || defaultValue is String ||
defaultValue is Color || defaultValue is Duration) {
return ListTile(
title: Text(key),
subtitle: TextField(
controller: _controllers[key],
keyboardType: defaultValue is int || defaultValue is double
? const TextInputType.numberWithOptions(signed: true, decimal: true)
: TextInputType.text,
textInputAction: TextInputAction.done,
onChanged: (value) {
// Store the string value for now - actual parsing would happen on save
_overrides[key] = value;
},
),
);
}
return const SizedBox.shrink();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Developer Settings'),
actions: [
TextButton(
onPressed: _saveAndRestart,
child: const Text('SAVE', style: TextStyle(color: Colors.white)),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: devConfigForSettings.entries
.map((entry) => _buildSettingWidget(entry.key, entry.value))
.toList(),
),
);
}
}

View File

@@ -713,7 +713,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
final safeArea = MediaQuery.of(context).padding;
return Padding(
padding: EdgeInsets.only(
bottom: safeArea.bottom + kBottomButtonBarOffset,
bottom: safeArea.bottom + dev.kBottomButtonBarOffset,
left: leftPositionWithSafeArea(8, safeArea),
right: rightPositionWithSafeArea(8, safeArea),
),
@@ -731,7 +731,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
)
],
),
margin: EdgeInsets.only(bottom: kBottomButtonBarOffset),
margin: EdgeInsets.only(bottom: dev.kBottomButtonBarOffset),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../dev_config.dart';
import '../state/settings_state.dart';
import '../screens/settings/sections/upload_mode_section.dart';
class OSMAccountScreen extends StatelessWidget {
const OSMAccountScreen({super.key});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(
title: Text(locService.t('auth.osmAccountTitle')),
),
body: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Login/Account Status Section
Card(
child: Column(
children: [
ListTile(
leading: Icon(
appState.isLoggedIn ? Icons.person : Icons.login,
color: appState.isLoggedIn ? Colors.green : null,
),
title: Text(appState.isLoggedIn
? locService.t('auth.loggedInAs', params: [appState.username])
: locService.t('auth.loginToOSM')),
subtitle: appState.isLoggedIn
? Text(locService.t('auth.tapToLogout'))
: Text(locService.t('auth.requiredToSubmit')),
onTap: () async {
if (appState.isLoggedIn) {
await appState.logout();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(locService.t('auth.loggedOut')),
backgroundColor: Colors.grey,
),
);
}
} else {
// Start login flow - the user will be redirected to browser
await appState.forceLogin();
// Don't show immediate feedback - the UI will update automatically
// when the OAuth callback completes and notifyListeners() is called
}
},
),
if (appState.isLoggedIn) ...[
const Divider(),
ListTile(
leading: const Icon(Icons.wifi_protected_setup),
title: Text(locService.t('auth.testConnection')),
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
onTap: () async {
final isValid = await appState.validateToken();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isValid
? locService.t('auth.connectionOK')
: locService.t('auth.connectionFailed')),
backgroundColor: isValid ? Colors.green : Colors.red,
),
);
}
if (!isValid) {
await appState.logout();
}
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.history),
title: Text(locService.t('auth.viewMyEdits')),
subtitle: Text(locService.t('auth.viewMyEditsSubtitle')),
trailing: const Icon(Icons.open_in_new),
onTap: () async {
final url = Uri.parse('https://openstreetmap.org/user/${Uri.encodeComponent(appState.username)}/history');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
),
],
],
),
),
const SizedBox(height: 16),
// Upload Mode Section (only show in development builds)
if (dev.kEnableDevelopmentModes) ...[
Card(
child: const Padding(
padding: EdgeInsets.all(16.0),
child: UploadModeSection(),
),
),
const SizedBox(height: 16),
],
// Information Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('auth.aboutOSM'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
locService.t('auth.aboutOSMDescription'),
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final url = Uri.parse('https://openstreetmap.org');
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('advancedEdit.couldNotOpenOSMWebsite'))),
);
}
}
},
icon: const Icon(Icons.open_in_new),
label: Text(locService.t('auth.visitOSM')),
),
),
],
),
),
),
],
),
);
},
);
}
}

View File

@@ -1,14 +1,41 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'settings/sections/auth_section.dart';
import 'settings/sections/upload_mode_section.dart';
import 'settings/sections/queue_section.dart';
import '../services/localization_service.dart';
import '../services/version_service.dart';
import '../dev_config.dart';
class SettingsScreen extends StatelessWidget {
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
int _versionTapCount = 0;
Timer? _tapTimer;
@override
void dispose() {
_tapTimer?.cancel();
super.dispose();
}
void _onVersionTap() {
_tapTimer?.cancel();
_versionTapCount++;
if (_versionTapCount >= 10) {
Navigator.pushNamed(context, '/settings/developer');
_versionTapCount = 0;
return;
}
_tapTimer = Timer(const Duration(milliseconds: 400), () {
_versionTapCount = 0;
});
}
@override
Widget build(BuildContext context) {
final locService = LocalizationService.instance;
@@ -25,14 +52,24 @@ class SettingsScreen extends StatelessWidget {
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Only show upload mode section in development builds
if (kEnableDevelopmentModes) ...[
const UploadModeSection(),
const Divider(),
],
const AuthSection(),
// OpenStreetMap Account
_buildNavigationTile(
context,
icon: Icons.account_circle,
title: locService.t('auth.osmAccountTitle'),
subtitle: locService.t('auth.osmAccountSubtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/osm-account'),
),
const Divider(),
const QueueSection(),
// Upload Queue
_buildNavigationTile(
context,
icon: Icons.queue,
title: locService.t('queue.title'),
subtitle: locService.t('queue.subtitle'),
onTap: () => Navigator.pushNamed(context, '/settings/queue'),
),
const Divider(),
// Navigation to sub-pages
@@ -93,15 +130,18 @@ class SettingsScreen extends StatelessWidget {
),
const Divider(),
// Version display
// Version display with secret tap counter
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Version: ${VersionService().version}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
child: GestureDetector(
onTap: _onVersionTap,
child: Text(
'Version: ${VersionService().version}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
textAlign: TextAlign.center,
),
),
],

View File

@@ -345,7 +345,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.maxZoomRequired');
final zoom = int.tryParse(value!);
if (zoom == null) return locService.t('tileTypeEditor.maxZoomInvalid');
if (zoom < 1 || zoom > kAbsoluteMaxZoom) return locService.t('tileTypeEditor.maxZoomRange', params: ['1', kAbsoluteMaxZoom.toString()]);
if (zoom < 1 || zoom > dev.kAbsoluteMaxZoom) return locService.t('tileTypeEditor.maxZoomRange', params: ['1', kAbsoluteMaxZoom.toString()]);
return null;
},
),
@@ -405,9 +405,9 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
try {
// Use a sample tile from configured preview location
final url = _urlController.text
.replaceAll('{z}', kPreviewTileZoom.toString())
.replaceAll('{x}', kPreviewTileX.toString())
.replaceAll('{y}', kPreviewTileY.toString());
.replaceAll('{z}', dev.kPreviewTileZoom.toString())
.replaceAll('{x}', dev.kPreviewTileX.toString())
.replaceAll('{y}', dev.kPreviewTileY.toString());
final response = await http.get(Uri.parse(url));

View File

@@ -0,0 +1,189 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../app_state.dart';
import '../services/localization_service.dart';
import '../state/settings_state.dart';
class UploadQueueScreen extends StatelessWidget {
const UploadQueueScreen({super.key});
String _getUploadModeDisplayName(UploadMode mode) {
final locService = LocalizationService.instance;
switch (mode) {
case UploadMode.production:
return locService.t('uploadMode.production');
case UploadMode.sandbox:
return locService.t('uploadMode.sandbox');
case UploadMode.simulate:
return locService.t('uploadMode.simulate');
}
}
Color _getUploadModeColor(UploadMode mode) {
switch (mode) {
case UploadMode.production:
return Colors.green; // Green for production (real)
case UploadMode.sandbox:
return Colors.orange; // Orange for sandbox (testing)
case UploadMode.simulate:
return Colors.grey; // Grey for simulate (fake)
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Scaffold(
appBar: AppBar(
title: Text(locService.t('queue.title')),
),
body: ListView(
padding: EdgeInsets.fromLTRB(
16,
16,
16,
16 + MediaQuery.of(context).padding.bottom,
),
children: [
// Clear Upload Queue button - always visible
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: appState.pendingCount > 0 ? () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('queue.clearQueueTitle')),
content: Text(locService.t('queue.clearQueueConfirm', params: [appState.pendingCount.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.cancel),
),
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('queue.queueCleared'))),
);
},
child: Text(locService.t('actions.clear')),
),
],
),
);
} : null,
icon: const Icon(Icons.clear_all),
label: Text(locService.t('queue.clearUploadQueue')),
style: ElevatedButton.styleFrom(
backgroundColor: appState.pendingCount > 0 ? null : Theme.of(context).disabledColor.withOpacity(0.1),
),
),
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Queue list or empty message
if (appState.pendingUploads.isEmpty) ...[
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
children: [
Icon(
Icons.check_circle_outline,
size: 64,
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.4),
),
const SizedBox(height: 16),
Text(
locService.t('queue.nothingInQueue'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color?.withOpacity(0.6),
),
textAlign: TextAlign.center,
),
],
),
),
),
] else ...[
Text(
locService.t('queue.pendingItemsCount', params: [appState.pendingCount.toString()]),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
// Queue items
...appState.pendingUploads.asMap().entries.map((entry) {
final index = entry.key;
final upload = entry.value;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
leading: Icon(
upload.error ? Icons.error : Icons.camera_alt,
color: upload.error
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text(
locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
(upload.error ? locService.t('queue.error') : "") +
(upload.completing ? locService.t('queue.completing') : "")
),
subtitle: Text(
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.direction', params: [
upload.direction is String
? upload.direction.toString()
: upload.direction.round().toString()
]) + '\n' +
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (upload.error && !upload.completing)
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: locService.t('queue.retryUpload'),
onPressed: () {
appState.retryUpload(upload);
},
),
if (upload.completing)
const Icon(Icons.check_circle, color: Colors.green)
else
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
appState.removeFromQueue(upload);
},
),
],
),
),
);
}),
],
],
),
);
},
);
}
}

View File

@@ -44,7 +44,7 @@ Future<List<OsmNode>> _fetchOverpassNodesWithSplitting({
}) async {
if (profiles.isEmpty) return [];
const int maxSplitDepth = kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
final int maxSplitDepth = dev.dev.kMaxPreFetchSplitDepth; // Maximum times we'll split (4^3 = 64 max sub-areas)
try {
return await _fetchSingleOverpassQuery(

View File

@@ -36,14 +36,14 @@ void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
int _calculateRetryDelay(int attempt, Random random) {
// Calculate exponential backoff: initialDelay * (multiplier ^ (attempt - 1))
final baseDelay = (kTileFetchInitialDelayMs *
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
final baseDelay = (dev.kTileFetchInitialDelayMs *
pow(dev.kTileFetchBackoffMultiplier, attempt - 1)).round();
// Add random jitter to avoid thundering herd
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
final jitter = random.nextInt(dev.kTileFetchRandomJitterMs + 1);
// Apply max delay cap
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
return (baseDelay + jitter).clamp(0, dev.kTileFetchMaxDelayMs);
}
/// Convert tile coordinates to lat/lng bounds for spatial filtering
@@ -101,7 +101,7 @@ Future<List<int>> fetchRemoteTile({
required int y,
required String url,
}) async {
const int maxAttempts = kTileFetchMaxAttempts;
final int maxAttempts = dev.dev.kTileFetchMaxAttempts;
int attempt = 0;
final random = Random();
final hostInfo = Uri.parse(url).host; // For logging

View File

@@ -30,8 +30,8 @@ class PrefetchAreaService {
Timer? _debounceTimer;
// Configuration from dev_config
static const double _areaExpansionMultiplier = kPreFetchAreaExpansionMultiplier;
static const int _preFetchZoomLevel = kPreFetchZoomLevel;
static final double _areaExpansionMultiplier = dev.dev.kPreFetchAreaExpansionMultiplier;
static final int _preFetchZoomLevel = dev.dev.kPreFetchZoomLevel;
/// Check if the given bounds are fully within the current pre-fetched area.
bool isWithinPreFetchedArea(LatLngBounds bounds, List<NodeProfile> profiles, UploadMode uploadMode) {
@@ -58,7 +58,7 @@ class PrefetchAreaService {
/// Check if cached data is stale (older than configured refresh interval).
bool isDataStale() {
if (_lastFetchTime == null) return true;
return DateTime.now().difference(_lastFetchTime!).inSeconds > kDataRefreshIntervalSeconds;
return DateTime.now().difference(_lastFetchTime!).inSeconds > dev.kDataRefreshIntervalSeconds;
}
/// Request pre-fetch for the given view bounds if not already covered or if data is stale.
@@ -84,7 +84,7 @@ class PrefetchAreaService {
}
if (isStale) {
debugPrint('[PrefetchAreaService] Data is stale (>${kDataRefreshIntervalSeconds}s), refreshing');
debugPrint('[PrefetchAreaService] Data is stale (>${dev.kDataRefreshIntervalSeconds}s), refreshing');
} else {
debugPrint('[PrefetchAreaService] Current view outside pre-fetched area, fetching larger area');
}

View File

@@ -28,7 +28,7 @@ class ProximityAlertService {
// Simple in-memory tracking of recent alerts to prevent spam
final List<RecentAlert> _recentAlerts = [];
static const Duration _alertCooldown = kProximityAlertCooldown;
static final Duration _alertCooldown = dev.dev.kProximityAlertCooldown;
// Callback for showing in-app visual alerts
VoidCallback? _onVisualAlert;

View File

@@ -102,10 +102,10 @@ class SuspectedLocationService {
/// Fetch data from the CSV URL
Future<bool> _fetchData() async {
try {
debugPrint('[SuspectedLocationService] Fetching CSV data from $kSuspectedLocationsCsvUrl');
debugPrint('[SuspectedLocationService] Fetching CSV data from $dev.kSuspectedLocationsCsvUrl');
final response = await http.get(
Uri.parse(kSuspectedLocationsCsvUrl),
Uri.parse(dev.kSuspectedLocationsCsvUrl),
headers: {
'User-Agent': 'DeFlock/1.0 (OSM surveillance mapping app)',
},

View File

@@ -61,7 +61,7 @@ class TilePreviewService {
static Future<Uint8List?> _fetchPreviewForTileType(TileType tileType, String? apiKey) async {
try {
final url = tileType.getTileUrl(kPreviewTileZoom, kPreviewTileX, kPreviewTileY, apiKey: apiKey);
final url = tileType.getTileUrl(dev.kPreviewTileZoom, dev.kPreviewTileX, dev.kPreviewTileY, apiKey: apiKey);
final response = await http.get(Uri.parse(url)).timeout(_timeout);

View File

@@ -44,7 +44,7 @@ class Uploader {
final csXml = '''
<osm>
<changeset>
<tag k="created_by" v="$kClientName ${VersionService().version}"/>
<tag k="created_by" v="$dev.kClientName ${VersionService().version}"/>
<tag k="comment" v="$action $profileName surveillance node"/>
</changeset>
</osm>''';

View File

@@ -33,10 +33,10 @@ class SettingsState extends ChangeNotifier {
bool _offlineMode = false;
bool _pauseQueueProcessing = false;
int _maxCameras = 250;
UploadMode _uploadMode = kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
UploadMode _uploadMode = dev.kEnableDevelopmentModes ? UploadMode.simulate : UploadMode.production;
FollowMeMode _followMeMode = FollowMeMode.follow;
bool _proximityAlertsEnabled = false;
int _proximityAlertDistance = kProximityAlertDefaultDistance;
int _proximityAlertDistance = dev.kProximityAlertDefaultDistance;
bool _networkStatusIndicatorEnabled = true;
int _suspectedLocationMinDistance = 100; // meters
List<TileProvider> _tileProviders = [];
@@ -105,7 +105,7 @@ class SettingsState extends ChangeNotifier {
// Load proximity alerts settings
_proximityAlertsEnabled = prefs.getBool(_proximityAlertsEnabledPrefsKey) ?? false;
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? kProximityAlertDefaultDistance;
_proximityAlertDistance = prefs.getInt(_proximityAlertDistancePrefsKey) ?? dev.kProximityAlertDefaultDistance;
// Load network status indicator setting
_networkStatusIndicatorEnabled = prefs.getBool(_networkStatusIndicatorEnabledPrefsKey) ?? true;
@@ -128,7 +128,7 @@ class SettingsState extends ChangeNotifier {
}
// In production builds, force production mode if development modes are disabled
if (!kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
if (!dev.kEnableDevelopmentModes && _uploadMode != UploadMode.production) {
debugPrint('SettingsState: Development modes disabled, forcing production mode');
_uploadMode = UploadMode.production;
await prefs.setInt(_uploadModePrefsKey, _uploadMode.index);
@@ -236,7 +236,7 @@ class SettingsState extends ChangeNotifier {
Future<void> setUploadMode(UploadMode mode) async {
// In production builds, only allow production mode
if (!kEnableDevelopmentModes && mode != UploadMode.production) {
if (!dev.kEnableDevelopmentModes && mode != UploadMode.production) {
debugPrint('SettingsState: Development modes disabled, forcing production mode');
mode = UploadMode.production;
}
@@ -323,8 +323,8 @@ class SettingsState extends ChangeNotifier {
/// Set proximity alert distance in meters
Future<void> setProximityAlertDistance(int distance) async {
if (distance < kProximityAlertMinDistance) distance = kProximityAlertMinDistance;
if (distance > kProximityAlertMaxDistance) distance = kProximityAlertMaxDistance;
if (distance < dev.kProximityAlertMinDistance) distance = dev.kProximityAlertMinDistance;
if (distance > dev.kProximityAlertMaxDistance) distance = dev.kProximityAlertMaxDistance;
if (_proximityAlertDistance != distance) {
_proximityAlertDistance = distance;
final prefs = await SharedPreferences.getInstance();

View File

@@ -81,7 +81,7 @@ class AddNodeSheet extends StatelessWidget {
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
),
// Add button
IconButton(
@@ -95,7 +95,7 @@ class AddNodeSheet extends StatelessWidget {
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
@@ -109,7 +109,7 @@ class AddNodeSheet extends StatelessWidget {
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
),
],
),

View File

@@ -19,28 +19,28 @@ class CameraIcon extends StatelessWidget {
Color get _ringColor {
switch (type) {
case CameraIconType.real:
return kNodeRingColorReal;
return dev.kNodeRingColorReal;
case CameraIconType.mock:
return kNodeRingColorMock;
return dev.kNodeRingColorMock;
case CameraIconType.pending:
return kNodeRingColorPending;
return dev.kNodeRingColorPending;
case CameraIconType.editing:
return kNodeRingColorEditing;
return dev.kNodeRingColorEditing;
case CameraIconType.pendingEdit:
return kNodeRingColorPendingEdit;
return dev.kNodeRingColorPendingEdit;
case CameraIconType.pendingDeletion:
return kNodeRingColorPendingDeletion;
return dev.kNodeRingColorPendingDeletion;
}
}
@override
Widget build(BuildContext context) {
return Container(
width: kNodeIconDiameter,
height: kNodeIconDiameter,
width: dev.kNodeIconDiameter,
height: dev.kNodeIconDiameter,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _ringColor.withOpacity(kNodeDotOpacity),
color: _ringColor.withOpacity(dev.kNodeDotOpacity),
border: Border.all(
color: _ringColor,
width: getNodeRingThickness(context),

View File

@@ -76,14 +76,14 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
/// Calculate the maximum zoom level that keeps tile count under the absolute limit
int _calculateMaxZoomForTileLimit(LatLngBounds bounds, int minZoom) {
for (int zoom = minZoom; zoom <= kAbsoluteMaxZoom; zoom++) {
for (int zoom = minZoom; zoom <= dev.kAbsoluteMaxZoom; zoom++) {
final tileCount = computeTileList(bounds, minZoom, zoom).length;
if (tileCount > kAbsoluteMaxTileCount) {
if (tileCount > dev.kAbsoluteMaxTileCount) {
// Return the previous zoom level that was still under the absolute limit
return math.max(minZoom, zoom - 1);
}
}
return kAbsoluteMaxZoom;
return dev.kAbsoluteMaxZoom;
}
/// Get tile size estimate in KB, using preview tile data if available, otherwise fallback to constant
@@ -98,7 +98,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
return previewSizeKb;
} else {
// Fall back to configured estimate
return kFallbackTileEstimateKb;
return dev.kFallbackTileEstimateKb;
}
}
@@ -176,7 +176,7 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _tileCount! > kMaxReasonableTileCount
color: _tileCount! > dev.kMaxReasonableTileCount
? Colors.orange.withOpacity(0.1)
: Colors.green.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
@@ -185,12 +185,12 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_tileCount! > kMaxReasonableTileCount
_tileCount! > dev.kMaxReasonableTileCount
? 'Above recommended limit (Z${_maxPossibleZoom})'
: locService.t('download.maxRecommendedZoom', params: [_maxPossibleZoom.toString()]),
style: TextStyle(
fontSize: 12,
color: _tileCount! > kMaxReasonableTileCount
color: _tileCount! > dev.kMaxReasonableTileCount
? Colors.orange[700]
: Colors.green[700],
fontWeight: FontWeight.w500,
@@ -198,12 +198,12 @@ class _DownloadAreaDialogState extends State<DownloadAreaDialog> {
),
const SizedBox(height: 2),
Text(
_tileCount! > kMaxReasonableTileCount
? 'Current selection exceeds ${kMaxReasonableTileCount} recommended tile limit but is within ${kAbsoluteMaxTileCount} absolute limit'
_tileCount! > dev.kMaxReasonableTileCount
? 'Current selection exceeds ${dev.kMaxReasonableTileCount} recommended tile limit but is within ${dev.kAbsoluteMaxTileCount} absolute limit'
: locService.t('download.withinTileLimit', params: [kMaxReasonableTileCount.toString()]),
style: TextStyle(
fontSize: 11,
color: _tileCount! > kMaxReasonableTileCount
color: _tileCount! > dev.kMaxReasonableTileCount
? Colors.orange[600]
: Colors.green[600],
),

View File

@@ -83,7 +83,7 @@ class EditNodeSheet extends StatelessWidget {
: null,
tooltip: requiresDirection ? 'Remove current direction' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
),
// Add button
IconButton(
@@ -97,7 +97,7 @@ class EditNodeSheet extends StatelessWidget {
? (session.directions.length >= 8 ? 'Maximum 8 directions allowed' : 'Add new direction')
: 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
),
// Cycle button
IconButton(
@@ -111,7 +111,7 @@ class EditNodeSheet extends StatelessWidget {
: null,
tooltip: requiresDirection ? 'Cycle through directions' : 'Direction not required for this profile',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: kDirectionButtonMinWidth, minHeight: kDirectionButtonMinHeight),
constraints: BoxConstraints(minWidth: dev.kDirectionButtonMinWidth, minHeight: dev.kDirectionButtonMinHeight),
),
],
),
@@ -160,7 +160,7 @@ class EditNodeSheet extends StatelessWidget {
final submittableProfiles = appState.enabledProfiles.where((p) => p.isSubmittable).toList();
final isSandboxMode = appState.uploadMode == UploadMode.sandbox;
final allowSubmit = kEnableNodeEdits &&
final allowSubmit = dev.kEnableNodeEdits &&
appState.isLoggedIn &&
submittableProfiles.isNotEmpty &&
session.profile != null &&
@@ -219,20 +219,22 @@ class EditNodeSheet extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column(
children: [
// Extract from way checkbox
CheckboxListTile(
title: Text(locService.t('editNode.extractFromWay')),
subtitle: Text(locService.t('editNode.extractFromWaySubtitle')),
value: session.extractFromWay,
onChanged: (value) {
appState.updateEditSession(extractFromWay: value);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
// Constraint info message (only show if extract is not checked)
if (!session.extractFromWay) ...[
// Extract from way checkbox (only show if enabled in dev config)
if (dev.kEnableNodeExtraction) ...[
CheckboxListTile(
title: Text(locService.t('editNode.extractFromWay')),
subtitle: Text(locService.t('editNode.extractFromWaySubtitle')),
value: session.extractFromWay,
onChanged: (value) {
appState.updateEditSession(extractFromWay: value);
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
],
// Constraint info message (only show if extract is not checked or not enabled)
if (!dev.kEnableNodeExtraction || !session.extractFromWay) ...[
Row(
children: [
const Icon(Icons.info_outline, size: 20),
@@ -264,7 +266,7 @@ class EditNodeSheet extends StatelessWidget {
),
),
if (!kEnableNodeEdits)
if (!dev.kEnableNodeEdits)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(

View File

@@ -28,7 +28,7 @@ class CameraMapMarker extends StatefulWidget {
class _CameraMapMarkerState extends State<CameraMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
static final Duration tapTimeout = dev.dev.kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
@@ -49,7 +49,7 @@ class _CameraMapMarkerState extends State<CameraMapMarker> {
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
widget.mapController.move(widget.node.coord, widget.mapController.camera.zoom + dev.kNodeDoubleTapZoomDelta);
}
@override
@@ -108,8 +108,8 @@ class CameraMarkersBuilder {
return Marker(
point: n.coord,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
width: dev.kNodeIconDiameter,
height: dev.kNodeIconDiameter,
child: Opacity(
opacity: shouldDimNode ? 0.5 : 1.0,
child: CameraMapMarker(

View File

@@ -72,12 +72,12 @@ class CameraRefreshController {
}
final zoom = controller.mapController.camera.zoom;
if (zoom < kNodeMinZoomLevel) {
if (zoom < dev.kNodeMinZoomLevel) {
// Show a snackbar-style bubble warning
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Nodes not drawn below zoom level $kNodeMinZoomLevel'),
content: Text('Nodes not drawn below zoom level $dev.kNodeMinZoomLevel'),
duration: const Duration(seconds: 2),
),
);

View File

@@ -112,11 +112,11 @@ class DirectionConesBuilder {
bool isSession = false,
bool isActiveDirection = true,
}) {
final halfAngle = kDirectionConeHalfAngle;
final halfAngle = dev.kDirectionConeHalfAngle;
// Calculate pixel-based radii
final outerRadiusPx = kNodeIconDiameter + (kNodeIconDiameter * kDirectionConeBaseLength);
final innerRadiusPx = kNodeIconDiameter + (2 * getNodeRingThickness(context));
final outerRadiusPx = dev.kNodeIconDiameter + (dev.kNodeIconDiameter * dev.kDirectionConeBaseLength);
final innerRadiusPx = dev.kNodeIconDiameter + (2 * getNodeRingThickness(context));
// Convert pixels to coordinate distances with zoom scaling
final pixelToCoordinate = 0.00001 * math.pow(2, 15 - zoom);
@@ -150,15 +150,15 @@ class DirectionConesBuilder {
}
// Adjust opacity based on direction state
double opacity = kDirectionConeOpacity;
double opacity = dev.kDirectionConeOpacity;
if (isSession && !isActiveDirection) {
opacity = kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
opacity = dev.kDirectionConeOpacity * 0.4; // Reduced opacity for inactive session directions
}
return Polygon(
points: points,
color: kDirectionConeColor.withOpacity(opacity),
borderColor: kDirectionConeColor,
color: dev.kDirectionConeColor.withOpacity(opacity),
borderColor: dev.kDirectionConeColor,
borderStrokeWidth: getDirectionConeBorderWidth(context),
);
}

View File

@@ -60,7 +60,7 @@ class GpsController {
controller.animateTo(
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
duration: kFollowMeAnimationDuration,
duration: dev.kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
@@ -70,7 +70,7 @@ class GpsController {
dest: _currentLatLng!,
zoom: controller.mapController.camera.zoom,
rotation: 0.0,
duration: kFollowMeAnimationDuration,
duration: dev.kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
onMapMovedProgrammatically?.call();
@@ -123,7 +123,7 @@ class GpsController {
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: controller.mapController.camera.rotation,
duration: kFollowMeAnimationDuration,
duration: dev.kFollowMeAnimationDuration,
curve: Curves.easeOut,
);
@@ -135,14 +135,14 @@ class GpsController {
final speed = position.speed; // Speed in m/s
// Only apply rotation if moving fast enough to avoid wild spinning when stationary
final shouldRotate = !speed.isNaN && speed >= kMinSpeedForRotationMps && !heading.isNaN;
final shouldRotate = !speed.isNaN && speed >= dev.kMinSpeedForRotationMps && !heading.isNaN;
final rotation = shouldRotate ? -heading : controller.mapController.camera.rotation;
controller.animateTo(
dest: latLng,
zoom: controller.mapController.camera.zoom,
rotation: rotation,
duration: kFollowMeAnimationDuration,
duration: dev.kFollowMeAnimationDuration,
curve: Curves.easeOut,
);

View File

@@ -94,7 +94,7 @@ class MapOverlays extends StatelessWidget {
// Zoom indicator, positioned relative to button bar with left safe area
Positioned(
left: leftPositionWithSafeArea(10, safeArea),
bottom: bottomPositionFromButtonBar(kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
bottom: bottomPositionFromButtonBar(dev.kZoomIndicatorSpacingAboveButtonBar, safeArea.bottom),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
@@ -125,7 +125,7 @@ class MapOverlays extends StatelessWidget {
// Attribution overlay, positioned relative to button bar with left safe area
if (attribution != null)
Positioned(
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
bottom: bottomPositionFromButtonBar(dev.kAttributionSpacingAboveButtonBar, safeArea.bottom),
left: leftPositionWithSafeArea(10, safeArea),
child: GestureDetector(
onTap: () => _showAttributionDialog(context, attribution!),
@@ -151,7 +151,7 @@ class MapOverlays extends StatelessWidget {
// Zoom and layer controls (bottom-right), positioned relative to button bar with right safe area
Positioned(
bottom: bottomPositionFromButtonBar(kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
bottom: bottomPositionFromButtonBar(dev.kZoomControlsSpacingAboveButtonBar, safeArea.bottom),
right: rightPositionWithSafeArea(16, safeArea),
child: Consumer<AppState>(
builder: (context, appState, child) {

View File

@@ -28,7 +28,7 @@ class SuspectedLocationMapMarker extends StatefulWidget {
class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker> {
Timer? _tapTimer;
// From dev_config.dart for build-time parameters
static const Duration tapTimeout = kMarkerTapTimeout;
static final Duration tapTimeout = dev.dev.kMarkerTapTimeout;
void _onTap() {
_tapTimer = Timer(tapTimeout, () {
@@ -47,7 +47,7 @@ class _SuspectedLocationMapMarkerState extends State<SuspectedLocationMapMarker>
void _onDoubleTap() {
_tapTimer?.cancel();
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + kNodeDoubleTapZoomDelta);
widget.mapController.move(widget.location.centroid, widget.mapController.camera.zoom + dev.kNodeDoubleTapZoomDelta);
}
@override

View File

@@ -60,7 +60,7 @@ class MapView extends StatefulWidget {
class MapViewState extends State<MapView> {
late final AnimatedMapController _controller;
final Debouncer _cameraDebounce = Debouncer(kDebounceCameraRefresh);
final Debouncer _cameraDebounce = Debouncer(dev.kDebounceCameraRefresh);
final Debouncer _tileDebounce = Debouncer(const Duration(milliseconds: 150));
final Debouncer _mapPositionDebounce = Debouncer(const Duration(milliseconds: 1000));
final Debouncer _constrainedNodeSnapBack = Debouncer(const Duration(milliseconds: 100));
@@ -240,9 +240,9 @@ class MapViewState extends State<MapView> {
// OSM API (sandbox mode) needs higher zoom level due to bbox size limits
if (uploadMode == UploadMode.sandbox) {
return kOsmApiMinZoomLevel;
return dev.kOsmApiMinZoomLevel;
} else {
return kNodeMinZoomLevel;
return dev.kNodeMinZoomLevel;
}
}
@@ -268,17 +268,17 @@ class MapViewState extends State<MapView> {
// Check if we're editing a constrained node that's not being extracted
if (editSession?.originalNode.isConstrained == true && editSession?.extractFromWay != true) {
// Constrained node (not extracting): only allow pinch zoom and rotation, disable ALL panning
return const InteractionOptions(
return InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.pinchZoom | InteractiveFlag.rotate,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
scrollWheelVelocity: dev.kScrollWheelVelocity,
pinchZoomThreshold: dev.kPinchZoomThreshold,
pinchMoveThreshold: dev.kPinchMoveThreshold,
);
}
// Normal case: all interactions allowed with gesture race to prevent accidental rotation during zoom
return const InteractionOptions(
return InteractionOptions(
enableMultiFingerGestureRace: true,
flags: InteractiveFlag.doubleTapDragZoom |
InteractiveFlag.doubleTapZoom |
@@ -287,9 +287,9 @@ class MapViewState extends State<MapView> {
InteractiveFlag.pinchZoom |
InteractiveFlag.rotate |
InteractiveFlag.scrollWheelZoom,
scrollWheelVelocity: kScrollWheelVelocity,
pinchZoomThreshold: kPinchZoomThreshold,
pinchMoveThreshold: kPinchMoveThreshold,
scrollWheelVelocity: dev.kScrollWheelVelocity,
pinchZoomThreshold: dev.kPinchZoomThreshold,
pinchMoveThreshold: dev.kPinchMoveThreshold,
);
}
@@ -506,8 +506,8 @@ class MapViewState extends State<MapView> {
centerMarkers.add(
Marker(
point: center,
width: kNodeIconDiameter,
height: kNodeIconDiameter,
width: dev.kNodeIconDiameter,
height: dev.kNodeIconDiameter,
child: CameraIcon(
type: editSession != null ? CameraIconType.editing : CameraIconType.mock,
),
@@ -691,7 +691,7 @@ class MapViewState extends State<MapView> {
alignment: Alignment.bottomLeft,
padding: EdgeInsets.only(
left: leftPositionWithSafeArea(8, safeArea),
bottom: bottomPositionFromButtonBar(kScaleBarSpacingAboveButtonBar, safeArea.bottom)
bottom: bottomPositionFromButtonBar(dev.kScaleBarSpacingAboveButtonBar, safeArea.bottom)
),
textStyle: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
lineColor: Colors.black,
@@ -753,7 +753,7 @@ class MapViewState extends State<MapView> {
if (originalCoord != null) {
lines.add(Polyline(
points: [originalCoord, node.coord],
color: kNodeRingColorPending,
color: dev.kNodeRingColorPending,
strokeWidth: 3.0,
));
}

View File

@@ -209,12 +209,12 @@ class NodeTagSheet extends StatelessWidget {
),
const SizedBox(width: 8),
ElevatedButton.icon(
onPressed: _deleteNode,
onPressed: node.isConstrained ? null : _deleteNode,
icon: const Icon(Icons.delete, size: 18),
label: Text(locService.t('actions.delete')),
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
foregroundColor: Colors.red,
foregroundColor: node.isConstrained ? null : Colors.red,
),
),
const SizedBox(width: 12),

View File

@@ -0,0 +1,150 @@
#!/usr/bin/env python3
import os
import re
# All constants to replace
CONSTANTS = [
"kFallbackTileEstimateKb", "kPreviewTileZoom", "kPreviewTileY", "kPreviewTileX",
"kDirectionConeHalfAngle", "kDirectionConeBaseLength", "kDirectionConeColor", "kDirectionConeOpacity",
"kBottomButtonBarOffset", "kButtonBarHeight", "kAttributionSpacingAboveButtonBar",
"kZoomIndicatorSpacingAboveButtonBar", "kScaleBarSpacingAboveButtonBar", "kZoomControlsSpacingAboveButtonBar",
"kClientName", "kSuspectedLocationsCsvUrl", "kEnableDevelopmentModes", "kEnableNodeEdits", "kEnableNodeExtraction",
"kNodeMinZoomLevel", "kOsmApiMinZoomLevel", "kMarkerTapTimeout", "kDebounceCameraRefresh",
"kPreFetchAreaExpansionMultiplier", "kPreFetchZoomLevel", "kMaxPreFetchSplitDepth", "kDataRefreshIntervalSeconds",
"kFollowMeAnimationDuration", "kMinSpeedForRotationMps", "kProximityAlertDefaultDistance",
"kProximityAlertMinDistance", "kProximityAlertMaxDistance", "kProximityAlertCooldown",
"kNodeDoubleTapZoomDelta", "kScrollWheelVelocity", "kPinchZoomThreshold", "kPinchMoveThreshold", "kRotationThreshold",
"kTileFetchMaxAttempts", "kTileFetchInitialDelayMs", "kTileFetchBackoffMultiplier", "kTileFetchMaxDelayMs",
"kTileFetchRandomJitterMs", "kMaxUserDownloadZoomSpan", "kMaxReasonableTileCount", "kAbsoluteMaxTileCount",
"kAbsoluteMaxZoom", "kNodeIconDiameter", "kNodeDotOpacity", "kNodeRingColorReal", "kNodeRingColorMock",
"kNodeRingColorPending", "kNodeRingColorEditing", "kNodeRingColorPendingEdit", "kNodeRingColorPendingDeletion",
"kDirectionButtonMinWidth", "kDirectionButtonMinHeight"
]
def find_dart_files():
"""Find all .dart files except dev_config.dart"""
dart_files = []
for root, dirs, files in os.walk('.'):
for file in files:
if file.endswith('.dart'):
path = os.path.join(root, file)
if 'dev_config.dart' not in path:
dart_files.append(path)
return dart_files
def process_file(filepath):
"""Process a single dart file"""
print(f" 📝 Processing {filepath}")
try:
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
print(f" ❌ Error reading file: {e}")
return False
original_content = content
changes_made = []
# Process each constant
for constant in CONSTANTS:
content, changed = process_constant_in_content(content, constant)
if changed:
changes_made.append(constant)
# Only write if something actually changed
if content != original_content:
try:
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
print(f" ✅ Updated: {', '.join(changes_made)}")
return True
except Exception as e:
print(f" ❌ Error writing file: {e}")
return False
else:
print(f" ⏭️ No changes needed")
return False
def process_constant_in_content(content, constant):
"""Process a single constant in file content, handling const issues"""
original_content = content
# Skip if already using dev.constant (idempotent)
if f"dev.{constant}" in content:
return content, False
# Skip if constant not found at all
if constant not in content:
return content, False
print(f" 🔄 Replacing {constant}")
# Pattern 1: const Type variable = kConstant;
# Change to: final Type variable = dev.kConstant;
pattern1 = rf'\bconst\s+(\w+)\s+(\w+)\s*=\s*{re.escape(constant)}\s*;'
replacement1 = rf'final \1 \2 = dev.{constant};'
content = re.sub(pattern1, replacement1, content)
# Pattern 2: static const Type variable = kConstant;
# Change to: static final Type variable = dev.kConstant;
pattern2 = rf'\bstatic\s+const\s+(\w+)\s+(\w+)\s*=\s*{re.escape(constant)}\s*;'
replacement2 = rf'static final \1 \2 = dev.{constant};'
content = re.sub(pattern2, replacement2, content)
# Pattern 3: const ConstructorName(...kConstant...)
# We need to be careful here - find const constructors that contain our constant
# and remove the const keyword
# This is tricky to do perfectly with regex, so let's do a simple approach:
# If we find "const SomeConstructor(" followed by our constant somewhere before the matching ")"
# we'll remove the const keyword from the constructor
# Find all const constructor calls that contain our constant
const_constructor_pattern = r'\bconst\s+(\w+)\s*\([^)]*' + re.escape(constant) + r'[^)]*\)'
matches = list(re.finditer(const_constructor_pattern, content))
# Replace const with just the constructor name for each match
for match in reversed(matches): # Reverse to maintain positions
full_match = match.group(0)
constructor_name = match.group(1)
# Remove 'const ' from the beginning
replacement = full_match.replace(f'const {constructor_name}', constructor_name, 1)
content = content[:match.start()] + replacement + content[match.end():]
# Pattern 4: Simple replacements - any remaining instances of kConstant
# Use word boundaries to avoid partial matches, but avoid already replaced dev.kConstant
pattern4 = rf'\b{re.escape(constant)}\b(?![\w.])' # Negative lookahead to avoid partial matches
replacement4 = f'dev.{constant}'
content = re.sub(pattern4, replacement4, content)
return content, content != original_content
def main():
print("🚀 Starting dev_config reference update...")
print("🔍 Finding Dart files...")
dart_files = find_dart_files()
print(f"📁 Found {len(dart_files)} Dart files to process")
if not dart_files:
print("❌ No Dart files found!")
return
updated_files = 0
for filepath in dart_files:
if process_file(filepath):
updated_files += 1
print(f"\n✨ Finished! Updated {updated_files} out of {len(dart_files)} files")
print("💡 Next steps:")
print(" 1. flutter analyze (check for syntax errors)")
print(" 2. flutter pub get (refresh dependencies)")
print(" 3. flutter run (test the app)")
if updated_files > 0:
print("⚠️ If you see compilation errors, the script can be run again safely")
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,12 @@
#!/bin/bash
# Super simple test - just replace one constant first
echo "🔄 Testing with kClientName..."
find . -name "*.dart" -not -path "./lib/dev_config.dart" -exec grep -l "kClientName" {} \;
echo "Found files with kClientName. Now replacing..."
find . -name "*.dart" -not -path "./lib/dev_config.dart" -exec sed -i '' 's/kClientName/dev.kClientName/g' {} \;
echo "✅ Done with test. Check if lib/services/uploader.dart changed"