localizations, dark mode touchups

This commit is contained in:
stopflock
2025-08-31 11:16:20 -05:00
parent 8381388ffa
commit fa6b6ffcda
17 changed files with 598 additions and 191 deletions

View File

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

View File

@@ -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.

33
lib/localizations/de.json Normal file
View File

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

33
lib/localizations/en.json Normal file
View File

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

33
lib/localizations/es.json Normal file
View File

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

33
lib/localizations/fr.json Normal file
View File

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

View File

@@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize localization service
await LocalizationService.instance.init();
runApp(
ChangeNotifierProvider(

View File

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

View File

@@ -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(),
],
),
),
);
}

View File

@@ -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<String>(
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<String>(
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'),
),
],
),
),
);
},
);
},
);

View File

@@ -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<LanguageSection> createState() => _LanguageSectionState();
}
class _LanguageSectionState extends State<LanguageSection> {
String? _selectedLanguage;
Map<String, String> _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<String, String> 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<String?>(
title: Text(locService.t('settings.systemDefault')),
value: null,
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
// Dynamic language options
...locService.availableLanguages.map((langCode) =>
RadioListTile<String>(
title: Text(_languageNames[langCode] ?? langCode.toUpperCase()),
value: langCode,
groupValue: _selectedLanguage,
onChanged: _setLanguage,
),
),
],
);
},
);
}
}

View File

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

View File

@@ -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<String, dynamic> _strings = {};
List<String> _availableLanguages = [];
String get currentLanguage => _currentLanguage;
List<String> get availableLanguages => _availableLanguages;
Future<void> init() async {
await _discoverAvailableLanguages();
await _loadSavedLanguage();
await _loadStrings();
}
Future<void> _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<void> _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<void> _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<void> 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<String> 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<String> getLanguageDisplayName(String languageCode) async {
try {
final String jsonString = await rootBundle.loadString('lib/localizations/$languageCode.json');
final Map<String, dynamic> 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');
}

View File

@@ -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<AppState>();
// 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'),
),
],
),
],
),
),
),
);
}
}

View File

@@ -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<CameraMapMarker> {
_tapTimer = Timer(tapTimeout, () {
showModalBottomSheet(
context: context,
builder: (_) => CameraTagSheet(node: widget.node),
builder: (_) => NodeTagSheet(node: widget.node),
showDragHandle: true,
);
});

View File

@@ -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<AppState>();
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')),
),
],
),
],
),
),
),
);
},
);
}
}

View File

@@ -40,6 +40,7 @@ flutter:
- assets/app_icon.png
- assets/transparent_1x1.png
- assets/deflock-logo.svg
- lib/localizations/
flutter_native_splash:
color: "#202020"