From fa6b6ffcda2bcd209dc54ae3782f65341394332e Mon Sep 17 00:00:00 2001 From: stopflock Date: Sun, 31 Aug 2025 11:16:20 -0500 Subject: [PATCH] localizations, dark mode touchups --- REBRAND_PROGRESS.md | 26 ---- lib/localizations/README.md | 49 +++++++ lib/localizations/de.json | 33 +++++ lib/localizations/en.json | 33 +++++ lib/localizations/es.json | 33 +++++ lib/localizations/fr.json | 33 +++++ lib/main.dart | 4 + lib/screens/home_screen.dart | 58 +++++---- lib/screens/settings_screen.dart | 57 ++++---- .../about_section.dart | 53 ++++---- .../language_section.dart | 90 +++++++++++++ .../upload_mode_section.dart | 2 +- lib/services/localization_service.dart | 122 ++++++++++++++++++ lib/widgets/camera_tag_sheet.dart | 91 ------------- lib/widgets/map/camera_markers.dart | 4 +- lib/widgets/node_tag_sheet.dart | 100 ++++++++++++++ pubspec.yaml | 1 + 17 files changed, 598 insertions(+), 191 deletions(-) delete mode 100644 REBRAND_PROGRESS.md create mode 100644 lib/localizations/README.md create mode 100644 lib/localizations/de.json create mode 100644 lib/localizations/en.json create mode 100644 lib/localizations/es.json create mode 100644 lib/localizations/fr.json create mode 100644 lib/screens/settings_screen_sections/language_section.dart create mode 100644 lib/services/localization_service.dart delete mode 100644 lib/widgets/camera_tag_sheet.dart create mode 100644 lib/widgets/node_tag_sheet.dart diff --git a/REBRAND_PROGRESS.md b/REBRAND_PROGRESS.md deleted file mode 100644 index 3bd8e58..0000000 --- a/REBRAND_PROGRESS.md +++ /dev/null @@ -1,26 +0,0 @@ -# DeFlock Rebrand Progress - -## TODO -- [ ] Test that the app still compiles and runs correctly - -## IN PROGRESS -- [ ] Nothing currently - -## FINISHED -- [x] pubspec.yaml (package name, description) -- [x] lib/main.dart (app title, class names, theme colors) -- [x] lib/screens/home_screen.dart (app bar title) -- [x] lib/services/auth_service.dart (redirect scheme) -- [x] lib/dev_config.dart (client name) -- [x] android/app/src/main/AndroidManifest.xml (app label, redirect scheme) -- [x] ios/Runner/Info.plist (display name, bundle name, redirect scheme) -- [x] android/app/build.gradle.kts (application ID) -- [x] android/app/src/main/kotlin/... (MainActivity package and directory structure) -- [x] assets/info.txt (about content) -- [x] README.md (all branding references) -- [x] Update all import statements (package:flock_map_app -> package:deflockapp) -- [x] lib/widgets/map/tile_layer_manager.dart (user agent package name) -- [x] test/models/pending_upload_test.dart (imports and CameraProfile -> NodeProfile) -- [x] test/widget_test.dart (import statement) -- [x] linux/CMakeLists.txt (binary name and application ID) -- [x] windows/CMakeLists.txt (project name and binary name) \ No newline at end of file diff --git a/lib/localizations/README.md b/lib/localizations/README.md new file mode 100644 index 0000000..94be103 --- /dev/null +++ b/lib/localizations/README.md @@ -0,0 +1,49 @@ +# DeFlock Localizations + +This directory contains translation files for DeFlock. Each language is a simple JSON file. + +## Adding a New Language + +Want to add support for your language? It's simple: + +1. **Copy the English file**: `cp en.json your_language_code.json` + - Use 2-letter language codes: `es` (Spanish), `fr` (French), `it` (Italian), etc. + +2. **Edit your new file**: + ```json + { + "language": { + "name": "Your Language Name" ← Change this to your language in your language + }, + "app": { + "title": "DeFlock" ← Keep this as-is + }, + "actions": { + "tagNode": "Your Translation Here", + "download": "Your Translation Here", + ... + } + } + ``` + +3. **Submit a PR** with just that one file. Done! + +The new language will automatically appear in Settings → Language. + +## Translation Rules + +- **Only translate the values** (text after the `:`), never the keys +- **Keep `{}` placeholders** if you see them - they get replaced with numbers/text +- **Don't translate "DeFlock"** - it's the app name +- **Use your language's name for itself** - "Français" not "French", "Español" not "Spanish" + +## Current Languages + +- `en.json` - English +- `es.json` - Español +- `fr.json` - Français +- `de.json` - Deutsch + +## That's It! + +No configuration files, no build steps, no complex setup. Just add your JSON file and it works. \ No newline at end of file diff --git a/lib/localizations/de.json b/lib/localizations/de.json new file mode 100644 index 0000000..f091649 --- /dev/null +++ b/lib/localizations/de.json @@ -0,0 +1,33 @@ +{ + "language": { + "name": "Deutsch" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "Knoten Markieren", + "download": "Herunterladen", + "settings": "Einstellungen", + "edit": "Bearbeiten", + "cancel": "Abbrechen", + "ok": "OK", + "close": "Schließen" + }, + "followMe": { + "off": "Verfolgung aktivieren (Norden oben)", + "northUp": "Verfolgung aktivieren (Rotation)", + "rotating": "Verfolgung deaktivieren" + }, + "settings": { + "title": "Einstellungen", + "language": "Sprache", + "systemDefault": "Systemstandard", + "aboutInfo": "Über / Informationen", + "aboutThisApp": "Über Diese App" + }, + "node": { + "title": "Knoten #{}", + "tagSheetTitle": "Gerät-Tags" + } +} \ No newline at end of file diff --git a/lib/localizations/en.json b/lib/localizations/en.json new file mode 100644 index 0000000..b144b5d --- /dev/null +++ b/lib/localizations/en.json @@ -0,0 +1,33 @@ +{ + "language": { + "name": "English" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "Tag Node", + "download": "Download", + "settings": "Settings", + "edit": "Edit", + "cancel": "Cancel", + "ok": "OK", + "close": "Close" + }, + "followMe": { + "off": "Enable follow-me (north up)", + "northUp": "Enable follow-me (rotating)", + "rotating": "Disable follow-me" + }, + "settings": { + "title": "Settings", + "language": "Language", + "systemDefault": "System Default", + "aboutInfo": "About / Info", + "aboutThisApp": "About This App" + }, + "node": { + "title": "Node #{}", + "tagSheetTitle": "Surveillance Device Tags" + } +} \ No newline at end of file diff --git a/lib/localizations/es.json b/lib/localizations/es.json new file mode 100644 index 0000000..fa6ac2c --- /dev/null +++ b/lib/localizations/es.json @@ -0,0 +1,33 @@ +{ + "language": { + "name": "Español" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "Etiquetar Nodo", + "download": "Descargar", + "settings": "Configuración", + "edit": "Editar", + "cancel": "Cancelar", + "ok": "Aceptar", + "close": "Cerrar" + }, + "followMe": { + "off": "Activar seguimiento (norte arriba)", + "northUp": "Activar seguimiento (rotación)", + "rotating": "Desactivar seguimiento" + }, + "settings": { + "title": "Configuración", + "language": "Idioma", + "systemDefault": "Sistema por Defecto", + "aboutInfo": "Acerca de / Información", + "aboutThisApp": "Acerca de Esta App" + }, + "node": { + "title": "Nodo #{}", + "tagSheetTitle": "Etiquetas del Dispositivo" + } +} \ No newline at end of file diff --git a/lib/localizations/fr.json b/lib/localizations/fr.json new file mode 100644 index 0000000..3071553 --- /dev/null +++ b/lib/localizations/fr.json @@ -0,0 +1,33 @@ +{ + "language": { + "name": "Français" + }, + "app": { + "title": "DeFlock" + }, + "actions": { + "tagNode": "Marquer Nœud", + "download": "Télécharger", + "settings": "Paramètres", + "edit": "Modifier", + "cancel": "Annuler", + "ok": "OK", + "close": "Fermer" + }, + "followMe": { + "off": "Activer le suivi (nord en haut)", + "northUp": "Activer le suivi (rotation)", + "rotating": "Désactiver le suivi" + }, + "settings": { + "title": "Paramètres", + "language": "Langue", + "systemDefault": "Par Défaut du Système", + "aboutInfo": "À Propos / Informations", + "aboutThisApp": "À Propos de Cette App" + }, + "node": { + "title": "Nœud #{}", + "tagSheetTitle": "Balises du Dispositif" + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 1a9b061..4e67496 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,11 +4,15 @@ import 'package:provider/provider.dart'; import 'app_state.dart'; import 'screens/home_screen.dart'; import 'screens/settings_screen.dart'; +import 'services/localization_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); + + // Initialize localization service + await LocalizationService.instance.init(); runApp( ChangeNotifierProvider( diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index e26bfcf..8ac9e09 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -7,6 +7,7 @@ import 'package:provider/provider.dart'; import '../app_state.dart'; import '../dev_config.dart'; import '../widgets/map_view.dart'; +import '../services/localization_service.dart'; import '../widgets/add_node_sheet.dart'; import '../widgets/edit_node_sheet.dart'; @@ -44,13 +45,14 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } String _getFollowMeTooltip(FollowMeMode mode) { + final locService = LocalizationService.instance; switch (mode) { case FollowMeMode.off: - return 'Enable follow-me (north up)'; + return locService.t('followMe.off'); case FollowMeMode.northUp: - return 'Enable follow-me (rotating)'; + return locService.t('followMe.northUp'); case FollowMeMode.rotating: - return 'Disable follow-me'; + return locService.t('followMe.rotating'); } } @@ -172,9 +174,13 @@ class _HomeScreenState extends State with TickerProviderStateMixin { } }, ), - IconButton( - icon: const Icon(Icons.settings), - onPressed: () => Navigator.pushNamed(context, '/settings'), + AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => IconButton( + tooltip: LocalizationService.instance.settings, + icon: const Icon(Icons.settings), + onPressed: () => Navigator.pushNamed(context, '/settings'), + ), ), ], ), @@ -216,28 +222,34 @@ class _HomeScreenState extends State with TickerProviderStateMixin { child: Row( children: [ Expanded( - child: ElevatedButton.icon( - icon: Icon(Icons.add_location_alt), - label: Text('Tag Node'), - onPressed: _openAddNodeSheet, - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), + child: AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => ElevatedButton.icon( + icon: Icon(Icons.add_location_alt), + label: Text(LocalizationService.instance.tagNode), + onPressed: _openAddNodeSheet, + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), ), ), ), SizedBox(width: 12), Expanded( - child: ElevatedButton.icon( - icon: Icon(Icons.download_for_offline), - label: Text('Download'), - onPressed: () => showDialog( - context: context, - builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), - ), - style: ElevatedButton.styleFrom( - minimumSize: Size(0, 48), - textStyle: TextStyle(fontSize: 16), + child: AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => ElevatedButton.icon( + icon: Icon(Icons.download_for_offline), + label: Text(LocalizationService.instance.download), + onPressed: () => showDialog( + context: context, + builder: (ctx) => DownloadAreaDialog(controller: _mapController.mapController), + ), + style: ElevatedButton.styleFrom( + minimumSize: Size(0, 48), + textStyle: TextStyle(fontSize: 16), + ), ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index a8d201f..09fff92 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,37 +9,44 @@ import 'settings_screen_sections/offline_mode_section.dart'; import 'settings_screen_sections/about_section.dart'; import 'settings_screen_sections/max_nodes_section.dart'; import 'settings_screen_sections/tile_provider_section.dart'; +import 'settings_screen_sections/language_section.dart'; +import '../services/localization_service.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Settings')), - body: ListView( - padding: const EdgeInsets.all(16), - children: const [ - UploadModeSection(), - Divider(), - AuthSection(), - Divider(), - QueueSection(), - Divider(), - ProfileListSection(), - Divider(), - OperatorProfileListSection(), - Divider(), - MaxNodesSection(), - Divider(), - TileProviderSection(), - Divider(), - OfflineModeSection(), - Divider(), - OfflineAreasSection(), - Divider(), - AboutSection(), - ], + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) => Scaffold( + appBar: AppBar(title: Text(LocalizationService.instance.t('settings.title'))), + body: ListView( + padding: const EdgeInsets.all(16), + children: const [ + UploadModeSection(), + Divider(), + AuthSection(), + Divider(), + QueueSection(), + Divider(), + ProfileListSection(), + Divider(), + OperatorProfileListSection(), + Divider(), + MaxNodesSection(), + Divider(), + TileProviderSection(), + Divider(), + OfflineModeSection(), + Divider(), + OfflineAreasSection(), + Divider(), + LanguageSection(), + Divider(), + AboutSection(), + ], + ), ), ); } diff --git a/lib/screens/settings_screen_sections/about_section.dart b/lib/screens/settings_screen_sections/about_section.dart index 4803cbb..62f16b9 100644 --- a/lib/screens/settings_screen_sections/about_section.dart +++ b/lib/screens/settings_screen_sections/about_section.dart @@ -1,35 +1,42 @@ import 'package:flutter/material.dart'; +import '../../services/localization_service.dart'; class AboutSection extends StatelessWidget { const AboutSection({super.key}); @override Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('About / Info'), - onTap: () async { - showDialog( - context: context, - builder: (context) => FutureBuilder( - future: DefaultAssetBundle.of(context).loadString('assets/info.txt'), - builder: (context, snapshot) => AlertDialog( - title: const Text('About This App'), - content: SingleChildScrollView( - child: Text( - snapshot.connectionState == ConnectionState.done - ? (snapshot.data ?? 'No info available.') - : 'Loading...', + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final locService = LocalizationService.instance; + return ListTile( + leading: const Icon(Icons.info_outline), + title: Text(locService.t('settings.aboutInfo')), + onTap: () async { + showDialog( + context: context, + builder: (context) => FutureBuilder( + future: DefaultAssetBundle.of(context).loadString('assets/info.txt'), + builder: (context, snapshot) => AlertDialog( + title: Text(locService.t('settings.aboutThisApp')), + content: SingleChildScrollView( + child: Text( + snapshot.connectionState == ConnectionState.done + ? (snapshot.data ?? 'No info available.') + : 'Loading...', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.ok), + ), + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('OK'), - ), - ], - ), - ), + ); + }, ); }, ); diff --git a/lib/screens/settings_screen_sections/language_section.dart b/lib/screens/settings_screen_sections/language_section.dart new file mode 100644 index 0000000..345ed33 --- /dev/null +++ b/lib/screens/settings_screen_sections/language_section.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import '../../services/localization_service.dart'; + +class LanguageSection extends StatefulWidget { + const LanguageSection({super.key}); + + @override + State createState() => _LanguageSectionState(); +} + +class _LanguageSectionState extends State { + String? _selectedLanguage; + Map _languageNames = {}; + + @override + void initState() { + super.initState(); + _loadSelectedLanguage(); + _loadLanguageNames(); + } + + _loadSelectedLanguage() async { + final prefs = await SharedPreferences.getInstance(); + setState(() { + _selectedLanguage = prefs.getString('language_code'); + }); + } + + _loadLanguageNames() async { + final locService = LocalizationService.instance; + final Map names = {}; + + for (String langCode in locService.availableLanguages) { + names[langCode] = await locService.getLanguageDisplayName(langCode); + } + + setState(() { + _languageNames = names; + }); + } + + _setLanguage(String? languageCode) async { + await LocalizationService.instance.setLanguage(languageCode); + setState(() { + _selectedLanguage = languageCode; + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final locService = LocalizationService.instance; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + locService.t('settings.language'), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + // System Default option + RadioListTile( + title: Text(locService.t('settings.systemDefault')), + value: null, + groupValue: _selectedLanguage, + onChanged: _setLanguage, + ), + // Dynamic language options + ...locService.availableLanguages.map((langCode) => + RadioListTile( + title: Text(_languageNames[langCode] ?? langCode.toUpperCase()), + value: langCode, + groupValue: _selectedLanguage, + onChanged: _setLanguage, + ), + ), + ], + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/screens/settings_screen_sections/upload_mode_section.dart b/lib/screens/settings_screen_sections/upload_mode_section.dart index 20557af..59e26b0 100644 --- a/lib/screens/settings_screen_sections/upload_mode_section.dart +++ b/lib/screens/settings_screen_sections/upload_mode_section.dart @@ -42,7 +42,7 @@ class UploadModeSection extends StatelessWidget { builder: (context) { switch (appState.uploadMode) { case UploadMode.production: - return const Text('Upload to the live OSM database (visible to all users)', style: TextStyle(fontSize: 12, color: Colors.black87)); + 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, diff --git a/lib/services/localization_service.dart b/lib/services/localization_service.dart new file mode 100644 index 0000000..b84c062 --- /dev/null +++ b/lib/services/localization_service.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class LocalizationService extends ChangeNotifier { + static LocalizationService? _instance; + static LocalizationService get instance => _instance ??= LocalizationService._(); + + LocalizationService._(); + + String _currentLanguage = 'en'; + Map _strings = {}; + List _availableLanguages = []; + + String get currentLanguage => _currentLanguage; + List get availableLanguages => _availableLanguages; + + Future init() async { + await _discoverAvailableLanguages(); + await _loadSavedLanguage(); + await _loadStrings(); + } + + Future _discoverAvailableLanguages() async { + // For now, we'll hardcode the languages we support + // In the future, this could scan the assets directory + _availableLanguages = ['en', 'es', 'fr', 'de']; + } + + Future _loadSavedLanguage() async { + final prefs = await SharedPreferences.getInstance(); + final savedLanguage = prefs.getString('language_code'); + + if (savedLanguage != null && _availableLanguages.contains(savedLanguage)) { + _currentLanguage = savedLanguage; + } else { + // Use system default or fallback to English + final systemLocale = Platform.localeName.split('_')[0]; + if (_availableLanguages.contains(systemLocale)) { + _currentLanguage = systemLocale; + } else { + _currentLanguage = 'en'; + } + } + } + + Future _loadStrings() async { + try { + final String jsonString = await rootBundle.loadString('lib/localizations/$_currentLanguage.json'); + _strings = json.decode(jsonString); + } catch (e) { + // Fallback to English if the language file doesn't exist + if (_currentLanguage != 'en') { + try { + final String fallbackString = await rootBundle.loadString('lib/localizations/en.json'); + _strings = json.decode(fallbackString); + } catch (e) { + debugPrint('Failed to load fallback language file: $e'); + _strings = {}; + } + } else { + debugPrint('Failed to load language file for $_currentLanguage: $e'); + _strings = {}; + } + } + } + + Future setLanguage(String? languageCode) async { + final prefs = await SharedPreferences.getInstance(); + + if (languageCode == null) { + // System default + await prefs.remove('language_code'); + final systemLocale = Platform.localeName.split('_')[0]; + _currentLanguage = _availableLanguages.contains(systemLocale) ? systemLocale : 'en'; + } else { + await prefs.setString('language_code', languageCode); + _currentLanguage = languageCode; + } + + await _loadStrings(); + notifyListeners(); + } + + String t(String key) { + List keys = key.split('.'); + dynamic current = _strings; + + for (String k in keys) { + if (current is Map && current.containsKey(k)) { + current = current[k]; + } else { + // Return the key as fallback for missing translations + return key; + } + } + + return current is String ? current : key; + } + + // Get display name for a specific language code + Future getLanguageDisplayName(String languageCode) async { + try { + final String jsonString = await rootBundle.loadString('lib/localizations/$languageCode.json'); + final Map langData = json.decode(jsonString); + return langData['language']?['name'] ?? languageCode.toUpperCase(); + } catch (e) { + return languageCode.toUpperCase(); // Fallback to language code + } + } + + // Helper methods for common strings + String get appTitle => t('app.title'); + String get settings => t('actions.settings'); + String get tagNode => t('actions.tagNode'); + String get download => t('actions.download'); + String get edit => t('actions.edit'); + String get cancel => t('actions.cancel'); + String get ok => t('actions.ok'); +} \ No newline at end of file diff --git a/lib/widgets/camera_tag_sheet.dart b/lib/widgets/camera_tag_sheet.dart deleted file mode 100644 index c5c897b..0000000 --- a/lib/widgets/camera_tag_sheet.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../models/osm_camera_node.dart'; -import '../app_state.dart'; - -class CameraTagSheet extends StatelessWidget { - final OsmCameraNode node; - - const CameraTagSheet({super.key, required this.node}); - - @override - Widget build(BuildContext context) { - final appState = context.watch(); - - // Check if this camera is editable (not a pending upload or pending edit) - final isEditable = (!node.tags.containsKey('_pending_upload') || - node.tags['_pending_upload'] != 'true') && - (!node.tags.containsKey('_pending_edit') || - node.tags['_pending_edit'] != 'true'); - - void _openEditSheet() { - Navigator.pop(context); // Close this sheet first - appState.startEditSession(node); // HomeScreen will auto-show the edit sheet - } - - return SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Camera #${node.id}', - style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 12), - ...node.tags.entries.map( - (e) => Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - e.key, - style: const TextStyle( - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - e.value, - style: const TextStyle( - color: Colors.black54, - ), - softWrap: true, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isEditable) ...[ - ElevatedButton.icon( - onPressed: _openEditSheet, - icon: const Icon(Icons.edit, size: 18), - label: const Text('Edit'), - style: ElevatedButton.styleFrom( - minimumSize: const Size(0, 36), - ), - ), - const SizedBox(width: 12), - ], - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Close'), - ), - ], - ), - ], - ), - ), - ), - ); - } -} diff --git a/lib/widgets/map/camera_markers.dart b/lib/widgets/map/camera_markers.dart index 0ab73b7..48eefab 100644 --- a/lib/widgets/map/camera_markers.dart +++ b/lib/widgets/map/camera_markers.dart @@ -5,7 +5,7 @@ import 'package:latlong2/latlong.dart'; import '../../dev_config.dart'; import '../../models/osm_camera_node.dart'; -import '../camera_tag_sheet.dart'; +import '../node_tag_sheet.dart'; import '../camera_icon.dart'; /// Smart marker widget for camera with single/double tap distinction @@ -27,7 +27,7 @@ class _CameraMapMarkerState extends State { _tapTimer = Timer(tapTimeout, () { showModalBottomSheet( context: context, - builder: (_) => CameraTagSheet(node: widget.node), + builder: (_) => NodeTagSheet(node: widget.node), showDragHandle: true, ); }); diff --git a/lib/widgets/node_tag_sheet.dart b/lib/widgets/node_tag_sheet.dart new file mode 100644 index 0000000..b4f163b --- /dev/null +++ b/lib/widgets/node_tag_sheet.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/osm_camera_node.dart'; +import '../app_state.dart'; +import '../services/localization_service.dart'; + +class NodeTagSheet extends StatelessWidget { + final OsmCameraNode node; + + const NodeTagSheet({super.key, required this.node}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: LocalizationService.instance, + builder: (context, child) { + final appState = context.watch(); + final locService = LocalizationService.instance; + + // Check if this device is editable (not a pending upload or pending edit) + final isEditable = (!node.tags.containsKey('_pending_upload') || + node.tags['_pending_upload'] != 'true') && + (!node.tags.containsKey('_pending_edit') || + node.tags['_pending_edit'] != 'true'); + + void _openEditSheet() { + Navigator.pop(context); // Close this sheet first + appState.startEditSession(node); // HomeScreen will auto-show the edit sheet + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + locService.t('node.title').replaceAll('{}', node.id.toString()), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + ...node.tags.entries.map( + (e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + e.key, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + e.value, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), + ), + softWrap: true, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (isEditable) ...[ + ElevatedButton.icon( + onPressed: _openEditSheet, + icon: const Icon(Icons.edit, size: 18), + label: Text(locService.edit), + style: ElevatedButton.styleFrom( + minimumSize: const Size(0, 36), + ), + ), + const SizedBox(width: 12), + ], + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(locService.t('actions.close')), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index ebcb6d1..eb89bbe 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ flutter: - assets/app_icon.png - assets/transparent_1x1.png - assets/deflock-logo.svg + - lib/localizations/ flutter_native_splash: color: "#202020"