From 2d615128aa4dd769a1d17ed230b08abbbd3b9078 Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 24 Aug 2025 14:08:15 -0500 Subject: [PATCH] generic providers --- lib/app_state.dart | 25 ++ lib/models/tile_provider.dart | 311 ++++++++++++------ .../tile_provider_section.dart | 110 ++++--- lib/state/settings_state.dart | 186 ++++++++++- lib/widgets/map_view.dart | 53 ++- 5 files changed, 525 insertions(+), 160 deletions(-) diff --git a/lib/app_state.dart b/lib/app_state.dart index 0d59186..cea81ac 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -66,6 +66,14 @@ class AppState extends ChangeNotifier { bool get offlineMode => _settingsState.offlineMode; int get maxCameras => _settingsState.maxCameras; UploadMode get uploadMode => _settingsState.uploadMode; + + // Tile provider state + List get tileProviders => _settingsState.tileProviders; + TileType? get selectedTileType => _settingsState.selectedTileType; + TileProvider? get selectedTileProvider => _settingsState.selectedTileProvider; + + /// Legacy getter for backward compatibility + @Deprecated('Use selectedTileType instead') TileProviderType get tileProvider => _settingsState.tileProvider; // Upload queue state @@ -179,6 +187,23 @@ class AppState extends ChangeNotifier { _startUploader(); // Restart uploader with new mode } + /// Select a tile type by ID + Future setSelectedTileType(String tileTypeId) async { + await _settingsState.setSelectedTileType(tileTypeId); + } + + /// Add or update a tile provider + Future addOrUpdateTileProvider(TileProvider provider) async { + await _settingsState.addOrUpdateTileProvider(provider); + } + + /// Delete a tile provider + Future deleteTileProvider(String providerId) async { + await _settingsState.deleteTileProvider(providerId); + } + + /// Legacy setter for backward compatibility + @Deprecated('Use setSelectedTileType instead') Future setTileProvider(TileProviderType provider) async { await _settingsState.setTileProvider(provider); } diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 36a8dc7..688df97 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -1,100 +1,223 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// A specific tile type within a provider +class TileType { + final String id; + final String name; + final String urlTemplate; + final String attribution; + final Uint8List? previewTile; // Single tile image data for preview + + const TileType({ + required this.id, + required this.name, + required this.urlTemplate, + required this.attribution, + this.previewTile, + }); + + /// Create URL for a specific tile, replacing template variables + String getTileUrl(int z, int x, int y, {String? apiKey}) { + String url = urlTemplate + .replaceAll('{z}', z.toString()) + .replaceAll('{x}', x.toString()) + .replaceAll('{y}', y.toString()); + + if (apiKey != null && apiKey.isNotEmpty) { + url = url.replaceAll('{api_key}', apiKey); + } + + return url; + } + + /// Check if this tile type needs an API key + bool get requiresApiKey => urlTemplate.contains('{api_key}'); + + Map toJson() => { + 'id': id, + 'name': name, + 'urlTemplate': urlTemplate, + 'attribution': attribution, + 'previewTile': previewTile != null ? base64Encode(previewTile!) : null, + }; + + static TileType fromJson(Map json) => TileType( + id: json['id'], + name: json['name'], + urlTemplate: json['urlTemplate'], + attribution: json['attribution'], + previewTile: json['previewTile'] != null + ? base64Decode(json['previewTile']) + : null, + ); + + TileType copyWith({ + String? id, + String? name, + String? urlTemplate, + String? attribution, + Uint8List? previewTile, + }) => TileType( + id: id ?? this.id, + name: name ?? this.name, + urlTemplate: urlTemplate ?? this.urlTemplate, + attribution: attribution ?? this.attribution, + previewTile: previewTile ?? this.previewTile, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TileType && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// A tile provider containing multiple tile types +class TileProvider { + final String id; + final String name; + final String? apiKey; + final List tileTypes; + + const TileProvider({ + required this.id, + required this.name, + this.apiKey, + required this.tileTypes, + }); + + /// Check if this provider is usable (has API key if any tile types need it) + bool get isUsable { + final needsKey = tileTypes.any((type) => type.requiresApiKey); + return !needsKey || (apiKey != null && apiKey!.isNotEmpty); + } + + /// Get available tile types (those that don't need API key or have one) + List get availableTileTypes { + return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList(); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'apiKey': apiKey, + 'tileTypes': tileTypes.map((type) => type.toJson()).toList(), + }; + + static TileProvider fromJson(Map json) => TileProvider( + id: json['id'], + name: json['name'], + apiKey: json['apiKey'], + tileTypes: (json['tileTypes'] as List) + .map((typeJson) => TileType.fromJson(typeJson)) + .toList(), + ); + + TileProvider copyWith({ + String? id, + String? name, + String? apiKey, + List? tileTypes, + }) => TileProvider( + id: id ?? this.id, + name: name ?? this.name, + apiKey: apiKey ?? this.apiKey, + tileTypes: tileTypes ?? this.tileTypes, + ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is TileProvider && runtimeType == other.runtimeType && id == other.id; + + @override + int get hashCode => id.hashCode; +} + +/// Factory for creating default tile providers +class DefaultTileProviders { + /// Create the default set of tile providers + static List createDefaults() { + return [ + TileProvider( + id: 'openstreetmap', + name: 'OpenStreetMap', + tileTypes: [ + TileType( + id: 'osm_street', + name: 'Street Map', + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors', + ), + ], + ), + TileProvider( + id: 'google', + name: 'Google', + tileTypes: [ + TileType( + id: 'google_hybrid', + name: 'Satellite + Roads', + urlTemplate: 'https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', + attribution: '© Google', + ), + TileType( + id: 'google_satellite', + name: 'Satellite Only', + urlTemplate: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}', + attribution: '© Google', + ), + TileType( + id: 'google_roadmap', + name: 'Road Map', + urlTemplate: 'https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', + attribution: '© Google', + ), + ], + ), + TileProvider( + id: 'esri', + name: 'Esri', + tileTypes: [ + TileType( + id: 'esri_satellite', + name: 'Satellite Imagery', + urlTemplate: 'https://services.arcgisonline.com/ArcGis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}.png', + attribution: '© Esri © Maxar', + ), + ], + ), + TileProvider( + id: 'mapbox', + name: 'Mapbox', + tileTypes: [ + TileType( + id: 'mapbox_satellite', + name: 'Satellite', + urlTemplate: 'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}', + attribution: '© Mapbox © Maxar', + ), + TileType( + id: 'mapbox_streets', + name: 'Streets', + urlTemplate: 'https://api.mapbox.com/styles/v1/mapbox/streets-v12/tiles/{z}/{x}/{y}?access_token={api_key}', + attribution: '© Mapbox © OpenStreetMap', + ), + ], + ), + ]; + } +} + +/// Legacy enum for backward compatibility during transition +/// TODO: Remove once all references are updated +@Deprecated('Use TileProvider and TileType instead') 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: 'https://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_sections/tile_provider_section.dart b/lib/screens/settings_screen_sections/tile_provider_section.dart index 1720a4e..605bdb2 100644 --- a/lib/screens/settings_screen_sections/tile_provider_section.dart +++ b/lib/screens/settings_screen_sections/tile_provider_section.dart @@ -10,50 +10,84 @@ class TileProviderSection extends StatelessWidget { @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 - ]; + final selectedTileType = appState.selectedTileType; + final allTileTypes = []; + for (final provider in appState.tileProviders) { + allTileTypes.addAll(provider.availableTileTypes); + } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Map Type', - style: Theme.of(context).textTheme.titleMedium, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Map Type', + style: Theme.of(context).textTheme.titleMedium, + ), + TextButton( + onPressed: () { + // TODO: Navigate to provider management screen + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Provider management coming soon!')), + ); + }, + child: const Text('Manage Providers'), + ), + ], ), 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); - }, - ); - }), + if (allTileTypes.isEmpty) + const Text('No tile providers available') + else + ...allTileTypes.map((tileType) { + final provider = appState.tileProviders + .firstWhere((p) => p.tileTypes.contains(tileType)); + final isSelected = selectedTileType?.id == tileType.id; + final isUsable = !tileType.requiresApiKey || provider.isUsable; + + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Radio( + value: tileType.id, + groupValue: selectedTileType?.id, + onChanged: isUsable ? (String? value) { + if (value != null) { + appState.setSelectedTileType(value); + } + } : null, + ), + title: Text( + '${provider.name} - ${tileType.name}', + style: TextStyle( + color: isUsable ? null : Theme.of(context).disabledColor, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tileType.attribution, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: isUsable ? null : Theme.of(context).disabledColor, + ), + ), + if (!isUsable) + Text( + 'Requires API key', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.error, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + onTap: isUsable ? () { + appState.setSelectedTileType(tileType.id); + } : null, + ); + }), ], ); } diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 3db04ca..38d067d 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -1,5 +1,7 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:collection/collection.dart'; import '../models/tile_provider.dart'; @@ -10,19 +12,74 @@ 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 _tileProvidersPrefsKey = 'tile_providers'; + static const String _selectedTileTypePrefsKey = 'selected_tile_type'; static const String _legacyTestModePrefsKey = 'test_mode'; bool _offlineMode = false; int _maxCameras = 250; UploadMode _uploadMode = UploadMode.simulate; - TileProviderType _tileProvider = TileProviderType.osmStreet; + List _tileProviders = []; + String _selectedTileTypeId = ''; // Getters bool get offlineMode => _offlineMode; int get maxCameras => _maxCameras; UploadMode get uploadMode => _uploadMode; - TileProviderType get tileProvider => _tileProvider; + List get tileProviders => List.unmodifiable(_tileProviders); + String get selectedTileTypeId => _selectedTileTypeId; + + /// Get the currently selected tile type + TileType? get selectedTileType { + for (final provider in _tileProviders) { + for (final tileType in provider.tileTypes) { + if (tileType.id == _selectedTileTypeId) { + return tileType; + } + } + } + return null; + } + + /// Get the provider that contains the selected tile type + TileProvider? get selectedTileProvider { + for (final provider in _tileProviders) { + if (provider.tileTypes.any((type) => type.id == _selectedTileTypeId)) { + return provider; + } + } + return null; + } + + /// Get all available tile types from all providers + List get allAvailableTileTypes { + final types = []; + for (final provider in _tileProviders) { + types.addAll(provider.availableTileTypes); + } + return types; + } + + /// Legacy getter for backward compatibility + @Deprecated('Use selectedTileType instead') + TileProviderType get tileProvider { + // Map current selection to legacy enum for compatibility + final selected = selectedTileType; + if (selected == null) return TileProviderType.osmStreet; + + switch (selected.id) { + case 'osm_street': + return TileProviderType.osmStreet; + case 'google_hybrid': + return TileProviderType.googleHybrid; + case 'esri_satellite': + return TileProviderType.arcgisSatellite; + case 'mapbox_satellite': + return TileProviderType.mapboxSatellite; + default: + return TileProviderType.osmStreet; + } + } // Initialize settings from preferences Future init() async { @@ -50,15 +107,53 @@ class SettingsState extends ChangeNotifier { 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]; + // Load tile providers (default to built-in providers if none saved) + await _loadTileProviders(prefs); + + // Load selected tile type (default to first available) + _selectedTileTypeId = prefs.getString(_selectedTileTypePrefsKey) ?? ''; + if (_selectedTileTypeId.isEmpty || selectedTileType == null) { + final firstType = allAvailableTileTypes.firstOrNull; + if (firstType != null) { + _selectedTileTypeId = firstType.id; + await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId); } } } + Future _loadTileProviders(SharedPreferences prefs) async { + if (prefs.containsKey(_tileProvidersPrefsKey)) { + try { + final providersJson = prefs.getString(_tileProvidersPrefsKey); + if (providersJson != null) { + final providersList = jsonDecode(providersJson) as List; + _tileProviders = providersList + .map((json) => TileProvider.fromJson(json)) + .toList(); + } + } catch (e) { + debugPrint('Error loading tile providers: $e'); + // Fall back to defaults on error + _tileProviders = DefaultTileProviders.createDefaults(); + } + } else { + // First time - use defaults + _tileProviders = DefaultTileProviders.createDefaults(); + await _saveTileProviders(prefs); + } + } + + Future _saveTileProviders(SharedPreferences prefs) async { + try { + final providersJson = jsonEncode( + _tileProviders.map((provider) => provider.toJson()).toList(), + ); + await prefs.setString(_tileProvidersPrefsKey, providersJson); + } catch (e) { + debugPrint('Error saving tile providers: $e'); + } + } + Future setOfflineMode(bool enabled) async { _offlineMode = enabled; final prefs = await SharedPreferences.getInstance(); @@ -82,10 +177,79 @@ class SettingsState extends ChangeNotifier { notifyListeners(); } - Future setTileProvider(TileProviderType provider) async { - _tileProvider = provider; + /// Select a tile type by ID + Future setSelectedTileType(String tileTypeId) async { + if (_selectedTileTypeId != tileTypeId) { + _selectedTileTypeId = tileTypeId; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_selectedTileTypePrefsKey, tileTypeId); + notifyListeners(); + } + } + + /// Add or update a tile provider + Future addOrUpdateTileProvider(TileProvider provider) async { + final existingIndex = _tileProviders.indexWhere((p) => p.id == provider.id); + if (existingIndex >= 0) { + _tileProviders[existingIndex] = provider; + } else { + _tileProviders.add(provider); + } + final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_tileProviderPrefsKey, provider.index); + await _saveTileProviders(prefs); notifyListeners(); } + + /// Delete a tile provider + Future deleteTileProvider(String providerId) async { + // Don't allow deleting all providers + if (_tileProviders.length <= 1) return; + + final providerToDelete = _tileProviders.firstWhereOrNull((p) => p.id == providerId); + if (providerToDelete == null) return; + + // If selected tile type belongs to this provider, switch to another + if (providerToDelete.tileTypes.any((type) => type.id == _selectedTileTypeId)) { + // Find first available tile type from remaining providers + final remainingProviders = _tileProviders.where((p) => p.id != providerId).toList(); + final firstAvailable = remainingProviders + .expand((p) => p.availableTileTypes) + .firstOrNull; + + if (firstAvailable != null) { + _selectedTileTypeId = firstAvailable.id; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_selectedTileTypePrefsKey, _selectedTileTypeId); + } + } + + _tileProviders.removeWhere((p) => p.id == providerId); + final prefs = await SharedPreferences.getInstance(); + await _saveTileProviders(prefs); + notifyListeners(); + } + + /// Legacy setter for backward compatibility + @Deprecated('Use setSelectedTileType instead') + Future setTileProvider(TileProviderType provider) async { + // Map legacy enum to new tile type ID + String tileTypeId; + switch (provider) { + case TileProviderType.osmStreet: + tileTypeId = 'osm_street'; + break; + case TileProviderType.googleHybrid: + tileTypeId = 'google_hybrid'; + break; + case TileProviderType.arcgisSatellite: + tileTypeId = 'esri_satellite'; + break; + case TileProviderType.mapboxSatellite: + tileTypeId = 'mapbox_satellite'; + break; + } + + await setSelectedTileType(tileTypeId); + } } \ No newline at end of file diff --git a/lib/widgets/map_view.dart b/lib/widgets/map_view.dart index 67ec9be..09cc2fc 100644 --- a/lib/widgets/map_view.dart +++ b/lib/widgets/map_view.dart @@ -5,6 +5,7 @@ import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:provider/provider.dart'; import 'package:http/http.dart' as http; +import 'package:collection/collection.dart'; import '../app_state.dart'; import '../services/offline_area_service.dart'; @@ -172,11 +173,30 @@ class MapViewState extends State { /// 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 + final selectedTileType = appState.selectedTileType; + final selectedProvider = appState.selectedTileProvider; + + // Fallback to first available tile type if none selected + if (selectedTileType == null || selectedProvider == null) { + final allTypes = []; + for (final provider in appState.tileProviders) { + allTypes.addAll(provider.availableTileTypes); + } + + final fallback = allTypes.firstOrNull; + if (fallback != null) { + return TileLayer( + urlTemplate: fallback.urlTemplate, + userAgentPackageName: 'com.stopflock.flock_map_app', + tileProvider: NetworkTileProvider( + httpClient: _tileHttpClient, + ), + ); + } + + // Ultimate fallback - hardcoded OSM return TileLayer( - urlTemplate: TileProviders.osmStreet.urlTemplate, + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.stopflock.flock_map_app', tileProvider: NetworkTileProvider( httpClient: _tileHttpClient, @@ -184,23 +204,22 @@ class MapViewState extends State { ); } - // 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, - ), - ); + // Get the URL template with API key if needed + String urlTemplate = selectedTileType.urlTemplate; + if (selectedTileType.requiresApiKey && selectedProvider.apiKey != null) { + urlTemplate = urlTemplate.replaceAll('{api_key}', selectedProvider.apiKey!); } - // For other providers, use standard HTTP client (no offline support yet) + // For now, use our custom HTTP client for all tile requests + // This will enable offline support for all providers return TileLayer( - urlTemplate: providerConfig.urlTemplate, + urlTemplate: urlTemplate, userAgentPackageName: 'com.stopflock.flock_map_app', + tileProvider: NetworkTileProvider( + httpClient: _tileHttpClient, + ), additionalOptions: { - 'attribution': providerConfig.attribution, + 'attribution': selectedTileType.attribution, }, ); } @@ -274,7 +293,7 @@ class MapViewState extends State { return Stack( children: [ FlutterMap( - key: ValueKey('map_offline_${appState.offlineMode}_provider_${appState.tileProvider.name}'), + key: ValueKey('map_offline_${appState.offlineMode}_tiletype_${appState.selectedTileType?.id ?? 'none'}'), mapController: _controller, options: MapOptions( initialCenter: _currentLatLng ?? LatLng(37.7749, -122.4194),