laggy on android, UX needs polish

This commit is contained in:
stopflock
2025-11-26 15:03:58 -06:00
parent c6d73d42ee
commit 153377e9e6
19 changed files with 200 additions and 241 deletions

View File

@@ -4,6 +4,8 @@
"• IMPROVED: Simplified tile loading architecture - replaced HTTP interception with clean TileProvider implementation",
"• IMPROVED: Network status indicator now focuses only on surveillance data loading, not tile loading (tiles show their own progress)",
"• IMPROVED: Reduced complexity in cache management and state tracking",
"• FIXED: Max nodes setting now correctly limits rendering only (not data fetching) to prevent UI lag",
"• FIXED: New node limit indicator shows when not all devices are displayed due to rendering limit",
"• FIXED: Tile cache properly clears when switching between tile providers/types - no more mixed tiles",
"• FIXED: Network status indicator no longer shows false timeouts during surveillance data splitting operations",
"• FIXED: Eliminated potential conflicts between multiple cache layers"

View File

@@ -16,7 +16,6 @@ import 'services/tile_preview_service.dart';
import 'services/changelog_service.dart';
import 'services/operator_profile_service.dart';
import 'services/profile_service.dart';
import 'widgets/camera_provider_with_cache.dart';
import 'widgets/proximity_warning_dialog.dart';
import 'dev_config.dart';
import 'state/auth_state.dart';
@@ -437,7 +436,6 @@ class AppState extends ChangeNotifier {
Future<void> setUploadMode(UploadMode mode) async {
// Clear node cache when switching upload modes to prevent mixing production/sandbox data
NodeCache.instance.clear();
CameraProviderWithCache.instance.notifyListeners();
debugPrint('[AppState] Cleared node cache due to upload mode change');
await _settingsState.setUploadMode(mode);

View File

@@ -382,9 +382,11 @@
"timedOut": "Anfrage Zeitüberschreitung",
"noData": "Keine Offline-Daten",
"success": "Überwachungsdaten geladen",
"nodeLimitReached": "Limit erreicht - in Einstellungen erhöhen",
"nodeDataSlow": "Überwachungsdaten langsam"
},
"nodeLimitIndicator": {
"message": "Zeige {rendered} von {total} Geräten"
},
"about": {
"title": "DeFlock - Überwachungs-Transparenz",
"description": "DeFlock ist eine datenschutzorientierte mobile App zur Kartierung öffentlicher Überwachungsinfrastruktür mit OpenStreetMap. Dokumentieren Sie Kameras, ALPRs, Schussdetektoren und andere Überwachungsgeräte in Ihrer Gemeinde, um diese Infrastruktur sichtbar und durchsuchbar zu machen.",

View File

@@ -414,9 +414,11 @@
"timedOut": "Request timed out",
"noData": "No offline data",
"success": "Surveillance data loaded",
"nodeLimitReached": "Showing limit - increase in settings",
"nodeDataSlow": "Surveillance data slow"
},
"nodeLimitIndicator": {
"message": "Showing {rendered} of {total} devices"
},
"navigation": {
"searchLocation": "Search Location",
"searchPlaceholder": "Search places or coordinates...",

View File

@@ -414,9 +414,11 @@
"timedOut": "Solicitud agotada",
"noData": "Sin datos sin conexión",
"success": "Datos de vigilancia cargados",
"nodeLimitReached": "Mostrando límite - aumentar en ajustes",
"nodeDataSlow": "Datos de vigilancia lentos"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos"
},
"navigation": {
"searchLocation": "Buscar ubicación",
"searchPlaceholder": "Buscar lugares o coordenadas...",

View File

@@ -414,9 +414,11 @@
"timedOut": "Demande expirée",
"noData": "Aucune donnée hors ligne",
"success": "Données de surveillance chargées",
"nodeLimitReached": "Limite affichée - augmenter dans les paramètres",
"nodeDataSlow": "Données de surveillance lentes"
},
"nodeLimitIndicator": {
"message": "Affichage de {rendered} sur {total} appareils"
},
"navigation": {
"searchLocation": "Rechercher lieu",
"searchPlaceholder": "Rechercher lieux ou coordonnées...",

View File

@@ -414,9 +414,11 @@
"timedOut": "Richiesta scaduta",
"noData": "Nessun dato offline",
"success": "Dati di sorveglianza caricati",
"nodeLimitReached": "Limite visualizzato - aumentare nelle impostazioni",
"nodeDataSlow": "Dati di sorveglianza lenti"
},
"nodeLimitIndicator": {
"message": "Mostra {rendered} di {total} dispositivi"
},
"navigation": {
"searchLocation": "Cerca posizione",
"searchPlaceholder": "Cerca luoghi o coordinate...",

View File

@@ -414,9 +414,11 @@
"timedOut": "Solicitação expirada",
"noData": "Nenhum dado offline",
"success": "Dados de vigilância carregados",
"nodeLimitReached": "Limite exibido - aumentar nas configurações",
"nodeDataSlow": "Dados de vigilância lentos"
},
"nodeLimitIndicator": {
"message": "Mostrando {rendered} de {total} dispositivos"
},
"navigation": {
"searchLocation": "Buscar localização",
"searchPlaceholder": "Buscar locais ou coordenadas...",

View File

@@ -414,9 +414,11 @@
"timedOut": "请求超时",
"noData": "无离线数据",
"success": "监控数据已加载",
"nodeLimitReached": "显示限制 - 在设置中增加",
"nodeDataSlow": "监控数据缓慢"
},
"nodeLimitIndicator": {
"message": "显示 {rendered} / {total} 设备"
},
"navigation": {
"searchLocation": "搜索位置",
"searchPlaceholder": "搜索地点或坐标...",

View File

@@ -13,7 +13,6 @@ import '../services/localization_service.dart';
import '../widgets/add_node_sheet.dart';
import '../widgets/edit_node_sheet.dart';
import '../widgets/node_tag_sheet.dart';
import '../widgets/camera_provider_with_cache.dart';
import '../widgets/download_area_dialog.dart';
import '../widgets/measured_sheet.dart';
import '../widgets/navigation_sheet.dart';
@@ -49,6 +48,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
// Flag to prevent map bounce when transitioning from tag sheet to edit sheet
bool _transitioningToEdit = false;
// Track node limit state for button disabling
bool _isNodeLimitActive = false;
// Track selected node for highlighting
int? _selectedNodeId;
@@ -546,6 +548,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
},
child: NodeTagSheet(
node: node,
isNodeLimitActive: _isNodeLimitActive,
onEditPressed: () {
// Check minimum zoom level before starting edit session
final currentZoom = _mapController.mapController.camera.zoom;
@@ -666,13 +669,9 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
? _navigationSheetHeight
: _tagSheetHeight));
return MultiProvider(
providers: [
ChangeNotifierProvider<CameraProviderWithCache>(create: (_) => CameraProviderWithCache()),
],
child: MediaQuery(
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
child: Scaffold(
return MediaQuery(
data: MediaQuery.of(context).copyWith(viewInsets: EdgeInsets.zero),
child: Scaffold(
key: _scaffoldKey,
appBar: AppBar(
automaticallyImplyLeading: false, // Disable automatic back button
@@ -717,6 +716,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
onNodeTap: openNodeTagSheet,
onSuspectedLocationTap: openSuspectedLocationSheet,
onSearchPressed: _onNavigationButtonPressed,
onNodeLimitChanged: (isLimited) {
setState(() {
_isNodeLimitActive = isLimited;
});
},
onUserGesture: () {
if (appState.followMeMode != FollowMeMode.off) {
appState.setFollowMeMode(FollowMeMode.off);
@@ -771,7 +775,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
builder: (context, child) => ElevatedButton.icon(
icon: Icon(Icons.add_location_alt),
label: Text(LocalizationService.instance.tagNode),
onPressed: _openAddNodeSheet,
onPressed: _isNodeLimitActive ? null : _openAddNodeSheet,
style: ElevatedButton.styleFrom(
minimumSize: Size(0, 48),
textStyle: TextStyle(fontSize: 16),
@@ -828,7 +832,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
],
),
),
),
);
}
}

View File

@@ -97,19 +97,14 @@ class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
return await decode(buffer);
} catch (e) {
// Log error for debugging but don't spam network status
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e');
// Don't log routine offline misses to avoid console spam
if (!e.toString().contains('offline mode is enabled')) {
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $e');
}
// Return a transparent 1x1 pixel tile for missing tiles
// This is more graceful than throwing and prevents cascade failures
final transparentPixel = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0B, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]);
final buffer = await ImmutableBuffer.fromUint8List(transparentPixel);
return await decode(buffer);
// Re-throw the exception and let FlutterMap handle missing tiles gracefully
// This is better than trying to provide fallback images
throw e;
}
}

