generic providers

This commit is contained in:
stopflock
2025-08-24 14:08:15 -05:00
parent 024d3f09c3
commit 2d615128aa
5 changed files with 525 additions and 160 deletions
+25
View File
@@ -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<TileProvider> 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<void> setSelectedTileType(String tileTypeId) async {
await _settingsState.setSelectedTileType(tileTypeId);
}
/// Add or update a tile provider
Future<void> addOrUpdateTileProvider(TileProvider provider) async {
await _settingsState.addOrUpdateTileProvider(provider);
}
/// Delete a tile provider
Future<void> deleteTileProvider(String providerId) async {
await _settingsState.deleteTileProvider(providerId);
}
/// Legacy setter for backward compatibility
@Deprecated('Use setSelectedTileType instead')
Future<void> setTileProvider(TileProviderType provider) async {
await _settingsState.setTileProvider(provider);
}
+217 -94
View File
@@ -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<String, dynamic> toJson() => {
'id': id,
'name': name,
'urlTemplate': urlTemplate,
'attribution': attribution,
'previewTile': previewTile != null ? base64Encode(previewTile!) : null,
};
static TileType fromJson(Map<String, dynamic> 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<TileType> 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<TileType> get availableTileTypes {
return tileTypes.where((type) => !type.requiresApiKey || isUsable).toList();
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'apiKey': apiKey,
'tileTypes': tileTypes.map((type) => type.toJson()).toList(),
};
static TileProvider fromJson(Map<String, dynamic> 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<TileType>? 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<TileProvider> 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<TileProviderConfig> 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;
}
}
}
@@ -10,50 +10,84 @@ class TileProviderSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
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 = <TileType>[];
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<TileProviderType>(
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<String>(
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,
);
}),
],
);
}
+175 -11
View File
@@ -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<TileProvider> _tileProviders = [];
String _selectedTileTypeId = '';
// Getters
bool get offlineMode => _offlineMode;
int get maxCameras => _maxCameras;
UploadMode get uploadMode => _uploadMode;
TileProviderType get tileProvider => _tileProvider;
List<TileProvider> 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<TileType> get allAvailableTileTypes {
final types = <TileType>[];
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<void> 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<void> _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<void> _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<void> setOfflineMode(bool enabled) async {
_offlineMode = enabled;
final prefs = await SharedPreferences.getInstance();
@@ -82,10 +177,79 @@ class SettingsState extends ChangeNotifier {
notifyListeners();
}
Future<void> setTileProvider(TileProviderType provider) async {
_tileProvider = provider;
/// Select a tile type by ID
Future<void> 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<void> 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<void> 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<void> 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);
}
}
+36 -17
View File
@@ -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<MapView> {
/// 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 = <TileType>[];
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<MapView> {
);
}
// 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<MapView> {
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),