more localization

This commit is contained in:
stopflock
2025-08-31 14:01:03 -05:00
parent 3150297bb0
commit f05a31f40b
21 changed files with 1961 additions and 1182 deletions
@@ -1,68 +1,75 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
class AuthSection extends StatelessWidget {
const AuthSection({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
children: [
ListTile(
leading: Icon(
appState.isLoggedIn ? Icons.person : Icons.login,
color: appState.isLoggedIn ? Colors.green : null,
),
title: Text(appState.isLoggedIn
? 'Logged in as ${appState.username}'
: 'Log in to OpenStreetMap'),
subtitle: appState.isLoggedIn
? const Text('Tap to logout')
: const Text('Required to submit camera data'),
onTap: () async {
if (appState.isLoggedIn) {
await appState.logout();
} else {
await appState.forceLogin();
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(appState.isLoggedIn
? 'Logged in as ${appState.username}'
: 'Logged out'),
backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey,
),
);
}
},
),
if (appState.isLoggedIn)
ListTile(
leading: const Icon(Icons.wifi_protected_setup),
title: const Text('Test Connection'),
subtitle: const Text('Verify OSM credentials are working'),
onTap: () async {
final isValid = await appState.validateToken();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isValid
? 'Connection OK - credentials are valid'
: 'Connection failed - please re-login'),
backgroundColor: isValid ? Colors.green : Colors.red,
),
);
}
if (!isValid) {
await appState.logout();
}
},
),
],
return Column(
children: [
ListTile(
leading: Icon(
appState.isLoggedIn ? Icons.person : Icons.login,
color: appState.isLoggedIn ? Colors.green : null,
),
title: Text(appState.isLoggedIn
? locService.t('auth.loggedInAs', params: [appState.username])
: locService.t('auth.loginToOSM')),
subtitle: appState.isLoggedIn
? Text(locService.t('auth.tapToLogout'))
: Text(locService.t('auth.requiredToSubmit')),
onTap: () async {
if (appState.isLoggedIn) {
await appState.logout();
} else {
await appState.forceLogin();
}
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(appState.isLoggedIn
? locService.t('auth.loggedInAs', params: [appState.username])
: locService.t('auth.loggedOut')),
backgroundColor: appState.isLoggedIn ? Colors.green : Colors.grey,
),
);
}
},
),
if (appState.isLoggedIn)
ListTile(
leading: const Icon(Icons.wifi_protected_setup),
title: Text(locService.t('auth.testConnection')),
subtitle: Text(locService.t('auth.testConnectionSubtitle')),
onTap: () async {
final isValid = await appState.validateToken();
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(isValid
? locService.t('auth.connectionOK')
: locService.t('auth.connectionFailed')),
backgroundColor: isValid ? Colors.green : Colors.red,
),
);
}
if (!isValid) {
await appState.logout();
}
},
),
],
);
},
);
}
}
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
class MaxNodesSection extends StatefulWidget {
const MaxNodesSection({super.key});
@@ -27,54 +28,61 @@ class _MaxNodesSectionState extends State<MaxNodesSection> {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final current = appState.maxCameras;
final showWarning = current > 1000;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.filter_alt),
title: const Text('Max nodes fetched/drawn'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Set an upper limit for the number of nodes on the map (default: 250).'),
if (showWarning)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: const [
Icon(Icons.warning, color: Colors.orange, size: 18),
SizedBox(width: 6),
Expanded(child: Text(
'You probably don\'t want to do that unless you are absolutely sure you have a good reason for it.',
style: TextStyle(color: Colors.orange),
)),
],
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
final current = appState.maxCameras;
final showWarning = current > 1000;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.filter_alt),
title: Text(locService.t('settings.maxNodes')),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('settings.maxNodesSubtitle')),
if (showWarning)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
children: [
const Icon(Icons.warning, color: Colors.orange, size: 18),
const SizedBox(width: 6),
Expanded(child: Text(
locService.t('settings.maxNodesWarning'),
style: const TextStyle(color: Colors.orange),
)),
],
),
),
],
),
trailing: SizedBox(
width: 80,
child: TextFormField(
controller: _controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: OutlineInputBorder(),
),
onFieldSubmitted: (value) {
final n = int.tryParse(value) ?? 10;
appState.maxCameras = n;
_controller.text = appState.maxCameras.toString();
},
),
],
),
trailing: SizedBox(
width: 80,
child: TextFormField(
controller: _controller,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(vertical: 6, horizontal: 8),
border: OutlineInputBorder(),
),
onFieldSubmitted: (value) {
final n = int.tryParse(value) ?? 10;
appState.maxCameras = n;
_controller.text = appState.maxCameras.toString();
},
),
),
),
],
],
);
},
);
}
}
@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/offline_area_service.dart';
import '../../services/localization_service.dart';
class OfflineModeSection extends StatelessWidget {
const OfflineModeSection({super.key});
Future<void> _handleOfflineModeChange(BuildContext context, AppState appState, bool value) async {
final locService = LocalizationService.instance;
// If enabling offline mode, check for active downloads
if (value && !appState.offlineMode) {
final offlineService = OfflineAreaService();
@@ -16,19 +19,17 @@ class OfflineModeSection extends StatelessWidget {
context: context,
builder: (context) => AlertDialog(
title: Row(
children: const [
Icon(Icons.warning, color: Colors.orange),
SizedBox(width: 8),
Text('Active Downloads'),
children: [
const Icon(Icons.warning, color: Colors.orange),
const SizedBox(width: 8),
Text(locService.t('settings.offlineModeWarningTitle')),
],
),
content: const Text(
'Enabling offline mode will cancel any active area downloads. Do you want to continue?',
),
content: Text(locService.t('settings.offlineModeWarningMessage')),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
child: Text(locService.cancel),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
@@ -36,7 +37,7 @@ class OfflineModeSection extends StatelessWidget {
backgroundColor: Colors.orange,
foregroundColor: Colors.white,
),
child: const Text('Enable Offline Mode'),
child: Text(locService.t('settings.enableOfflineMode')),
),
],
),
@@ -54,15 +55,22 @@ class OfflineModeSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return ListTile(
leading: const Icon(Icons.wifi_off),
title: const Text('Offline Mode'),
subtitle: const Text('Disable all network requests except for local/offline areas.'),
trailing: Switch(
value: appState.offlineMode,
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return ListTile(
leading: const Icon(Icons.wifi_off),
title: Text(locService.t('settings.offlineMode')),
subtitle: Text(locService.t('settings.offlineModeSubtitle')),
trailing: Switch(
value: appState.offlineMode,
onChanged: (value) => _handleOfflineModeChange(context, appState, value),
),
);
},
);
}
}
@@ -3,6 +3,7 @@ import 'package:uuid/uuid.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/node_profile.dart';
import '../../services/localization_service.dart';
import '../profile_editor.dart';
class ProfileListSection extends StatelessWidget {
@@ -10,129 +11,136 @@ class ProfileListSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
return Column(
children: [
const Text('Node Profiles', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(locService.t('profiles.nodeProfiles'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
TextButton.icon(
onPressed: () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(
profile: NodeProfile(
id: const Uuid().v4(),
name: '',
tags: const {},
),
),
),
),
icon: const Icon(Icons.add),
label: Text(locService.t('profiles.newProfile')),
),
],
),
...appState.profiles.map(
(p) => ListTile(
leading: Checkbox(
value: appState.isEnabled(p),
onChanged: (v) => appState.toggleProfile(p, v ?? false),
),
title: Text(p.name),
subtitle: Text(p.builtin ? locService.t('profiles.builtIn') : locService.t('profiles.custom')),
trailing: !p.editable
? PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'view',
child: Row(
children: [
const Icon(Icons.visibility),
const SizedBox(width: 8),
Text(locService.t('profiles.view')),
],
),
),
],
onSelected: (value) {
if (value == 'view') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
}
},
)
: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete, color: Colors.red),
const SizedBox(width: 8),
Text(locService.t('profiles.deleteProfile'), style: const TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
),
icon: const Icon(Icons.add),
label: const Text('New Profile'),
),
],
),
...appState.profiles.map(
(p) => ListTile(
leading: Checkbox(
value: appState.isEnabled(p),
onChanged: (v) => appState.toggleProfile(p, v ?? false),
),
title: Text(p.name),
subtitle: Text(p.builtin ? 'Built-in' : 'Custom'),
trailing: !p.editable
? PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'view',
child: const Row(
children: [
Icon(Icons.visibility),
SizedBox(width: 8),
Text('View'),
],
),
),
],
onSelected: (value) {
if (value == 'view') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
}
},
)
: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
value: 'edit',
child: const Row(
children: [
Icon(Icons.edit),
SizedBox(width: 8),
Text('Edit'),
],
),
),
PopupMenuItem(
value: 'delete',
child: const Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete', style: TextStyle(color: Colors.red)),
],
),
),
],
onSelected: (value) {
if (value == 'edit') {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ProfileEditor(profile: p),
),
);
} else if (value == 'delete') {
_showDeleteProfileDialog(context, p);
}
},
),
),
),
],
);
},
);
}
void _showDeleteProfileDialog(BuildContext context, NodeProfile profile) {
final locService = LocalizationService.instance;
final appState = context.read<AppState>();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Profile'),
content: Text('Are you sure you want to delete "${profile.name}"?'),
title: Text(locService.t('profiles.deleteProfile')),
content: Text(locService.t('profiles.deleteProfileConfirm', params: [profile.name])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
appState.deleteProfile(profile);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Profile deleted')),
SnackBar(content: Text(locService.t('profiles.profileDeleted'))),
);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
child: Text(locService.t('profiles.deleteProfile')),
),
],
),
@@ -1,19 +1,21 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
import '../../state/settings_state.dart';
class QueueSection extends StatelessWidget {
const QueueSection({super.key});
String _getUploadModeDisplayName(UploadMode mode) {
final locService = LocalizationService.instance;
switch (mode) {
case UploadMode.production:
return 'Production';
return locService.t('uploadMode.production');
case UploadMode.sandbox:
return 'Sandbox';
return locService.t('uploadMode.sandbox');
case UploadMode.simulate:
return 'Simulate';
return locService.t('uploadMode.simulate');
}
}
@@ -30,67 +32,75 @@ class QueueSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Column(
children: [
ListTile(
leading: const Icon(Icons.queue),
title: Text('Pending uploads: ${appState.pendingCount}'),
subtitle: appState.uploadMode == UploadMode.simulate
? const Text('Simulate mode enabled uploads simulated')
: appState.uploadMode == UploadMode.sandbox
? const Text('Sandbox mode uploads go to OSM Sandbox')
: const Text('Tap to view queue'),
onTap: appState.pendingCount > 0
? () => _showQueueDialog(context)
: null,
),
if (appState.pendingCount > 0)
ListTile(
leading: const Icon(Icons.clear_all),
title: const Text('Clear Upload Queue'),
subtitle: Text('Remove all ${appState.pendingCount} pending uploads'),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Queue'),
content: Text('Remove all ${appState.pendingCount} pending uploads?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
children: [
ListTile(
leading: const Icon(Icons.queue),
title: Text(locService.t('queue.pendingUploads', params: [appState.pendingCount.toString()])),
subtitle: appState.uploadMode == UploadMode.simulate
? Text(locService.t('queue.simulateModeEnabled'))
: appState.uploadMode == UploadMode.sandbox
? Text(locService.t('queue.sandboxMode'))
: Text(locService.t('queue.tapToViewQueue')),
onTap: appState.pendingCount > 0
? () => _showQueueDialog(context)
: null,
),
if (appState.pendingCount > 0)
ListTile(
leading: const Icon(Icons.clear_all),
title: Text(locService.t('queue.clearUploadQueue')),
subtitle: Text(locService.t('queue.removeAllPending', params: [appState.pendingCount.toString()])),
onTap: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('queue.clearQueueTitle')),
content: Text(locService.t('queue.clearQueueConfirm', params: [appState.pendingCount.toString()])),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(locService.cancel),
),
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('queue.queueCleared'))),
);
},
child: Text(locService.t('actions.clear')),
),
],
),
TextButton(
onPressed: () {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
);
},
child: const Text('Clear'),
),
],
),
);
},
),
],
);
},
),
],
);
},
);
}
void _showQueueDialog(BuildContext context) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => Consumer<AppState>(
builder: (context, appState, child) => AlertDialog(
title: Text('Upload Queue (${appState.pendingCount} items)'),
title: Text(locService.t('queue.uploadQueueTitle', params: [appState.pendingCount.toString()])),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: appState.pendingUploads.isEmpty
? const Center(child: Text('Queue is empty'))
? Center(child: Text(locService.t('queue.queueIsEmpty')))
: ListView.builder(
itemCount: appState.pendingUploads.length,
itemBuilder: (context, index) {
@@ -102,16 +112,16 @@ class QueueSection extends StatelessWidget {
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text('Camera ${index + 1}'
'${upload.error ? " (Error)" : ""}'
'${upload.completing ? " (Completing...)" : ""}'),
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
(upload.error ? locService.t('queue.error') : "") +
(upload.completing ? locService.t('queue.completing') : "")),
subtitle: Text(
'Dest: ${_getUploadModeDisplayName(upload.uploadMode)}\n'
'Lat: ${upload.coord.latitude.toStringAsFixed(6)}\n'
'Lon: ${upload.coord.longitude.toStringAsFixed(6)}\n'
'Direction: ${upload.direction.round()}°\n'
'Attempts: ${upload.attempts}' +
(upload.error ? "\nUpload failed. Tap retry to try again." : "")
locService.t('queue.destination', params: [_getUploadModeDisplayName(upload.uploadMode)]) + '\n' +
locService.t('queue.latitude', params: [upload.coord.latitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.longitude', params: [upload.coord.longitude.toStringAsFixed(6)]) + '\n' +
locService.t('queue.direction', params: [upload.direction.round().toString()]) + '\n' +
locService.t('queue.attempts', params: [upload.attempts.toString()]) +
(upload.error ? "\n${locService.t('queue.uploadFailedRetry')}" : "")
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
@@ -120,7 +130,7 @@ class QueueSection extends StatelessWidget {
IconButton(
icon: const Icon(Icons.refresh),
color: Colors.orange,
tooltip: 'Retry upload',
tooltip: locService.t('queue.retryUpload'),
onPressed: () {
appState.retryUpload(upload);
},
@@ -146,7 +156,7 @@ class QueueSection extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Close'),
child: Text(locService.t('actions.close')),
),
if (appState.pendingCount > 1)
TextButton(
@@ -154,10 +164,10 @@ class QueueSection extends StatelessWidget {
appState.clearQueue();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Queue cleared')),
SnackBar(content: Text(locService.t('queue.queueCleared'))),
);
},
child: const Text('Clear All'),
child: Text(locService.t('queue.clearAll')),
),
],
),
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../models/tile_provider.dart';
import '../../services/localization_service.dart';
import '../tile_provider_management_screen.dart';
class TileProviderSection extends StatelessWidget {
@@ -10,29 +11,36 @@ class TileProviderSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Map Tiles',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderManagementScreen(),
),
);
},
icon: const Icon(Icons.settings),
label: const Text('Manage Providers'),
),
),
],
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('mapTiles.title'),
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const TileProviderManagementScreen(),
),
);
},
icon: const Icon(Icons.settings),
label: Text(locService.t('mapTiles.manageProviders')),
),
),
],
);
},
);
}
}
@@ -1,71 +1,85 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../app_state.dart';
import '../../services/localization_service.dart';
class UploadModeSection extends StatelessWidget {
const UploadModeSection({super.key});
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.cloud_upload),
title: const Text('Upload Destination'),
subtitle: const Text('Choose where cameras are uploaded'),
trailing: DropdownButton<UploadMode>(
value: appState.uploadMode,
items: const [
DropdownMenuItem(
value: UploadMode.production,
child: Text('Production'),
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
final appState = context.watch<AppState>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
leading: const Icon(Icons.cloud_upload),
title: Text(locService.t('uploadMode.title')),
subtitle: Text(locService.t('uploadMode.subtitle')),
trailing: DropdownButton<UploadMode>(
value: appState.uploadMode,
items: [
DropdownMenuItem(
value: UploadMode.production,
child: Text(locService.t('uploadMode.production')),
),
DropdownMenuItem(
value: UploadMode.sandbox,
child: Text(locService.t('uploadMode.sandbox')),
),
DropdownMenuItem(
value: UploadMode.simulate,
child: Text(locService.t('uploadMode.simulate')),
),
],
onChanged: (mode) {
if (mode != null) appState.setUploadMode(mode);
},
),
DropdownMenuItem(
value: UploadMode.sandbox,
child: Text('Sandbox'),
),
Padding(
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
child: Builder(
builder: (context) {
switch (appState.uploadMode) {
case UploadMode.production:
return Text(
locService.t('uploadMode.productionDescription'),
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7))
);
case UploadMode.sandbox:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locService.t('uploadMode.sandboxDescription'),
style: const TextStyle(fontSize: 12, color: Colors.orange),
),
const SizedBox(height: 2),
Text(
locService.t('uploadMode.sandboxNote'),
style: const TextStyle(fontSize: 11, color: Colors.redAccent),
),
],
);
case UploadMode.simulate:
default:
return Text(
locService.t('uploadMode.simulateDescription'),
style: const TextStyle(fontSize: 12, color: Colors.deepPurple)
);
}
},
),
DropdownMenuItem(
value: UploadMode.simulate,
child: Text('Simulate'),
),
],
onChanged: (mode) {
if (mode != null) appState.setUploadMode(mode);
},
),
),
Padding(
padding: const EdgeInsets.only(left: 56, top: 2, right: 16, bottom: 12),
child: Builder(
builder: (context) {
switch (appState.uploadMode) {
case UploadMode.production:
return Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7)));
case UploadMode.sandbox:
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Uploads go to the OSM Sandbox (safe for testing, resets regularly).',
style: TextStyle(fontSize: 12, color: Colors.orange),
),
SizedBox(height: 2),
Text(
'NOTE: Due to OpenStreetMap limitations, cameras submitted to the sandbox will NOT appear on the map in this app.',
style: TextStyle(fontSize: 11, color: Colors.redAccent),
),
],
);
case UploadMode.simulate:
default:
return const Text('Simulate uploads (does not contact OSM servers)', style: TextStyle(fontSize: 12, color: Colors.deepPurple));
}
},
),
),
],
),
],
);
},
);
}
}
+191 -174
View File
@@ -6,6 +6,7 @@ import 'package:collection/collection.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import '../services/localization_service.dart';
class TileProviderEditorScreen extends StatefulWidget {
final TileProvider? provider; // null for adding new provider
@@ -44,107 +45,114 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? 'Edit Provider' : 'Add Provider'),
actions: [
TextButton(
onPressed: _saveProvider,
child: const Text('Save'),
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return Scaffold(
appBar: AppBar(
title: Text(_isEditing ? locService.t('tileProviders.editProvider') : locService.t('tileProviders.addProvider')),
actions: [
TextButton(
onPressed: _saveProvider,
child: Text(locService.t('tileTypeEditor.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,
body: Form(
key: _formKey,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Tile Types',
style: Theme.of(context).textTheme.headlineSmall,
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: locService.t('tileProviders.providerName'),
hintText: locService.t('tileProviders.providerNameHint'),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return locService.t('tileProviders.providerNameRequired');
}
return null;
},
),
TextButton.icon(
onPressed: _addTileType,
icon: const Icon(Icons.add),
label: const Text('Add Type'),
const SizedBox(height: 16),
TextFormField(
controller: _apiKeyController,
decoration: InputDecoration(
labelText: locService.t('tileProviders.apiKey'),
hintText: locService.t('tileProviders.apiKeyHint'),
),
obscureText: true,
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
locService.t('tileProviders.tileTypes'),
style: Theme.of(context).textTheme.headlineSmall,
),
TextButton.icon(
onPressed: _addTileType,
icon: const Icon(Icons.add),
label: Text(locService.t('tileProviders.addType')),
),
],
),
const SizedBox(height: 16),
if (_tileTypes.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(locService.t('tileProviders.noTileTypesConfigured')),
),
)
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),
),
);
}),
],
),
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),
),
);
}),
],
),
),
),
);
},
);
}
@@ -207,10 +215,11 @@ class _TileProviderEditorScreenState extends State<TileProviderEditorScreen> {
}
void _saveProvider() {
final locService = LocalizationService.instance;
if (!_formKey.currentState!.validate()) return;
if (_tileTypes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('At least one tile type is required')),
SnackBar(content: Text(locService.t('tileProviders.atLeastOneTileTypeRequired'))),
);
return;
}
@@ -269,91 +278,99 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
@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(
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
return AlertDialog(
title: Text(widget.tileType != null ? locService.t('tileTypeEditor.editTileType') : locService.t('tileTypeEditor.addTileType')),
content: SizedBox(
width: double.maxFinite,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
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),
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: locService.t('tileTypeEditor.name'),
hintText: locService.t('tileTypeEditor.nameHint'),
),
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.nameRequired') : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _urlController,
decoration: InputDecoration(
labelText: locService.t('tileTypeEditor.urlTemplate'),
hintText: locService.t('tileTypeEditor.urlTemplateHint'),
),
validator: (value) {
if (value?.trim().isEmpty == true) return locService.t('tileTypeEditor.urlTemplateRequired');
if (!value!.contains('{z}') || !value.contains('{x}') || !value.contains('{y}')) {
return locService.t('tileTypeEditor.urlTemplatePlaceholders');
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _attributionController,
decoration: InputDecoration(
labelText: locService.t('tileTypeEditor.attribution'),
hintText: locService.t('tileTypeEditor.attributionHint'),
),
validator: (value) => value?.trim().isEmpty == true ? locService.t('tileTypeEditor.attributionRequired') : 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: Text(locService.t('tileTypeEditor.fetchPreview')),
),
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'),
),
],
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(locService.cancel),
),
TextButton(
onPressed: _saveTileType,
child: Text(locService.t('tileTypeEditor.save')),
),
],
);
},
);
}
Future<void> _fetchPreviewTile() async {
final locService = LocalizationService.instance;
if (!_formKey.currentState!.validate()) return;
setState(() {
@@ -376,7 +393,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Preview tile loaded successfully')),
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileLoaded'))),
);
}
} else {
@@ -385,7 +402,7 @@ class _TileTypeDialogState extends State<_TileTypeDialog> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to fetch preview: $e')),
SnackBar(content: Text(locService.t('tileTypeEditor.previewTileFailed', params: [e.toString()]))),
);
}
} finally {
+111 -103
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../app_state.dart';
import '../models/tile_provider.dart';
import '../services/localization_service.dart';
import 'tile_provider_editor_screen.dart';
class TileProviderManagementScreen extends StatelessWidget {
@@ -10,112 +11,118 @@ class TileProviderManagementScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final appState = context.watch<AppState>();
final providers = appState.tileProviders;
return AnimatedBuilder(
animation: LocalizationService.instance,
builder: (context, child) {
final locService = LocalizationService.instance;
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),
return Scaffold(
appBar: AppBar(
title: Text(locService.t('tileProviders.title')),
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,
),
body: providers.isEmpty
? Center(
child: Text(locService.t('tileProviders.noProvidersConfigured')),
)
: 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,
),
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'),
],
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(locService.t('tileProviders.tileTypesCount', params: [provider.tileTypes.length.toString()])),
if (provider.apiKey?.isNotEmpty == true)
Text(
locService.t('tileProviders.apiKeyConfigured'),
style: const TextStyle(
fontStyle: FontStyle.italic,
fontSize: 12,
),
),
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete),
SizedBox(width: 8),
Text('Delete'),
],
if (!provider.isUsable)
Text(
locService.t('tileProviders.needsApiKey'),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
},
),
],
),
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) => [
PopupMenuItem(
value: 'edit',
child: Row(
children: [
const Icon(Icons.edit),
const SizedBox(width: 8),
Text(locService.t('actions.edit')),
],
),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(Icons.delete),
const SizedBox(width: 8),
Text(locService.t('tileProviders.deleteProvider')),
],
),
),
],
)
: const Icon(Icons.lock, size: 16), // Can't delete last provider
onTap: () => _editProvider(context, provider),
),
);
},
),
);
},
);
}
@@ -136,22 +143,23 @@ class TileProviderManagementScreen extends StatelessWidget {
}
void _deleteProvider(BuildContext context, TileProvider provider) {
final locService = LocalizationService.instance;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Delete Provider'),
content: Text('Are you sure you want to delete "${provider.name}"?'),
title: Text(locService.t('tileProviders.deleteProvider')),
content: Text(locService.t('tileProviders.deleteProviderConfirm', params: [provider.name])),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
child: Text(locService.t('actions.cancel')),
),
TextButton(
onPressed: () {
context.read<AppState>().deleteTileProvider(provider.id);
Navigator.of(context).pop();
},
child: const Text('Delete'),
child: Text(locService.t('tileProviders.deleteProvider')),
),
],
),