diff --git a/README.md b/README.md index 100e8a0..6dd1af9 100644 --- a/README.md +++ b/README.md @@ -4,16 +4,63 @@ A Flutter app for mapping and tagging ALPR-style cameras (and other surveillance --- -## Code Organization +## Code Organization (2025 Refactor) -This project uses a modular file/folder structure for maintainability: -- **Settings sections** each live in their own file under `lib/screens/settings_screen_sections/`. -- **Offline map area models, tile logic, and network/camera helpers** are grouped under `lib/services/offline_areas/`. -- The main Settings and OfflineAreaService files are now slim front-ends that delegate logic to these modules. +- **Data providers:** All map tile and camera data fetching now routes through `lib/services/map_data_provider.dart`, which supports both OSM/Overpass and fully offline/local sources, with pluggable submodules: + - Remote tile fetch: `map_data_submodules/tiles_from_osm.dart` + - Remote cameras: `map_data_submodules/cameras_from_overpass.dart` + - *Coming soon:* Local tile/camera modules for offline/area-aware access +- **Settings UI:** Each settings section lives in its own widget under `lib/screens/settings_screen_sections/`, using clean, modular ListTile-based layouts. +- **Offline areas:** Management, persistence, and download logic remain in `OfflineAreaService`, but all fetch/caching is routed through the new provider. +- **Legacy OSM/Overpass tile and camera fetch code has been removed from old modules.** --- -## User Experience & Features +## Key Features + +### Map Data & Provider Architecture +- **All map tile and camera fetches** go through MapDataProvider, which selects local or remote sources as needed, automatically obeying the user's offline/online preference and settings. +- **Offline Mode:** A global toggle in Settings disables all remote network fetches, forcing the app to use only locally downloaded map areas and cached camera data. (Instant feedback; no network calls when enabled.) +- **MapSource Selection:** MapDataProvider lets calling code specify local-only, remote-only, or auto preference for tiles and camera points. + +### Map View +- **Seamless offline/online tile loading:** Tiles are fetched (in parallel, with global concurrency/throttle control and exponential backoff) from OSM *only as needed*, with robust error handling and UI updates as tiles arrive. +- **Camera overlays** are fetched from Overpass or local cache, respecting both offline mode and user preference for which camera types to display. + +### Camera Profiles & Upload Queue +- Unchanged: creation/editing/enabling; see prior documentation. + +### Offline Map Areas +- **Download tiles/cameras for any bounding box**; areas cover any region/zoom, and are automatically de-duped and managed. +- **Robust area downloads** use the same MapDataProvider for source-of-truth logic, so downloads are always consistent with runtime lookup. +- **Permanent world base map** at low zoom always available for core map functionality, even on first-use/offline. + +### Modular, Future-friendly Codebase +- **No network fetch code outside the provider and submodules.** +- **All legacy/duplicate OSM/Overpass downloaders have been removed or marked for deprecation.** + +--- + +## For Developers + +**Highlights:** +- To add a new data source, just drop in a new submodule and route fetch via MapDataProvider. +- Any section of the app that needs tiles or camera data calls MapDataProvider with the relevant bounds/zoom/profiles and source preference. +- Offline Mode and all core settings are strictly respected at a single data/control point. + +--- + +## Roadmap (2025+) + +- **COMPLETE:** Core provider logic, settings, robust downloading and modular prefetch/caching. +- **IN PROGRESS:** Local/offline tile/camera fetch modules for runtime map viewing and offline area management. +- **NEXT:** More map overlays, offline routing, and data visualization. +- **SOON:** UX polish for download/error states, multi-layer base maps. + +--- + +*See prior README version for detailed setup/build/dependency notes—they remain unchanged!* + ### Map View - **Explore the Map:** View OSM raster tiles, live camera overlays, and a visual scale bar and zoom indicator in the lower left. diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png new file mode 100644 index 0000000..342225a Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png new file mode 100644 index 0000000..5058638 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-v21/background.png b/android/app/src/main/res/drawable-v21/background.png new file mode 100644 index 0000000..72b7566 Binary files /dev/null and b/android/app/src/main/res/drawable-v21/background.png differ diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml index f74085f..3cc4948 100644 --- a/android/app/src/main/res/drawable-v21/launch_background.xml +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png new file mode 100644 index 0000000..88007cc Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png new file mode 100644 index 0000000..23a7632 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png new file mode 100644 index 0000000..7231139 Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splash.png differ diff --git a/android/app/src/main/res/drawable/background.png b/android/app/src/main/res/drawable/background.png new file mode 100644 index 0000000..72b7566 Binary files /dev/null and b/android/app/src/main/res/drawable/background.png differ diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml index 304732f..3cc4948 100644 --- a/android/app/src/main/res/drawable/launch_background.xml +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -1,12 +1,9 @@ - - - - - + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..98e79f7 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..96baa1c 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..2e62a59 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..c17a2f5 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..bea0ec8 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night-v31/styles.xml b/android/app/src/main/res/values-night-v31/styles.xml new file mode 100644 index 0000000..5fef228 --- /dev/null +++ b/android/app/src/main/res/values-night-v31/styles.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml index 06952be..dbc9ea9 100644 --- a/android/app/src/main/res/values-night/styles.xml +++ b/android/app/src/main/res/values-night/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index cb1ef88..0d1fa8f 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -5,6 +5,10 @@ @drawable/launch_background + false + false + false + shortEdges - CFBundleURLTypes - - - CFBundleTypeRole - None - CFBundleURLSchemes - - flockmap - - - - - - LSApplicationQueriesSchemes - - https - - + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Flock Map App + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + flock_map_app + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSLocationWhenInUseUsageDescription + This app needs your location to show nearby cameras. + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + CFBundleURLTypes + + + CFBundleTypeRole + None + CFBundleURLSchemes + + flockmap + + + + + LSApplicationQueriesSchemes + + https + + UIStatusBarHidden + + diff --git a/lib/app_state.dart b/lib/app_state.dart index c96719d..c12c987 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -9,6 +9,7 @@ import 'models/pending_upload.dart'; import 'services/auth_service.dart'; import 'services/uploader.dart'; import 'services/profile_service.dart'; +import 'widgets/tile_provider_with_cache.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -24,16 +25,50 @@ class AddCameraSession { // ------------------ AppState ------------------ class AppState extends ChangeNotifier { + static late AppState instance; AppState() { + instance = this; _init(); } + // ------------------- Offline Mode ------------------- + static const String _offlineModePrefsKey = 'offline_mode'; + bool _offlineMode = false; + bool get offlineMode => _offlineMode; + Future setOfflineMode(bool enabled) async { + final wasOffline = _offlineMode; + _offlineMode = enabled; + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_offlineModePrefsKey, enabled); + if (wasOffline && !enabled) { + // Transitioning from offline to online: clear tile cache! + TileProviderWithCache.clearCache(); + } + notifyListeners(); + } + final _auth = AuthService(); String? _username; + bool _isInitialized = false; + bool get isInitialized => _isInitialized; + 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; @@ -114,8 +149,17 @@ 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; + } // Ensure AuthService follows loaded mode _auth.setUploadMode(_uploadMode); + print('AppState: AuthService mode now updated to $_uploadMode'); await _loadQueue(); @@ -137,6 +181,7 @@ class AppState extends ChangeNotifier { } _startUploader(); + _isInitialized = true; notifyListeners(); } diff --git a/lib/main.dart b/lib/main.dart index 9d85692..db56c14 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,11 +5,34 @@ import 'app_state.dart'; import 'screens/home_screen.dart'; import 'screens/settings_screen.dart'; -void main() { +import 'widgets/tile_provider_with_cache.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + runApp( ChangeNotifierProvider( create: (_) => AppState(), - child: const FlockMapApp(), + child: Consumer( + builder: (context, appState, _) { + if (!appState.isInitialized) { + // You can customize this splash/loading screen as needed + return MaterialApp( + home: Scaffold( + backgroundColor: Color(0xFF202020), + body: Center( + child: Image.asset( + 'assets/app_icon.png', + width: 240, + height: 240, + ), + ), + ), + ); + } + return const FlockMapApp(); + }, + ), ), ); } diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 6e4aa31..cbfc9be 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -4,7 +4,9 @@ import 'settings_screen_sections/upload_mode_section.dart'; import 'settings_screen_sections/profile_list_section.dart'; 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}); @@ -16,18 +18,18 @@ class SettingsScreen extends StatelessWidget { body: ListView( padding: const EdgeInsets.all(16), children: const [ - AuthSection(), - Divider(), UploadModeSection(), Divider(), + AuthSection(), + Divider(), QueueSection(), Divider(), ProfileListSection(), Divider(), - Padding( - padding: EdgeInsets.only(bottom: 8.0), - child: Text('Offline Areas', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - ), + MaxCamerasSection(), + Divider(), + OfflineModeSection(), + Divider(), OfflineAreasSection(), Divider(), AboutSection(), 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/screens/settings_screen_sections/offline_mode_section.dart b/lib/screens/settings_screen_sections/offline_mode_section.dart new file mode 100644 index 0000000..36a734a --- /dev/null +++ b/lib/screens/settings_screen_sections/offline_mode_section.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../app_state.dart'; + +class OfflineModeSection extends StatelessWidget { + const OfflineModeSection({super.key}); + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + return ListTile( + leading: const Icon(Icons.wifi_off), + title: const Text('Offline Mode'), + subtitle: const Text('Disable all network requests except for local/offline areas.'), + trailing: Switch( + value: appState.offlineMode, + onChanged: (value) async => await appState.setOfflineMode(value), + ), + ); + } +} diff --git a/lib/screens/settings_screen_sections/profile_list_section.dart b/lib/screens/settings_screen_sections/profile_list_section.dart index 94cbcee..00612f3 100644 --- a/lib/screens/settings_screen_sections/profile_list_section.dart +++ b/lib/screens/settings_screen_sections/profile_list_section.dart @@ -76,7 +76,7 @@ class ProfileListSection extends StatelessWidget { ), ); } else if (value == 'delete') { - _showDeleteProfileDialog(context, appState, p); + _showDeleteProfileDialog(context, p); } }, ), @@ -86,30 +86,31 @@ class ProfileListSection extends StatelessWidget { ); } - void _showDeleteProfileDialog(BuildContext context, AppState appState, CameraProfile profile) { - showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Delete Profile'), - content: Text('Are you sure you want to delete "${profile.name}"?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - appState.deleteProfile(profile); - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Profile deleted')), - ); - }, - style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Delete'), - ), - ], - ), - ); - } +void _showDeleteProfileDialog(BuildContext context, CameraProfile profile) { + final appState = context.read(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Profile'), + content: Text('Are you sure you want to delete "${profile.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + appState.deleteProfile(profile); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile deleted')), + ); + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete'), + ), + ], + ), + ); +} } diff --git a/lib/screens/settings_screen_sections/queue_section.dart b/lib/screens/settings_screen_sections/queue_section.dart index 3899965..6d82405 100644 --- a/lib/screens/settings_screen_sections/queue_section.dart +++ b/lib/screens/settings_screen_sections/queue_section.dart @@ -19,7 +19,7 @@ class QueueSection extends StatelessWidget { ? const Text('Sandbox mode – uploads go to OSM Sandbox') : const Text('Tap to view queue'), onTap: appState.pendingCount > 0 - ? () => _showQueueDialog(context, appState) + ? () => _showQueueDialog(context) : null, ), if (appState.pendingCount > 0) @@ -57,7 +57,8 @@ class QueueSection extends StatelessWidget { ); } - void _showQueueDialog(BuildContext context, AppState appState) { + void _showQueueDialog(BuildContext context) { + final appState = context.read(); showDialog( context: context, builder: (context) => AlertDialog( diff --git a/lib/services/map_data_provider.dart b/lib/services/map_data_provider.dart new file mode 100644 index 0000000..6f93549 --- /dev/null +++ b/lib/services/map_data_provider.dart @@ -0,0 +1,74 @@ +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../models/camera_profile.dart'; +import '../models/osm_camera_node.dart'; +import '../app_state.dart'; +import 'map_data_submodules/cameras_from_overpass.dart'; +import 'map_data_submodules/tiles_from_osm.dart'; + +enum MapSource { local, remote, auto } // For future use + +class OfflineModeException implements Exception { + final String message; + OfflineModeException(this.message); + @override + String toString() => 'OfflineModeException: $message'; +} + +class MapDataProvider { + static final MapDataProvider _instance = MapDataProvider._(); + factory MapDataProvider() => _instance; + MapDataProvider._(); + + // REMOVED: AppState get _appState => AppState(); + + bool get isOfflineMode => AppState.instance.offlineMode; + void setOfflineMode(bool enabled) { + AppState.instance.setOfflineMode(enabled); + } + + /// Fetch cameras from OSM/Overpass or local storage, depending on source/offline mode. + Future> getCameras({ + required LatLngBounds bounds, + required List profiles, + UploadMode uploadMode = UploadMode.production, + MapSource source = MapSource.auto, + }) async { + final offline = AppState.instance.offlineMode; + print('[MapDataProvider] getCameras called, source=$source, offlineMode=$offline'); + // Resolve source: + if (offline && source != MapSource.local) { + print('[MapDataProvider] BLOCKED by offlineMode for getCameras'); + throw OfflineModeException("Cannot fetch remote cameras in offline mode."); + } + if (source == MapSource.local) { + // TODO: implement local camera loading + throw UnimplementedError('Local camera loading not yet implemented.'); + } else { + // Use Overpass remote fetch, from submodule: + 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! + Future> getTile({ + required int z, + required int x, + required int y, + MapSource source = MapSource.auto, + }) async { + print('[MapDataProvider] getTile called for $z/$x/$y, source=$source'); + if (source == MapSource.local) { + // TODO: implement local tile loading + throw UnimplementedError('Local tile loading not yet implemented.'); + } else { + // Use OSM remote fetch from submodule: + return fetchOSMTile(z: z, x: x, y: y); + } + } +} \ No newline at end of file diff --git a/lib/services/map_data_submodules/cameras_from_local.dart b/lib/services/map_data_submodules/cameras_from_local.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/services/map_data_submodules/cameras_from_overpass.dart b/lib/services/map_data_submodules/cameras_from_overpass.dart new file mode 100644 index 0000000..d8339ea --- /dev/null +++ b/lib/services/map_data_submodules/cameras_from_overpass.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import '../../models/camera_profile.dart'; +import '../../models/osm_camera_node.dart'; +import '../../app_state.dart'; + +/// Fetches cameras from the Overpass OSM API for the given bounds and profiles. +Future> camerasFromOverpass({ + required LatLngBounds bounds, + required List profiles, + UploadMode uploadMode = UploadMode.production, + int? maxCameras, +}) async { + if (profiles.isEmpty) return []; + + final nodeClauses = profiles.map((profile) { + final tagFilters = profile.tags.entries + .map((e) => '["${e.key}"="${e.value}"]') + .join('\n '); + return '''node\n $tagFilters\n (${bounds.southWest.latitude},${bounds.southWest.longitude},\n ${bounds.northEast.latitude},${bounds.northEast.longitude});'''; + }).join('\n '); + + const String prodEndpoint = 'https://overpass-api.de/api/interpreter'; + + final limit = maxCameras ?? AppState.instance.maxCameras; + final query = ''' + [out:json][timeout:25]; + ( + $nodeClauses + ); + out body $limit; + '''; + + try { + print('[camerasFromOverpass] Querying Overpass...'); + print('[camerasFromOverpass] Query:\n$query'); + final resp = await http.post(Uri.parse(prodEndpoint), body: {'data': query.trim()}); + print('[camerasFromOverpass] Status: ${resp.statusCode}, Length: ${resp.body.length}'); + if (resp.statusCode != 200) { + print('[camerasFromOverpass] Overpass failed: ${resp.body}'); + return []; + } + final data = jsonDecode(resp.body) as Map; + final elements = data['elements'] as List; + print('[camerasFromOverpass] Retrieved elements: ${elements.length}'); + return elements.whereType>().map((e) { + return OsmCameraNode( + id: e['id'], + coord: LatLng(e['lat'], e['lon']), + tags: Map.from(e['tags'] ?? {}), + ); + }).toList(); + } catch (e) { + print('[camerasFromOverpass] Overpass exception: $e'); + return []; + } +} diff --git a/lib/services/map_data_submodules/tiles_from_local.dart b/lib/services/map_data_submodules/tiles_from_local.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/services/map_data_submodules/tiles_from_osm.dart b/lib/services/map_data_submodules/tiles_from_osm.dart new file mode 100644 index 0000000..67dfa7d --- /dev/null +++ b/lib/services/map_data_submodules/tiles_from_osm.dart @@ -0,0 +1,81 @@ +import 'dart:math'; +import 'dart:io'; +import 'dart:async'; +import 'package:http/http.dart' as http; +import 'package:flutter/foundation.dart'; + +/// Global semaphore to limit simultaneous tile fetches +final _tileFetchSemaphore = _SimpleSemaphore(4); // Max 4 concurrent + +/// Fetches a tile from OSM, with in-memory retries/backoff, and global concurrency limit. +/// Returns tile image bytes, or throws on persistent failure. +Future> fetchOSMTile({ + required int z, + required int x, + required int y, +}) async { + final url = 'https://tile.openstreetmap.org/$z/$x/$y.png'; + const int maxAttempts = 3; + int attempt = 0; + final random = Random(); + final delays = [ + 4000 + random.nextInt(1000), // 4-5s after 1st failure + 15000 + random.nextInt(4000), // 15-19s after 2nd + 60000 + random.nextInt(5000), // 60-65s after 3rd + ]; + while (true) { + await _tileFetchSemaphore.acquire(); + try { + print('[fetchOSMTile] FETCH $z/$x/$y'); + attempt++; + final resp = await http.get(Uri.parse(url)); + print('[fetchOSMTile] HTTP ${resp.statusCode} for $z/$x/$y, length=${resp.bodyBytes.length}'); + if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) { + print('[fetchOSMTile] SUCCESS $z/$x/$y'); + return resp.bodyBytes; + } else { + print('[fetchOSMTile] FAIL $z/$x/$y: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}'); + throw HttpException('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}'); + } + } catch (e) { + print('[fetchOSMTile] Exception $z/$x/$y: $e'); + if (attempt >= maxAttempts) { + print("[fetchOSMTile] Failed for $z/$x/$y after $attempt attempts: $e"); + rethrow; + } + final delay = delays[attempt - 1].clamp(0, 60000); + print("[fetchOSMTile] Attempt $attempt for $z/$x/$y failed: $e. Retrying in ${delay}ms."); + await Future.delayed(Duration(milliseconds: delay)); + } finally { + _tileFetchSemaphore.release(); + } + } +} + +/// Simple counting semaphore, suitable for single-thread Flutter concurrency +class _SimpleSemaphore { + final int _max; + int _current = 0; + final List _queue = []; + _SimpleSemaphore(this._max); + + Future acquire() async { + if (_current < _max) { + _current++; + return; + } else { + final c = Completer(); + _queue.add(() => c.complete()); + await c.future; + } + } + + void release() { + if (_queue.isNotEmpty) { + final callback = _queue.removeAt(0); + callback(); + } else { + _current--; + } + } +} \ No newline at end of file diff --git a/lib/services/offline_area_service.dart b/lib/services/offline_area_service.dart index 9e8c4b5..dc92c70 100644 --- a/lib/services/offline_area_service.dart +++ b/lib/services/offline_area_service.dart @@ -6,8 +6,11 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds; import 'package:path_provider/path_provider.dart'; import 'offline_areas/offline_area_models.dart'; import 'offline_areas/offline_tile_utils.dart'; -import 'offline_areas/offline_area_service_tile_fetch.dart'; +import 'offline_areas/offline_area_service_tile_fetch.dart'; // Only used for file IO during area downloads. import '../models/osm_camera_node.dart'; +import '../app_state.dart'; +import 'map_data_provider.dart'; +import 'map_data_submodules/cameras_from_overpass.dart'; /// Service for managing download, storage, and retrieval of offline map areas and cameras. class OfflineAreaService { @@ -213,7 +216,11 @@ class OfflineAreaService { for (final tile in tilesToFetch) { if (area.status == OfflineAreaStatus.cancelled) break; try { - await downloadTile(tile[0], tile[1], tile[2], directory); + final bytes = await MapDataProvider().getTile( + z: tile[0], x: tile[1], y: tile[2], source: MapSource.remote); + if (bytes.isNotEmpty) { + await saveTileBytes(tile[0], tile[1], tile[2], directory, bytes); + } totalDone++; doneThisPass++; area.tilesDownloaded = totalDone; @@ -238,7 +245,10 @@ class OfflineAreaService { } if (!area.isPermanent) { - final cameras = await downloadAllCameras(bounds); + final cameras = await camerasFromOverpass( + bounds: bounds, + profiles: AppState.instance.enabledProfiles, + ); area.cameras = cameras; await saveCameras(cameras, directory); } else { diff --git a/lib/services/offline_areas/offline_area_service_tile_fetch.dart b/lib/services/offline_areas/offline_area_service_tile_fetch.dart index c0ba7bc..6da9410 100644 --- a/lib/services/offline_areas/offline_area_service_tile_fetch.dart +++ b/lib/services/offline_areas/offline_area_service_tile_fetch.dart @@ -1,55 +1,18 @@ -import 'dart:math'; import 'dart:io'; import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:latlong2/latlong.dart'; import '../../models/osm_camera_node.dart'; -import 'package:flutter_map/flutter_map.dart' show LatLngBounds; -Future downloadTile(int z, int x, int y, String baseDir) async { - final url = 'https://tile.openstreetmap.org/$z/$x/$y.png'; +/// Disk IO utilities for offline area file management ONLY. No network requests should occur here. + +/// Save-to-disk for a tile that has already been fetched elsewhere. +Future saveTileBytes(int z, int x, int y, String baseDir, List bytes) async { final dir = Directory('$baseDir/tiles/$z/$x'); await dir.create(recursive: true); final file = File('${dir.path}/$y.png'); - if (await file.exists()) return; // already downloaded - const int maxAttempts = 3; - int attempt = 0; - final random = Random(); - final delays = [0, 3000 + random.nextInt(1000) - 500, 10000 + random.nextInt(4000) - 2000]; - while (true) { - try { - attempt++; - final resp = await http.get(Uri.parse(url)); - if (resp.statusCode == 200) { - await file.writeAsBytes(resp.bodyBytes); - return; - } else { - throw Exception('Failed to download tile $z/$x/$y (status \\${resp.statusCode})'); - } - } catch (e) { - if (attempt >= maxAttempts) { - throw Exception("Failed to download tile $z/$x/$y after $attempt attempts: $e"); - } - final delay = delays[attempt-1].clamp(0, 60000); - await Future.delayed(Duration(milliseconds: delay)); - } - } -} - -Future> downloadAllCameras(LatLngBounds bounds) async { - final sw = bounds.southWest; - final ne = bounds.northEast; - final bbox = [sw.latitude, sw.longitude, ne.latitude, ne.longitude].join(','); - final query = '[out:json][timeout:60];node["man_made"="surveillance"]["camera:mount"="pole"]($bbox);out body;'; - final url = 'https://overpass-api.de/api/interpreter'; - final resp = await http.post(Uri.parse(url), body: { 'data': query }); - if (resp.statusCode != 200) { - throw Exception('Failed to fetch cameras'); - } - final data = jsonDecode(resp.body); - return (data['elements'] as List?)?.map((e) => OsmCameraNode.fromJson(e)).toList() ?? []; + await file.writeAsBytes(bytes); } +/// Save-to-disk for cameras.json; called only by OfflineAreaService during area download Future saveCameras(List cams, String dir) async { final file = File('$dir/cameras.json'); await file.writeAsString(jsonEncode(cams.map((c) => c.toJson()).toList())); diff --git a/lib/services/overpass_service.dart b/lib/services/overpass_service.dart deleted file mode 100644 index 10db57e..0000000 --- a/lib/services/overpass_service.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; -import 'package:flutter_map/flutter_map.dart'; -import 'package:latlong2/latlong.dart'; - -import '../models/camera_profile.dart'; -import '../models/osm_camera_node.dart'; - -import '../app_state.dart'; - -class OverpassService { - static const _prodEndpoint = 'https://overpass-api.de/api/interpreter'; - static const _sandboxEndpoint = 'https://overpass-api.dev.openstreetmap.org/api/interpreter'; - - // You can pass UploadMode, or use production by default - Future> fetchCameras( - LatLngBounds bbox, - List profiles, - {UploadMode uploadMode = UploadMode.production} - ) async { - if (profiles.isEmpty) return []; - - // Build one node query per enabled profile (each with all its tags required) - final nodeClauses = profiles.map((profile) { - final tagFilters = profile.tags.entries - .map((e) => '["${e.key}"="${e.value}"]') - .join('\n '); - return '''node\n $tagFilters\n (${bbox.southWest.latitude},${bbox.southWest.longitude},\n ${bbox.northEast.latitude},${bbox.northEast.longitude});'''; - }).join('\n '); - - final query = ''' - [out:json][timeout:25]; - ( - $nodeClauses - ); - out body 250; - '''; - - Future> fetchFromUri(String endpoint, String query) async { - try { - print('[Overpass] Querying $endpoint'); - print('[Overpass] Query:\n$query'); - final resp = await http.post(Uri.parse(endpoint), body: {'data': query.trim()}); - print('[Overpass] Status: \\${resp.statusCode}, Length: \\${resp.body.length}'); - if (resp.statusCode != 200) { - print('[Overpass] Failed: \\${resp.body}'); - return []; - } - final data = jsonDecode(resp.body) as Map; - final elements = data['elements'] as List; - print('[Overpass] Retrieved elements: \\${elements.length}'); - return elements.whereType>().map((e) { - return OsmCameraNode( - id: e['id'], - coord: LatLng(e['lat'], e['lon']), - tags: Map.from(e['tags'] ?? {}), - ); - }).toList(); - } catch (e) { - print('[Overpass] Exception: \\${e}'); - // Network error – return empty list silently - return []; - } - } - - // Fetch from production Overpass for all modes. - return await fetchFromUri(_prodEndpoint, query); - } -} - diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 5451f8f..04817a8 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -1,20 +1,25 @@ import 'dart:async'; import 'dart:math' as math; import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:http/io_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import '../app_state.dart'; -import '../services/overpass_service.dart'; +import '../services/map_data_provider.dart'; import '../services/offline_area_service.dart'; import '../models/osm_camera_node.dart'; import 'debouncer.dart'; import 'camera_tag_sheet.dart'; +import 'tile_provider_with_cache.dart'; // --- Smart marker widget for camera with single/double tap distinction class _CameraMapMarker extends StatefulWidget { @@ -79,8 +84,9 @@ class MapView extends StatefulWidget { class _MapViewState extends State { late final MapController _controller; - final OverpassService _overpass = OverpassService(); + final MapDataProvider _mapDataProvider = MapDataProvider(); final Debouncer _debounce = Debouncer(const Duration(milliseconds: 500)); + Debouncer? _debounceTileLayerUpdate; StreamSubscription? _positionSub; LatLng? _currentLatLng; @@ -89,15 +95,16 @@ class _MapViewState extends State { List _lastProfileIds = []; UploadMode? _lastUploadMode; - void _maybeRefreshCameras(AppState appState) { + void _maybeRefreshCameras() { + final appState = context.read(); final currProfileIds = appState.enabledProfiles.map((p) => p.id).toList(); final currMode = appState.uploadMode; - if (_lastProfileIds.isEmpty || + if (_lastProfileIds.isEmpty || currProfileIds.length != _lastProfileIds.length || !_lastProfileIds.asMap().entries.every((entry) => currProfileIds[entry.key] == entry.value) || _lastUploadMode != currMode) { // If this is first load, or list/ids/mode changed, refetch - _debounce(() => _refreshCameras(appState)); + _debounce(_refreshCameras); _lastProfileIds = List.from(currProfileIds); _lastUploadMode = currMode; } @@ -106,6 +113,7 @@ class _MapViewState extends State { @override void initState() { super.initState(); + _debounceTileLayerUpdate = Debouncer(const Duration(milliseconds: 50),); // Kick off offline area loading as soon as map loads OfflineAreaService(); _controller = widget.controller; @@ -142,19 +150,41 @@ class _MapViewState extends State { }); } - Future _refreshCameras(AppState appState) async { + Future _refreshCameras() async { + final appState = context.read(); LatLngBounds? bounds; try { bounds = _controller.camera.visibleBounds; } catch (_) { return; // controller not ready yet } - final cams = await _overpass.fetchCameras( - bounds, - appState.enabledProfiles, - uploadMode: appState.uploadMode, - ); - 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() { @@ -173,7 +203,7 @@ class _MapViewState extends State { // Refetch only if profiles or mode changed // This avoids repeated fetches on every build // We track last seen values (local to the State class) - _maybeRefreshCameras(appState); + _maybeRefreshCameras(); // Seed add‑mode target once, after first controller center is available. if (session != null && session.target == null) { @@ -189,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!, @@ -211,38 +242,59 @@ 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)), ]; return Stack( children: [ FlutterMap( + key: ValueKey(appState.offlineMode), mapController: _controller, options: MapOptions( initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194), 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); } - _debounce(() => _refreshCameras(appState)); + _debounce(_refreshCameras); }, ), children: [ TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - tileProvider: NetworkTileProvider( - headers: { - 'User-Agent': - 'FlockMap/0.4 (+https://github.com/yourrepo)', + tileProvider: TileProviderWithCache( + onTileCacheUpdated: () { + if (_debounceTileLayerUpdate != null) _debounceTileLayerUpdate!(() { if (mounted) setState(() {}); }); }, - httpClient: IOClient( - HttpClient()..maxConnectionsPerHost = 4, - ), ), - userAgentPackageName: 'com.example.flock_map_app', + urlTemplate: 'unused-{z}-{x}-{y}', + tileSize: 256, + tileBuilder: (ctx, tileWidget, tileImage) { + try { + final str = tileImage.toString(); + final regex = RegExp(r'TileCoordinate\((\d+), (\d+), (\d+)\)'); + final match = regex.firstMatch(str); + if (match != null) { + final x = match.group(1); + final y = match.group(2); + final z = match.group(3); + final key = '$z/$x/$y'; + final bytes = TileProviderWithCache.tileCache[key]; + if (bytes != null && bytes.isNotEmpty) { + return Image.memory(bytes, gaplessPlayback: true, fit: BoxFit.cover); + } + } + return tileWidget; + } catch (e) { + print('tileBuilder error: $e for tileImage: ${tileImage.toString()}'); + return tileWidget; + } + } ), PolygonLayer(polygons: overlays), MarkerLayer(markers: markers), diff --git a/lib/widgets/tile_provider_with_cache.dart b/lib/widgets/tile_provider_with_cache.dart new file mode 100644 index 0000000..ba2c019 --- /dev/null +++ b/lib/widgets/tile_provider_with_cache.dart @@ -0,0 +1,59 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/services.dart'; +import '../services/map_data_provider.dart'; +import '../app_state.dart'; + +/// Singleton in-memory tile cache and async provider for custom tiles. +class TileProviderWithCache extends TileProvider { + static final Map _tileCache = {}; + static Map get tileCache => _tileCache; + final VoidCallback? onTileCacheUpdated; + + TileProviderWithCache({this.onTileCacheUpdated}); + + @override + ImageProvider getImage(TileCoordinates coords, TileLayer options, {MapSource source = MapSource.auto}) { + final key = '${coords.z}/${coords.x}/${coords.y}'; + if (_tileCache.containsKey(key)) { + return MemoryImage(_tileCache[key]!); + } else { + _fetchAndCacheTile(coords, key, source: source); + // Always return a placeholder until the real tile is cached, regardless of source/offline/online. + return const AssetImage('assets/transparent_1x1.png'); + } + } + + static void clearCache() { + _tileCache.clear(); + print('[TileProviderWithCache] Tile cache cleared'); + } + + void _fetchAndCacheTile(TileCoordinates coords, String key, {MapSource source = MapSource.auto}) async { + // Don't fire multiple fetches for the same tile simultaneously + if (_tileCache.containsKey(key)) return; + // Only block REMOTE fetch in offline mode, but allow local/offline sources in the future. + if (AppState.instance.offlineMode && source != MapSource.local) { + print('[TileProviderWithCache] BLOCKED tile $key due to offline mode'); + return; + } + try { + final bytes = await MapDataProvider().getTile( + z: coords.z, x: coords.x, y: coords.y, source: source, + ); + if (bytes.isNotEmpty) { + _tileCache[key] = Uint8List.fromList(bytes); + print('[TileProviderWithCache] Cached tile $key, bytes=${bytes.length}'); + if (onTileCacheUpdated != null) { + SchedulerBinding.instance.addPostFrameCallback((_) => onTileCacheUpdated!()); + } + } + // If bytes were empty, don't cache (will re-attempt next time) + } catch (e) { + print('[TileProviderWithCache] Error fetching tile $key: $e'); + // Do NOT cache a failed or empty tile! Placeholder tiles will be evicted on online transition. + } + } +} diff --git a/pubspec.lock b/pubspec.lock index 7fae507..29982b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1,6 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + ansicolor: + dependency: transitive + description: + name: ansicolor + sha256: "50e982d500bc863e1d703448afdbf9e5a72eb48840a4f766fa361ffd6877055f" + url: "https://pub.dev" + source: hosted + version: "2.0.3" + archive: + dependency: transitive + description: + name: archive + sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + url: "https://pub.dev" + source: hosted + version: "4.0.7" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -17,6 +41,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" clock: dependency: transitive description: @@ -41,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + csslib: + dependency: transitive + description: + name: csslib + sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e" + url: "https://pub.dev" + source: hosted + version: "1.0.2" dart_earcut: dependency: transitive description: @@ -94,6 +142,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_launcher_icons: + dependency: "direct dev" + description: + name: flutter_launcher_icons + sha256: "10f13781741a2e3972126fae08393d3c4e01fa4cd7473326b94b72cf594195e7" + url: "https://pub.dev" + source: hosted + version: "0.14.4" flutter_map: dependency: "direct main" description: @@ -102,6 +158,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.2.1" + flutter_native_splash: + dependency: "direct dev" + description: + name: flutter_native_splash + sha256: "8321a6d11a8d13977fa780c89de8d257cce3d841eecfb7a4cadffcc4f12d82dc" + url: "https://pub.dev" + source: hosted + version: "2.4.6" flutter_secure_storage: dependency: "direct main" description: @@ -219,6 +283,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.5" + html: + dependency: transitive + description: + name: html + sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" + url: "https://pub.dev" + source: hosted + version: "0.15.6" http: dependency: "direct main" description: @@ -235,6 +307,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: "4e973fcf4caae1a4be2fa0a13157aa38a8f9cb049db6529aa00b4d71abc4d928" + url: "https://pub.dev" + source: hosted + version: "4.5.4" intl: dependency: transitive description: @@ -243,6 +323,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" latlong2: dependency: "direct main" description: @@ -363,6 +451,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646" + url: "https://pub.dev" + source: hosted + version: "6.1.0" platform: dependency: transitive description: @@ -379,6 +475,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + url: "https://pub.dev" + source: hosted + version: "6.0.3" proj4dart: dependency: transitive description: @@ -512,6 +616,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" url_launcher: dependency: transitive description: @@ -632,6 +744,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" sdks: dart: ">=3.8.0 <4.0.0" flutter: ">=3.27.0" diff --git a/pubspec.yaml b/pubspec.yaml index dd10035..7da3e55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,8 +27,26 @@ dependencies: shared_preferences: ^2.2.2 uuid: ^4.0.0 +dev_dependencies: + flutter_launcher_icons: ^0.14.4 + flutter_native_splash: ^2.4.6 + flutter: uses-material-design: true assets: - assets/info.txt + - assets/app_icon.png + - assets/transparent_1x1.png + +flutter_native_splash: + color: "#202020" + image: assets/app_icon.png + android: true + ios: true + +flutter_icons: + android: true + ios: true + image_path: "assets/app_icon.png" + min_sdk_android: 21 \ No newline at end of file