diff --git a/assets/black_1x1.png b/assets/black_1x1.png deleted file mode 100644 index 92f7ea3..0000000 Binary files a/assets/black_1x1.png and /dev/null differ diff --git a/assets/transparent_1x1.png b/assets/transparent_1x1.png deleted file mode 100644 index 1914264..0000000 Binary files a/assets/transparent_1x1.png and /dev/null differ diff --git a/lib/app_state.dart b/lib/app_state.dart index e9dde49..6243154 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -50,6 +50,19 @@ class AppState extends ChangeNotifier { final List _profiles = []; final Set _enabled = {}; static const String _enabledPrefsKey = 'enabled_profiles'; + static const String _maxCamerasPrefsKey = 'max_cameras'; + + // Maximum number of cameras fetched/drawn + int _maxCameras = 250; + int get maxCameras => _maxCameras; + set maxCameras(int n) { + if (n < 10) n = 10; // minimum + _maxCameras = n; + SharedPreferences.getInstance().then((prefs) { + prefs.setInt(_maxCamerasPrefsKey, n); + }); + notifyListeners(); + } // Upload mode: production, sandbox, or simulate (in-memory, no uploads) UploadMode _uploadMode = UploadMode.production; @@ -130,6 +143,10 @@ class AppState extends ChangeNotifier { await prefs.remove(_legacyTestModePrefsKey); await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } + // Max cameras + if (prefs.containsKey(_maxCamerasPrefsKey)) { + _maxCameras = prefs.getInt(_maxCamerasPrefsKey) ?? 250; + } // Offline mode loading if (prefs.containsKey(_offlineModePrefsKey)) { _offlineMode = prefs.getBool(_offlineModePrefsKey) ?? false; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index fb309b9..cbfc9be 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -6,6 +6,7 @@ import 'settings_screen_sections/queue_section.dart'; import 'settings_screen_sections/offline_areas_section.dart'; import 'settings_screen_sections/offline_mode_section.dart'; import 'settings_screen_sections/about_section.dart'; +import 'settings_screen_sections/max_cameras_section.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -17,14 +18,16 @@ class SettingsScreen extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: const [ - AuthSection(), - Divider(), UploadModeSection(), Divider(), + AuthSection(), + Divider(), QueueSection(), Divider(), ProfileListSection(), Divider(), + MaxCamerasSection(), + Divider(), OfflineModeSection(), Divider(), OfflineAreasSection(), diff --git a/lib/screens/settings_screen_sections/max_cameras_section.dart b/lib/screens/settings_screen_sections/max_cameras_section.dart new file mode 100644 index 0000000..410acfc --- /dev/null +++ b/lib/screens/settings_screen_sections/max_cameras_section.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../app_state.dart'; + +class MaxCamerasSection extends StatefulWidget { + const MaxCamerasSection({super.key}); + + @override + State createState() => _MaxCamerasSectionState(); +} + +class _MaxCamerasSectionState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + final maxCameras = context.read().maxCameras; + _controller = TextEditingController(text: maxCameras.toString()); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final current = appState.maxCameras; + final showWarning = current > 250; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + leading: const Icon(Icons.filter_alt), + title: const Text('Max cameras fetched/drawn'), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Set an upper limit for the number of cameras on the map (default: 250).'), + if (showWarning) + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: const [ + Icon(Icons.warning, color: Colors.orange, size: 18), + SizedBox(width: 6), + Expanded(child: Text( + 'You probably don\'t want to do that unless you are absolutely sure you have a good reason for it.', + style: TextStyle(color: Colors.orange), + )), + ], + ), + ), + ], + ), + trailing: SizedBox( + width: 80, + child: TextFormField( + controller: _controller, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8), + border: OutlineInputBorder(), + ), + onFieldSubmitted: (value) { + final n = int.tryParse(value) ?? 10; + appState.maxCameras = n; + _controller.text = appState.maxCameras.toString(); + }, + ), + ), + ), + ], + ); + } +} diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart index 28c1e02..a19f606 100644 --- a/lib/services/map_data_provider.dart +++ b/lib/services/map_data_provider.dart @@ -47,7 +47,12 @@ class MapDataProvider { throw UnimplementedError('Local camera loading not yet implemented.'); } else { // Use Overpass remote fetch, from submodule: - return camerasFromOverpass(bounds: bounds, profiles: profiles, uploadMode: uploadMode); + return camerasFromOverpass( + bounds: bounds, + profiles: profiles, + uploadMode: uploadMode, + maxCameras: AppState.instance.maxCameras, + ); } } /// Fetch tile image bytes from OSM or local (future). Only fetches, does not save! diff --git a/lib/services/map_data_submodules/cameras_from_overpass.dart b/lib/services/map_data_submodules/cameras_from_overpass.dart index 998f30b..d8339ea 100644 --- a/lib/services/map_data_submodules/cameras_from_overpass.dart +++ b/lib/services/map_data_submodules/cameras_from_overpass.dart @@ -12,6 +12,7 @@ Future> camerasFromOverpass({ required LatLngBounds bounds, required List profiles, UploadMode uploadMode = UploadMode.production, + int? maxCameras, }) async { if (profiles.isEmpty) return []; @@ -24,12 +25,13 @@ Future> camerasFromOverpass({ const String prodEndpoint = 'https://overpass-api.de/api/interpreter'; + final limit = maxCameras ?? AppState.instance.maxCameras; final query = ''' [out:json][timeout:25]; ( $nodeClauses ); - out body 250; + out body $limit; '''; try { diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 4b807b4..877a639 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -158,13 +158,33 @@ class _MapViewState extends State { } catch (_) { return; // controller not ready yet } - final cams = await _mapDataProvider.getCameras( - bounds: bounds, - profiles: appState.enabledProfiles, - uploadMode: appState.uploadMode, - // MapSource.auto (default) will prefer Overpass for now - ); - if (mounted) setState(() => _cameras = cams); + // If too zoomed out, do NOT fetch cameras; show info + final zoom = _controller.camera.zoom; + if (zoom < 10) { + if (mounted) setState(() => _cameras = []); + // Show a snackbar-style bubble, if desired + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Cameras not drawn below zoom level 10'), + duration: Duration(seconds: 2), + ), + ); + } + return; + } + try { + final cams = await _mapDataProvider.getCameras( + bounds: bounds, + profiles: appState.enabledProfiles, + uploadMode: appState.uploadMode, + // MapSource.auto (default) will prefer Overpass for now + ); + if (mounted) setState(() => _cameras = cams); + } on OfflineModeException catch (_) { + // Swallow the error in offline mode + if (mounted) setState(() => _cameras = []); + } } double _safeZoom() { @@ -199,14 +219,15 @@ class _MapViewState extends State { // Camera markers first, then GPS dot, so blue dot is always on top final markers = [ - ..._cameras.map( - (n) => Marker( + ..._cameras + .where((n) => n.coord.latitude != 0 || n.coord.longitude != 0) + .where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180) + .map((n) => Marker( point: n.coord, width: 24, height: 24, child: _CameraMapMarker(node: n, mapController: _controller), - ), - ), + )), if (_currentLatLng != null) Marker( point: _currentLatLng!, @@ -221,6 +242,8 @@ class _MapViewState extends State { _buildCone(session.target!, session.directionDegrees, zoom), ..._cameras .where((n) => n.hasDirection && n.directionDeg != null) + .where((n) => n.coord.latitude != 0 || n.coord.longitude != 0) + .where((n) => n.coord.latitude.abs() <= 90 && n.coord.longitude.abs() <= 180) .map((n) => _buildCone(n.coord, n.directionDeg!, zoom)), ]; @@ -233,6 +256,7 @@ class _MapViewState extends State { initialZoom: 15, maxZoom: 19, onPositionChanged: (pos, gesture) { + setState(() {}); // Instant UI update for zoom, etc. if (gesture) widget.onUserGesture(); if (session != null) { appState.updateSession(target: pos.center); @@ -264,7 +288,7 @@ class _MapViewState extends State { return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover); } } - return Image.asset('assets/transparent_1x1.png', gaplessPlayback: true, fit: BoxFit.cover); + return tileWidget; } catch (e) { print('tileBuilder error: $e for tileImage: ${tileImage.toString()}'); return tileWidget; diff --git a/lib/widgets/tile_provider_with_cache.dart b/lib/widgets/tile_provider_with_cache.dart index b91026f..f43aecd 100644 --- a/lib/widgets/tile_provider_with_cache.dart +++ b/lib/widgets/tile_provider_with_cache.dart @@ -11,6 +11,7 @@ class TileProviderWithCache extends TileProvider { static final Map _tileCache = {}; static Map get tileCache => _tileCache; final VoidCallback? onTileCacheUpdated; + TileProviderWithCache({this.onTileCacheUpdated}); @override @@ -20,7 +21,7 @@ class TileProviderWithCache extends TileProvider { return MemoryImage(_tileCache[key]!); } else { _fetchAndCacheTile(coords, key); - // Return a transparent PNG until the tile is available. + // Use asset (robust, cross-platform) for non-existing tiles. return const AssetImage('assets/transparent_1x1.png'); } } @@ -39,9 +40,10 @@ class TileProviderWithCache extends TileProvider { SchedulerBinding.instance.addPostFrameCallback((_) => onTileCacheUpdated!()); } } + // If bytes were empty, don't cache anything (will re-attempt next time) } catch (e) { print('[TileProviderWithCache] Error fetching tile $key: $e'); - // Optionally: fall back to a different asset, or record failures + // Do NOT cache a failed/placeholder/empty tile! } } } diff --git a/pubspec.yaml b/pubspec.yaml index 15ae275..7da3e55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,9 +36,8 @@ flutter: assets: - assets/info.txt - - assets/transparent_1x1.png - - assets/black_1x1.png - assets/app_icon.png + - assets/transparent_1x1.png flutter_native_splash: color: "#202020"