cusstom providers settings

This commit is contained in:
stopflock
2025-08-24 14:18:04 -05:00
parent 2d615128aa
commit 813f4f69ea
6 changed files with 560 additions and 64 deletions

View File

@@ -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<void> setTileProvider(TileProviderType provider) async {
await _settingsState.setTileProvider(provider);
}
// ---------- Queue Methods ----------
void clearQueue() {

View File

@@ -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,
}

View File

@@ -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'),

View File

@@ -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<TileProviderEditorScreen> createState() => _TileProviderEditorScreenState();
}
class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _apiKeyController;
late List<TileType> _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)
: <TileType>[];
}
@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<AppState>().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<FormState>();
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<void> _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();
}
}

View File

@@ -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<AppState>();
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<String>(
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<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: const Text('Delete'),
),
],
),
);
}
}

View File

@@ -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<void> init() async {
@@ -230,26 +211,5 @@ class SettingsState extends ChangeNotifier {
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);
}
}