View File

@@ -53,7 +53,7 @@ class MapDataProvider {
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
maxResults: 0, // No limit - fetch all available data
);
}
@@ -76,7 +76,7 @@ class MapDataProvider {
return fetchLocalNodes(
bounds: bounds,
profiles: profiles,
maxNodes: AppState.instance.maxCameras,
maxNodes: 0, // No limit - get all available data
);
}
} else if (uploadMode == UploadMode.sandbox) {
@@ -86,7 +86,7 @@ class MapDataProvider {
bounds: bounds,
profiles: profiles,
uploadMode: uploadMode,
maxResults: AppState.instance.maxCameras,
maxResults: 0, // No limit - fetch all available data
);
} else {
// Production mode: use pre-fetch service for efficient area loading
@@ -116,13 +116,9 @@ class MapDataProvider {
debugPrint('[MapDataProvider] Using existing fresh pre-fetched area cache');
}
// Apply rendering limit and warn if nodes are being excluded
final maxNodes = AppState.instance.maxCameras;
if (localNodes.length > maxNodes) {
NetworkStatus.instance.reportNodeLimitReached(localNodes.length, maxNodes);
}
return localNodes.take(maxNodes).toList();
// Return all local nodes without any rendering limit
// Rendering limits are applied at the UI layer
return localNodes;
}
}

