From e65b9f58a662b5a8c9f4858e464d6e6807e9660f Mon Sep 17 00:00:00 2001 From: stopflock Date: Sat, 23 Aug 2025 21:40:36 -0500 Subject: [PATCH] holy crap tile types --- lib/app_state.dart | 7 ++ lib/models/tile_provider.dart | 100 ++++++++++++++++++ lib/screens/settings_screen.dart | 3 + .../tile_provider_section.dart | 60 +++++++++++ lib/services/simple_tile_service.dart | 4 +- lib/state/settings_state.dart | 20 +++- lib/widgets/map_view.dart | 46 ++++++-- 7 files changed, 229 insertions(+), 11 deletions(-) create mode 100644 lib/models/tile_provider.dart create mode 100644 lib/screens/settings_screen_sections/tile_provider_section.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index b7e836c..0d59186 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -3,6 +3,7 @@ import 'package:latlong2/latlong.dart'; import 'models/camera_profile.dart'; import 'models/pending_upload.dart'; +import 'models/tile_provider.dart'; import 'services/offline_area_service.dart'; import 'state/auth_state.dart'; import 'state/profile_state.dart'; @@ -13,6 +14,7 @@ import 'state/upload_queue_state.dart'; // Re-export types for backward compatibility export 'state/settings_state.dart' show UploadMode; export 'state/session_state.dart' show AddCameraSession; +export 'models/tile_provider.dart' show TileProviderType; // ------------------ AppState ------------------ class AppState extends ChangeNotifier { @@ -64,6 +66,7 @@ class AppState extends ChangeNotifier { bool get offlineMode => _settingsState.offlineMode; int get maxCameras => _settingsState.maxCameras; UploadMode get uploadMode => _settingsState.uploadMode; + TileProviderType get tileProvider => _settingsState.tileProvider; // Upload queue state int get pendingCount => _uploadQueueState.pendingCount; @@ -176,6 +179,10 @@ class AppState extends ChangeNotifier { _startUploader(); // Restart uploader with new mode } + Future setTileProvider(TileProviderType provider) async { + await _settingsState.setTileProvider(provider); + } + // ---------- Queue Methods ---------- void clearQueue() { _uploadQueueState.clearQueue(); diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart new file mode 100644 index 0000000..dedf1f6 --- /dev/null +++ b/lib/models/tile_provider.dart @@ -0,0 +1,100 @@ +enum TileProviderType { + osmStreet, + googleHybrid, + arcgisSatellite, + mapboxSatellite, +} + +class TileProviderConfig { + final TileProviderType type; + final String name; + final String urlTemplate; + final String attribution; + final bool requiresApiKey; + final String? description; + + const TileProviderConfig({ + required this.type, + required this.name, + required this.urlTemplate, + required this.attribution, + this.requiresApiKey = false, + this.description, + }); + + /// Returns the URL template with API key inserted if needed + String getUrlTemplate({String? apiKey}) { + if (requiresApiKey && apiKey != null) { + return urlTemplate.replaceAll('{api_key}', apiKey); + } + return urlTemplate; + } + + /// Check if this provider is available (has required API key if needed) + bool isAvailable({String? apiKey}) { + if (requiresApiKey) { + return apiKey != null && apiKey.isNotEmpty; + } + return true; + } +} + +/// Built-in tile provider configurations +class TileProviders { + static const osmStreet = TileProviderConfig( + type: TileProviderType.osmStreet, + name: 'Street Map', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + description: 'Standard street map with roads, buildings, and labels', + ); + + static const googleHybrid = TileProviderConfig( + type: TileProviderType.googleHybrid, + name: 'Satellite + Roads', + urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', + attribution: '© Google', + description: 'Satellite imagery with road and label overlays', + ); + + static const arcgisSatellite = TileProviderConfig( + type: TileProviderType.arcgisSatellite, + name: 'Pure Satellite', + urlTemplate: 'http://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png', + attribution: '© Esri © Maxar', + description: 'High-resolution satellite imagery without overlays', + ); + + static const mapboxSatellite = TileProviderConfig( + type: TileProviderType.mapboxSatellite, + name: 'Pure Satellite (Mapbox)', + urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + attribution: '© Mapbox © Maxar', + requiresApiKey: true, + description: 'High-resolution satellite imagery without overlays', + ); + + /// Get all available tile providers (those with API keys if required) + static List getAvailable({String? mapboxApiKey}) { + return [ + osmStreet, + googleHybrid, + arcgisSatellite, + if (mapboxSatellite.isAvailable(apiKey: mapboxApiKey)) mapboxSatellite, + ]; + } + + /// Get provider config by type + static TileProviderConfig? getByType(TileProviderType type) { + switch (type) { + case TileProviderType.osmStreet: + return osmStreet; + case TileProviderType.googleHybrid: + return googleHybrid; + case TileProviderType.arcgisSatellite: + return arcgisSatellite; + case TileProviderType.mapboxSatellite: + return mapboxSatellite; + } + } +} \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index cbfc9be..05f8a36 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -7,6 +7,7 @@ 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'; +import 'settings_screen_sections/tile_provider_section.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -28,6 +29,8 @@ class SettingsScreen extends StatelessWidget { Divider(), MaxCamerasSection(), Divider(), + TileProviderSection(), + Divider(), OfflineModeSection(), Divider(), OfflineAreasSection(), diff --git a/lib/screens/settings_screen_sections/tile_provider_section.dart b/lib/screens/settings_screen_sections/tile_provider_section.dart new file mode 100644 index 0000000..1720a4e --- /dev/null +++ b/lib/screens/settings_screen_sections/tile_provider_section.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../app_state.dart'; +import '../../models/tile_provider.dart'; + +class TileProviderSection extends StatelessWidget { + const TileProviderSection({super.key}); + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final currentProvider = appState.tileProvider; + + // Get available providers (for now, all free ones are available) + final availableProviders = [ + TileProviders.osmStreet, + TileProviders.googleHybrid, + TileProviders.arcgisSatellite, + // Don't include Mapbox for now since we don't have API key handling + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Map Type', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + ...availableProviders.map((config) { + final isSelected = config.type == currentProvider; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Radio( + value: config.type, + groupValue: currentProvider, + onChanged: (TileProviderType? value) { + if (value != null) { + appState.setTileProvider(value); + } + }, + ), + title: Text(config.name), + subtitle: config.description != null + ? Text( + config.description!, + style: Theme.of(context).textTheme.bodySmall, + ) + : null, + onTap: () { + appState.setTileProvider(config.type); + }, + ); + }), + ], + ); + } +} \ No newline at end of file diff --git a/lib/services/simple_tile_service.dart b/lib/services/simple_tile_service.dart index 1c97436..1ff7a56 100644 --- a/lib/services/simple_tile_service.dart +++ b/lib/services/simple_tile_service.dart @@ -13,12 +13,12 @@ class SimpleTileHttpClient extends http.BaseClient { @override Future send(http.BaseRequest request) async { - // Only intercept tile requests to OSM + // Only intercept tile requests to OSM (for now - other providers pass through) if (request.url.host == 'tile.openstreetmap.org') { return _handleTileRequest(request); } - // Pass through all other requests + // Pass through all other requests (Google, Mapbox, etc.) return _inner.send(request); } diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index de81cf2..3db04ca 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; - +import '../models/tile_provider.dart'; // Enum for upload mode (Production, OSM Sandbox, Simulate) enum UploadMode { production, sandbox, simulate } @@ -10,16 +10,19 @@ class SettingsState extends ChangeNotifier { static const String _offlineModePrefsKey = 'offline_mode'; static const String _maxCamerasPrefsKey = 'max_cameras'; static const String _uploadModePrefsKey = 'upload_mode'; + static const String _tileProviderPrefsKey = 'tile_provider'; static const String _legacyTestModePrefsKey = 'test_mode'; bool _offlineMode = false; int _maxCameras = 250; UploadMode _uploadMode = UploadMode.simulate; + TileProviderType _tileProvider = TileProviderType.osmStreet; // Getters bool get offlineMode => _offlineMode; int get maxCameras => _maxCameras; UploadMode get uploadMode => _uploadMode; + TileProviderType get tileProvider => _tileProvider; // Initialize settings from preferences Future init() async { @@ -46,6 +49,14 @@ class SettingsState extends ChangeNotifier { await prefs.remove(_legacyTestModePrefsKey); await prefs.setInt(_uploadModePrefsKey, _uploadMode.index); } + + // Load tile provider + if (prefs.containsKey(_tileProviderPrefsKey)) { + final idx = prefs.getInt(_tileProviderPrefsKey) ?? 0; + if (idx >= 0 && idx < TileProviderType.values.length) { + _tileProvider = TileProviderType.values[idx]; + } + } } Future setOfflineMode(bool enabled) async { @@ -70,4 +81,11 @@ class SettingsState extends ChangeNotifier { await prefs.setInt(_uploadModePrefsKey, mode.index); notifyListeners(); } + + Future setTileProvider(TileProviderType provider) async { + _tileProvider = provider; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_tileProviderPrefsKey, provider.index); + notifyListeners(); + } } \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index d8fa528..67ec9be 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -12,6 +12,7 @@ import '../services/simple_tile_service.dart'; import '../services/network_status.dart'; import '../models/osm_camera_node.dart'; import '../models/camera_profile.dart'; +import '../models/tile_provider.dart'; import 'debouncer.dart'; import 'camera_provider_with_cache.dart'; import 'map/camera_markers.dart'; @@ -169,6 +170,41 @@ class MapViewState extends State { return ids1.length == ids2.length && ids1.containsAll(ids2); } + /// Build tile layer based on selected tile provider + Widget _buildTileLayer(AppState appState) { + final providerConfig = TileProviders.getByType(appState.tileProvider); + if (providerConfig == null) { + // Fallback to OSM if somehow we have an invalid provider + return TileLayer( + urlTemplate: TileProviders.osmStreet.urlTemplate, + userAgentPackageName: 'com.stopflock.flock_map_app', + tileProvider: NetworkTileProvider( + httpClient: _tileHttpClient, + ), + ); + } + + // For OSM tiles, use our custom HTTP client for offline/online routing + if (providerConfig.type == TileProviderType.osmStreet) { + return TileLayer( + urlTemplate: providerConfig.urlTemplate, + userAgentPackageName: 'com.stopflock.flock_map_app', + tileProvider: NetworkTileProvider( + httpClient: _tileHttpClient, + ), + ); + } + + // For other providers, use standard HTTP client (no offline support yet) + return TileLayer( + urlTemplate: providerConfig.urlTemplate, + userAgentPackageName: 'com.stopflock.flock_map_app', + additionalOptions: { + 'attribution': providerConfig.attribution, + }, + ); + } + @override @@ -238,7 +274,7 @@ class MapViewState extends State { return Stack( children: [ FlutterMap( - key: ValueKey('map_offline_${appState.offlineMode}'), + key: ValueKey('map_offline_${appState.offlineMode}_provider_${appState.tileProvider.name}'), mapController: _controller, options: MapOptions( initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194), @@ -273,13 +309,7 @@ class MapViewState extends State { }, ), children: [ - TileLayer( - urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', - userAgentPackageName: 'com.stopflock.flock_map_app', - tileProvider: NetworkTileProvider( - httpClient: _tileHttpClient, - ), - ), + _buildTileLayer(appState), cameraLayers, // Built-in scale bar from flutter_map Scalebar(