From 813f4f69eafa4558f9e7d975f8d1c0dffc039c7c Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 24 Aug 2025 14:18:04 -0500 Subject: [PATCH] cusstom providers settings --- lib/app_state.dart | 13 +- lib/models/tile_provider.dart | 9 - .../tile_provider_section.dart | 8 +- lib/screens/tile_provider_editor_screen.dart | 390 ++++++++++++++++++ .../tile_provider_management_screen.dart | 160 +++++++ lib/state/settings_state.dart | 44 +- 6 files changed, 560 insertions(+), 64 deletions(-) create mode 100644 lib/screens/tile_provider_editor_screen.dart create mode 100644 lib/screens/tile_provider_management_screen.dart diff --git a/lib/app_state.dart b/lib/app_state.dart index cea81ac..aa72884 100644 --- a/lib/app_state.dart +++ b/lib/app_state.dart @@ -11,10 +11,9 @@ import 'state/session_state.dart'; import 'state/settings_state.dart'; import 'state/upload_queue_state.dart'; -// Re-export types for backward compatibility +// Re-export types 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 { @@ -72,9 +71,7 @@ class AppState extends ChangeNotifier { 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 int get pendingCount => _uploadQueueState.pendingCount; @@ -202,11 +199,7 @@ class AppState extends ChangeNotifier { await _settingsState.deleteTileProvider(providerId); } - /// Legacy setter for backward compatibility - @Deprecated('Use setSelectedTileType instead') - Future setTileProvider(TileProviderType provider) async { - await _settingsState.setTileProvider(provider); - } + // ---------- Queue Methods ---------- void clearQueue() { diff --git a/lib/models/tile_provider.dart b/lib/models/tile_provider.dart index 688df97..72ccc49 100644 --- a/lib/models/tile_provider.dart +++ b/lib/models/tile_provider.dart @@ -212,12 +212,3 @@ class DefaultTileProviders { } } -/// 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, -} \ 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 605bdb2..8d5dab7 100644 --- a/lib/screens/settings_screen_sections/tile_provider_section.dart +++ b/lib/screens/settings_screen_sections/tile_provider_section.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import '../../app_state.dart'; import '../../models/tile_provider.dart'; +import '../tile_provider_management_screen.dart'; class TileProviderSection extends StatelessWidget { const TileProviderSection({super.key}); @@ -28,9 +29,10 @@ class TileProviderSection extends StatelessWidget { ), TextButton( onPressed: () { - // TODO: Navigate to provider management screen - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Provider management coming soon!')), + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const TileProviderManagementScreen(), + ), ); }, child: const Text('Manage Providers'), diff --git a/lib/screens/tile_provider_editor_screen.dart b/lib/screens/tile_provider_editor_screen.dart new file mode 100644 index 0000000..0525301 --- /dev/null +++ b/lib/screens/tile_provider_editor_screen.dart @@ -0,0 +1,390 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:http/http.dart' as http; + +import '../app_state.dart'; +import '../models/tile_provider.dart'; + +class TileProviderEditorScreen extends StatefulWidget { + final TileProvider? provider; // null for adding new provider + + const TileProviderEditorScreen({super.key, this.provider}); + + @override + State createState() => _TileProviderEditorScreenState(); +} + +class _TileProviderEditorScreenState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _apiKeyController; + late List _tileTypes; + + bool get _isEditing => widget.provider != null; + + @override + void initState() { + super.initState(); + final provider = widget.provider; + _nameController = TextEditingController(text: provider?.name ?? ''); + _apiKeyController = TextEditingController(text: provider?.apiKey ?? ''); + _tileTypes = provider != null + ? List.from(provider.tileTypes) + : []; + } + + @override + void dispose() { + _nameController.dispose(); + _apiKeyController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? 'Edit Provider' : 'Add Provider'), + actions: [ + TextButton( + onPressed: _saveProvider, + child: const Text('Save'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Provider Name', + hintText: 'e.g., Custom Maps Inc.', + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Provider name is required'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _apiKeyController, + decoration: const InputDecoration( + labelText: 'API Key (Optional)', + hintText: 'Enter API key if required by tile types', + ), + obscureText: true, + ), + const SizedBox(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Tile Types', + style: Theme.of(context).textTheme.headlineSmall, + ), + TextButton.icon( + onPressed: _addTileType, + icon: const Icon(Icons.add), + label: const Text('Add Type'), + ), + ], + ), + const SizedBox(height: 16), + if (_tileTypes.isEmpty) + const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Text('No tile types configured'), + ), + ) + else + ..._tileTypes.asMap().entries.map((entry) { + final index = entry.key; + final tileType = entry.value; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(tileType.name), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(tileType.urlTemplate), + Text( + tileType.attribution, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () => _editTileType(index), + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: _tileTypes.length > 1 + ? () => _deleteTileType(index) + : null, // Can't delete last tile type + ), + ], + ), + onTap: () => _editTileType(index), + ), + ); + }), + ], + ), + ), + ); + } + + void _addTileType() { + _showTileTypeDialog(); + } + + void _editTileType(int index) { + _showTileTypeDialog(tileType: _tileTypes[index], index: index); + } + + void _deleteTileType(int index) { + if (_tileTypes.length <= 1) return; + + setState(() { + _tileTypes.removeAt(index); + }); + } + + void _showTileTypeDialog({TileType? tileType, int? index}) { + showDialog( + context: context, + builder: (context) => _TileTypeDialog( + tileType: tileType, + onSave: (newTileType) { + setState(() { + if (index != null) { + _tileTypes[index] = newTileType; + } else { + _tileTypes.add(newTileType); + } + }); + }, + ), + ); + } + + void _saveProvider() { + if (!_formKey.currentState!.validate()) return; + if (_tileTypes.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('At least one tile type is required')), + ); + return; + } + + final providerId = widget.provider?.id ?? DateTime.now().millisecondsSinceEpoch.toString(); + final provider = TileProvider( + id: providerId, + name: _nameController.text.trim(), + apiKey: _apiKeyController.text.trim().isEmpty ? null : _apiKeyController.text.trim(), + tileTypes: _tileTypes, + ); + + context.read().addOrUpdateTileProvider(provider); + Navigator.of(context).pop(); + } +} + +class _TileTypeDialog extends StatefulWidget { + final TileType? tileType; + final Function(TileType) onSave; + + const _TileTypeDialog({ + required this.onSave, + this.tileType, + }); + + @override + State<_TileTypeDialog> createState() => _TileTypeDialogState(); +} + +class _TileTypeDialogState extends State<_TileTypeDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _urlController; + late final TextEditingController _attributionController; + Uint8List? _previewTile; + bool _isLoadingPreview = false; + + @override + void initState() { + super.initState(); + final tileType = widget.tileType; + _nameController = TextEditingController(text: tileType?.name ?? ''); + _urlController = TextEditingController(text: tileType?.urlTemplate ?? ''); + _attributionController = TextEditingController(text: tileType?.attribution ?? ''); + _previewTile = tileType?.previewTile; + } + + @override + void dispose() { + _nameController.dispose(); + _urlController.dispose(); + _attributionController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.tileType != null ? 'Edit Tile Type' : 'Add Tile Type'), + content: SizedBox( + width: double.maxFinite, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + hintText: 'e.g., Satellite', + ), + validator: (value) => value?.trim().isEmpty == true ? 'Name is required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _urlController, + decoration: const InputDecoration( + labelText: 'URL Template', + hintText: 'https://example.com/{z}/{x}/{y}.png', + ), + validator: (value) { + if (value?.trim().isEmpty == true) return 'URL template is required'; + if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) { + return 'URL must contain {z}, {x}, and {y} placeholders'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _attributionController, + decoration: const InputDecoration( + labelText: 'Attribution', + hintText: '© Map Provider', + ), + validator: (value) => value?.trim().isEmpty == true ? 'Attribution is required' : null, + ), + const SizedBox(height: 16), + Row( + children: [ + TextButton.icon( + onPressed: _isLoadingPreview ? null : _fetchPreviewTile, + icon: _isLoadingPreview + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.preview), + label: const Text('Fetch Preview'), + ), + const SizedBox(width: 8), + if (_previewTile != null) + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + ), + child: Image.memory(_previewTile!, fit: BoxFit.cover), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: _saveTileType, + child: const Text('Save'), + ), + ], + ); + } + + Future _fetchPreviewTile() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoadingPreview = true; + }); + + try { + // Use a sample tile (zoom 10, somewhere in the world) + final url = _urlController.text + .replaceAll('{z}', '10') + .replaceAll('{x}', '512') + .replaceAll('{y}', '384'); + + final response = await http.get(Uri.parse(url)); + + if (response.statusCode == 200) { + setState(() { + _previewTile = response.bodyBytes; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Preview tile loaded successfully')), + ); + } + } else { + throw Exception('HTTP ${response.statusCode}'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to fetch preview: $e')), + ); + } + } finally { + setState(() { + _isLoadingPreview = false; + }); + } + } + + void _saveTileType() { + if (!_formKey.currentState!.validate()) return; + + final tileTypeId = widget.tileType?.id ?? + '${_nameController.text.toLowerCase().replaceAll(' ', '_')}_${DateTime.now().millisecondsSinceEpoch}'; + + final tileType = TileType( + id: tileTypeId, + name: _nameController.text.trim(), + urlTemplate: _urlController.text.trim(), + attribution: _attributionController.text.trim(), + previewTile: _previewTile, + ); + + widget.onSave(tileType); + Navigator.of(context).pop(); + } +} \ No newline at end of file diff --git a/lib/screens/tile_provider_management_screen.dart b/lib/screens/tile_provider_management_screen.dart new file mode 100644 index 0000000..e44a482 --- /dev/null +++ b/lib/screens/tile_provider_management_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../app_state.dart'; +import '../models/tile_provider.dart'; +import 'tile_provider_editor_screen.dart'; + +class TileProviderManagementScreen extends StatelessWidget { + const TileProviderManagementScreen({super.key}); + + @override + Widget build(BuildContext context) { + final appState = context.watch(); + final providers = appState.tileProviders; + + return Scaffold( + appBar: AppBar( + title: const Text('Tile Providers'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _addProvider(context), + ), + ], + ), + body: providers.isEmpty + ? const Center( + child: Text('No tile providers configured'), + ) + : ListView.builder( + itemCount: providers.length, + itemBuilder: (context, index) { + final provider = providers[index]; + final isSelected = appState.selectedTileProvider?.id == provider.id; + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: ListTile( + title: Text( + provider.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : null, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('${provider.tileTypes.length} tile types'), + if (provider.apiKey?.isNotEmpty == true) + const Text( + 'API Key configured', + style: TextStyle( + fontStyle: FontStyle.italic, + fontSize: 12, + ), + ), + if (!provider.isUsable) + Text( + 'Needs API key', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ], + ), + leading: CircleAvatar( + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceVariant, + child: Icon( + Icons.map, + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + trailing: providers.length > 1 + ? PopupMenuButton( + onSelected: (action) { + switch (action) { + case 'edit': + _editProvider(context, provider); + break; + case 'delete': + _deleteProvider(context, provider); + break; + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit), + SizedBox(width: 8), + Text('Edit'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete), + SizedBox(width: 8), + Text('Delete'), + ], + ), + ), + ], + ) + : const Icon(Icons.lock, size: 16), // Can't delete last provider + onTap: () => _editProvider(context, provider), + ), + ); + }, + ), + ); + } + + void _addProvider(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const TileProviderEditorScreen(), + ), + ); + } + + void _editProvider(BuildContext context, TileProvider provider) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => TileProviderEditorScreen(provider: provider), + ), + ); + } + + void _deleteProvider(BuildContext context, TileProvider provider) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Provider'), + content: Text('Are you sure you want to delete "${provider.name}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + context.read().deleteTileProvider(provider.id); + Navigator.of(context).pop(); + }, + child: const Text('Delete'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/state/settings_state.dart b/lib/state/settings_state.dart index 38d067d..61e9d1f 100644 --- a/lib/state/settings_state.dart +++ b/lib/state/settings_state.dart @@ -60,26 +60,7 @@ class SettingsState extends ChangeNotifier { 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 { @@ -230,26 +211,5 @@ class SettingsState extends ChangeNotifier { 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