mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
laggy on android, UX needs polish
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -414,9 +414,11 @@
|
||||
"timedOut": "请求超时",
|
||||
"noData": "无离线数据",
|
||||
"success": "监控数据已加载",
|
||||
"nodeLimitReached": "显示限制 - 在设置中增加",
|
||||
"nodeDataSlow": "监控数据缓慢"
|
||||
},
|
||||
"nodeLimitIndicator": {
|
||||
"message": "显示 {rendered} / {total} 设备"
|
||||
},
|
||||
"navigation": {
|
||||
"searchLocation": "搜索位置",
|
||||
"searchPlaceholder": "搜索地点或坐标...",
|
||||
|
||||
@@ -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 {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
59
lib/widgets/node_limit_indicator.dart
Normal file
59
lib/widgets/node_limit_indicator.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user