View File

@@ -4,7 +4,7 @@ import 'dart:async';
import '../app_state.dart';
enum NetworkIssueType { overpassApi }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success, nodeLimitReached }
enum NetworkStatusType { waiting, issues, timedOut, noData, ready, success }
@@ -22,9 +22,6 @@ class NetworkStatus extends ChangeNotifier {
Timer? _waitingTimer;
Timer? _noDataResetTimer;
Timer? _successResetTimer;
bool _nodeLimitReached = false;
Timer? _nodeLimitResetTimer;
// Getters
bool get hasAnyIssues => _overpassHasIssues;
bool get overpassHasIssues => _overpassHasIssues;
@@ -32,7 +29,6 @@ class NetworkStatus extends ChangeNotifier {
bool get isTimedOut => _isTimedOut;
bool get hasNoData => _hasNoData;
bool get hasSuccess => _hasSuccess;
bool get nodeLimitReached => _nodeLimitReached;
NetworkStatusType get currentStatus {
// Simple single-path status logic
@@ -41,7 +37,6 @@ class NetworkStatus extends ChangeNotifier {
if (_isTimedOut) return NetworkStatusType.timedOut;
if (_hasNoData) return NetworkStatusType.noData;
if (_hasSuccess) return NetworkStatusType.success;
if (_nodeLimitReached) return NetworkStatusType.nodeLimitReached;
return NetworkStatusType.ready;
}
@@ -144,17 +139,15 @@ class NetworkStatus extends ChangeNotifier {
/// Clear waiting/timeout/no-data status (legacy method for compatibility)
void clearWaiting() {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess || _nodeLimitReached) {
if (_isWaitingForData || _isTimedOut || _hasNoData || _hasSuccess) {
_isWaitingForData = false;
_isTimedOut = false;
_hasNoData = false;
_hasSuccess = false;
_nodeLimitReached = false;
_recentOfflineMisses = 0;
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
_nodeLimitResetTimer?.cancel();
notifyListeners();
}
}
@@ -195,22 +188,6 @@ class NetworkStatus extends ChangeNotifier {
debugPrint('[NetworkStatus] Network error occurred');
}
/// Show notification that node display limit was reached
void reportNodeLimitReached(int totalNodes, int maxNodes) {
_nodeLimitReached = true;
notifyListeners();
debugPrint('[NetworkStatus] Node display limit reached: $totalNodes found, showing $maxNodes');
// Auto-clear after 8 seconds
_nodeLimitResetTimer?.cancel();
_nodeLimitResetTimer = Timer(const Duration(seconds: 8), () {
if (_nodeLimitReached) {
_nodeLimitReached = false;
notifyListeners();
}
});
}
/// Report that a tile was not available offline
@@ -243,7 +220,6 @@ class NetworkStatus extends ChangeNotifier {
_waitingTimer?.cancel();
_noDataResetTimer?.cancel();
_successResetTimer?.cancel();
_nodeLimitResetTimer?.cancel();
super.dispose();
}
}

View File

@@ -1,130 +0,0 @@
import 'package:http/http.dart' as http;
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import '../app_state.dart';
import 'map_data_provider.dart';
import 'network_status.dart';
/// Simple HTTP client that routes tile requests through the centralized MapDataProvider.
/// This ensures all tile fetching (offline/online routing, retries, etc.) is in one place.
class SimpleTileHttpClient extends http.BaseClient {
final http.Client _inner = http.Client();
final MapDataProvider _mapDataProvider = MapDataProvider();
// Tile completion tracking (brutalist approach)
int _pendingTileRequests = 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
// Extract tile coordinates from our custom URL scheme
final tileCoords = _extractTileCoords(request.url);
if (tileCoords != null) {
final z = tileCoords['z']!;
final x = tileCoords['x']!;
final y = tileCoords['y']!;
return _handleTileRequest(z, x, y);
}
// Pass through non-tile requests
return _inner.send(request);
}
/// Extract z/x/y coordinates from our fake domain: https://tiles.local/provider/type/z/x/y
/// We ignore the provider/type in the URL since we use current AppState for actual fetching
Map<String, int>? _extractTileCoords(Uri url) {
if (url.host != 'tiles.local') return null;
final pathSegments = url.pathSegments;
if (pathSegments.length != 5) return null;
// pathSegments[0] = providerId (for cache separation only)
// pathSegments[1] = tileTypeId (for cache separation only)
final z = int.tryParse(pathSegments[2]);
final x = int.tryParse(pathSegments[3]);
final y = int.tryParse(pathSegments[4]);
if (z != null && x != null && y != null) {
return {'z': z, 'x': x, 'y': y};
}
return null;
}
Future<http.StreamedResponse> _handleTileRequest(int z, int x, int y) async {
// Increment pending counter (brutalist completion detection)
_pendingTileRequests++;
try {
// Always go through MapDataProvider - it handles offline/online routing
// MapDataProvider will get current provider from AppState
final tileBytes = await _mapDataProvider.getTile(z: z, x: x, y: y, source: MapSource.auto);
// Serve tile with proper cache headers
return http.StreamedResponse(
Stream.value(tileBytes),
200,
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800',
'Expires': _httpDateFormat(DateTime.now().add(Duration(days: 7))),
'Last-Modified': _httpDateFormat(DateTime.now().subtract(Duration(hours: 1))),
},
);
} catch (e) {
debugPrint('[SimpleTileService] Could not get tile $z/$x/$y: $e');
// Return 404 and let flutter_map handle it gracefully
return http.StreamedResponse(
Stream.value(<int>[]),
404,
reasonPhrase: 'Tile unavailable: $e',
);
} finally {
// Decrement pending counter and report completion when all done
_pendingTileRequests--;
if (_pendingTileRequests == 0) {
// Only report tile completion if we were in loading state (user-initiated)
if (NetworkStatus.instance.currentStatus == NetworkStatusType.waiting) {
NetworkStatus.instance.setSuccess();
}
}
}
}
/// Clear any queued tile requests when map view changes
void clearTileQueue() {
_mapDataProvider.clearTileQueue();
}
/// Clear only tile requests that are no longer visible in the current bounds
void clearStaleRequests(LatLngBounds currentBounds) {
_mapDataProvider.clearTileQueueSelective(currentBounds);
}
/// Format date for HTTP headers (RFC 7231)
String _httpDateFormat(DateTime date) {
final utc = date.toUtc();
final weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
final months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
final weekday = weekdays[utc.weekday - 1];
final day = utc.day.toString().padLeft(2, '0');
final month = months[utc.month - 1];
final year = utc.year;
final hour = utc.hour.toString().padLeft(2, '0');
final minute = utc.minute.toString().padLeft(2, '0');
final second = utc.second.toString().padLeft(2, '0');
return '$weekday, $day $month $year $hour:$minute:$second GMT';
}
@override
void close() {
_inner.close();
super.close();
}
}

View File

@@ -20,7 +20,7 @@ class CameraProviderWithCache extends ChangeNotifier {
Timer? _debounceTimer;
/// Call this to get (quickly) all cached overlays for the given view.
/// Filters by currently enabled profiles.
/// Filters by currently enabled profiles only. Limiting is handled by MapView.
List<OsmNode> getCachedNodesForBounds(LatLngBounds bounds) {
final allNodes = NodeCache.instance.queryByBounds(bounds);
final enabledProfiles = AppState.instance.enabledProfiles;

View File

@@ -25,6 +25,7 @@ import 'map/camera_refresh_controller.dart';
import 'map/gps_controller.dart';
import 'map/suspected_location_markers.dart';
import 'network_status_indicator.dart';
import 'node_limit_indicator.dart';
import 'provisional_pin.dart';
import 'proximity_alert_banner.dart';
import '../dev_config.dart';
@@ -44,6 +45,7 @@ class MapView extends StatefulWidget {
this.onNodeTap,
this.onSuspectedLocationTap,
this.onSearchPressed,
this.onNodeLimitChanged,
});
final FollowMeMode followMeMode;
@@ -53,6 +55,7 @@ class MapView extends StatefulWidget {
final void Function(OsmNode)? onNodeTap;
final void Function(SuspectedLocation)? onSuspectedLocationTap;
final VoidCallback? onSearchPressed;
final void Function(bool isLimited)? onNodeLimitChanged;
@override
State<MapView> createState() => MapViewState();
@@ -76,7 +79,8 @@ class MapViewState extends State<MapView> {
// Track map center to clear queue on significant panning
LatLng? _lastCenter;
// Track node limit state for parent notification
bool _lastNodeLimitState = false;
// State for proximity alert banner
bool _showProximityBanner = false;
@@ -156,7 +160,6 @@ class MapViewState extends State<MapView> {
getNearbyNodes: () {
if (mounted) {
try {
final cameraProvider = context.read<CameraProviderWithCache>();
LatLngBounds? mapBounds;
try {
mapBounds = _controller.mapController.camera.visibleBounds;
@@ -164,7 +167,7 @@ class MapViewState extends State<MapView> {
return [];
}
return mapBounds != null
? cameraProvider.getCachedNodesForBounds(mapBounds)
? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
: [];
} catch (e) {
debugPrint('[MapView] Could not get nearby nodes: $e');
@@ -395,38 +398,70 @@ class MapViewState extends State<MapView> {
// Edit sessions don't need to center - we're already centered from the node tap
// SheetAwareMap handles the visual positioning
// Fetch cached cameras for current map bounds (using Consumer so overlays redraw instantly)
Widget cameraLayers = Consumer<CameraProviderWithCache>(
builder: (context, cameraProvider, child) {
// Get current zoom level and map bounds (shared by all logic)
double currentZoom = 15.0; // fallback
LatLngBounds? mapBounds;
try {
currentZoom = _controller.mapController.camera.zoom;
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
// Controller not ready yet, use fallback values
mapBounds = null;
}
final minZoom = _getMinZoomForNodes(context);
List<OsmNode> nodes;
if (currentZoom >= minZoom) {
// Above minimum zoom - get cached nodes
nodes = (mapBounds != null)
? cameraProvider.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
} else {
// Below minimum zoom - don't render any nodes
nodes = <OsmNode>[];
}
// Get current zoom level and map bounds (shared by all logic)
double currentZoom = 15.0; // fallback
LatLngBounds? mapBounds;
try {
currentZoom = _controller.mapController.camera.zoom;
mapBounds = _controller.mapController.camera.visibleBounds;
} catch (_) {
// Controller not ready yet, use fallback values
mapBounds = null;
}
final minZoom = _getMinZoomForNodes(context);
List<OsmNode> allNodes;
List<OsmNode> nodesToRender;
bool isLimitActive = false;
if (currentZoom >= minZoom) {
// Above minimum zoom - get cached nodes directly (no Provider needed)
allNodes = (mapBounds != null)
? CameraProviderWithCache.instance.getCachedNodesForBounds(mapBounds)
: <OsmNode>[];
// Filter out invalid coordinates before applying limit
final validNodes = allNodes.where((node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}).toList();
// Apply rendering limit to prevent UI lag
final maxNodes = appState.maxCameras;
if (validNodes.length > maxNodes) {
nodesToRender = validNodes.take(maxNodes).toList();
isLimitActive = true;
debugPrint('[MapView] Node limit active: rendering ${nodesToRender.length} of ${validNodes.length} devices');
} else {
nodesToRender = validNodes;
isLimitActive = false;
}
} else {
// Below minimum zoom - don't render any nodes
allNodes = <OsmNode>[];
nodesToRender = <OsmNode>[];
isLimitActive = false;
}
// Notify parent if limit state changed (for button disabling)
if (isLimitActive != _lastNodeLimitState) {
_lastNodeLimitState = isLimitActive;
// Schedule callback after build completes to avoid setState during build
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onNodeLimitChanged?.call(isLimitActive);
});
}
// Build camera layers using the limited nodes
Widget cameraLayers = LayoutBuilder(
builder: (context, constraints) {
// Determine if we should dim node markers (when suspected location is selected)
final shouldDimNodes = appState.selectedSuspectedLocation != null;
final markers = CameraMarkersBuilder.buildCameraMarkers(
cameras: nodes,
cameras: nodesToRender,
mapController: _controller.mapController,
userLocation: _gpsController.currentLocation,
selectedNodeId: widget.selectedNodeId,
@@ -451,7 +486,7 @@ class MapViewState extends State<MapView> {
// Filter out suspected locations that are too close to real nodes
final filteredSuspectedLocations = _filterSuspectedLocationsByProximity(
suspectedLocations: limitedSuspectedLocations,
realNodes: nodes,
realNodes: nodesToRender,
minDistance: appState.suspectedLocationMinDistance,
);
@@ -473,7 +508,7 @@ class MapViewState extends State<MapView> {
}
final overlays = DirectionConesBuilder.buildDirectionCones(
cameras: nodes,
cameras: nodesToRender,
zoom: currentZoom,
session: session,
editSession: editSession,
@@ -496,7 +531,7 @@ class MapViewState extends State<MapView> {
}
// Build edit lines connecting original nodes to their edited positions
final editLines = _buildEditLines(nodes);
final editLines = _buildEditLines(nodesToRender);
// Build center marker for add/edit sessions
final centerMarkers = <Marker>[];
@@ -573,9 +608,20 @@ class MapViewState extends State<MapView> {
if (editLines.isNotEmpty) PolylineLayer(polylines: editLines),
if (routeLines.isNotEmpty) PolylineLayer(polylines: routeLines),
MarkerLayer(markers: [...suspectedLocationMarkers, ...markers, ...centerMarkers]),
// Node limit indicator (top-left) - shown when limit is active
NodeLimitIndicator(
isActive: isLimitActive,
renderedCount: nodesToRender.length,
totalCount: isLimitActive ? allNodes.where((node) {
return (node.coord.latitude != 0 || node.coord.longitude != 0) &&
node.coord.latitude.abs() <= 90 &&
node.coord.longitude.abs() <= 180;
}).length : 0,
),
],
);
}
},
);
return Stack(

View File

@@ -44,12 +44,6 @@ class NetworkStatusIndicator extends StatelessWidget {
color = Colors.green;
break;
case NetworkStatusType.nodeLimitReached:
message = locService.t('networkStatus.nodeLimitReached');
icon = Icons.visibility_off;
color = Colors.amber;
break;
case NetworkStatusType.issues:
switch (networkStatus.currentIssueType) {
case NetworkIssueType.overpassApi:
@@ -67,7 +61,7 @@ class NetworkStatusIndicator extends StatelessWidget {
}
return Positioned(
top: 8, // Position relative to the map area (not the screen)
top: 56, // Position below node limit indicator when present
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../services/localization_service.dart';
class NodeLimitIndicator extends StatelessWidget {
final bool isActive;
final int renderedCount;
final int totalCount;
const NodeLimitIndicator({
super.key,
required this.isActive,
required this.renderedCount,
required this.totalCount,
});
@override
Widget build(BuildContext context) {
if (!isActive) {
return const SizedBox.shrink();
}
final locService = LocalizationService.instance;
final message = locService.t('nodeLimitIndicator.message')
.replaceAll('{rendered}', renderedCount.toString())
.replaceAll('{total}', totalCount.toString());
return Positioned(
top: 8, // Position at top-left of map area
left: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.amber, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.visibility_off,
size: 16,
color: Colors.amber,
),
const SizedBox(width: 4),
Text(
message,
style: const TextStyle(
color: Colors.amber,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}

View File

@@ -11,8 +11,14 @@ import 'advanced_edit_options_sheet.dart';
class NodeTagSheet extends StatelessWidget {
final OsmNode node;
final VoidCallback? onEditPressed;
final bool isNodeLimitActive;
const NodeTagSheet({super.key, required this.node, this.onEditPressed});
const NodeTagSheet({
super.key,
required this.node,
this.onEditPressed,
this.isNodeLimitActive = false,
});
@override
Widget build(BuildContext context) {
@@ -200,7 +206,7 @@ class NodeTagSheet extends StatelessWidget {
children: [
if (isEditable) ...[
ElevatedButton.icon(
onPressed: _openEditSheet,
onPressed: isNodeLimitActive ? null : _openEditSheet,
icon: const Icon(Icons.edit, size: 18),
label: Text(locService.edit),
style: ElevatedButton.styleFrom(