diff --git a/lib/screens/settings/sections/queue_section.dart b/lib/screens/settings/sections/queue_section.dart index d6b2a20..4c4d946 100644 --- a/lib/screens/settings/sections/queue_section.dart +++ b/lib/screens/settings/sections/queue_section.dart @@ -112,7 +112,7 @@ class QueueSection extends StatelessWidget { ? Colors.red : _getUploadModeColor(upload.uploadMode), ), - title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) + + title: Text(locService.t('queue.itemWithIndex', params: [(index + 1).toString()]) + (upload.error ? locService.t('queue.error') : "") + (upload.completing ? locService.t('queue.completing') : "")), subtitle: Text( diff --git a/lib/services/localization_service.dart b/lib/services/localization_service.dart index 41bf904..1125302 100644 --- a/lib/services/localization_service.dart +++ b/lib/services/localization_service.dart @@ -25,42 +25,32 @@ class LocalizationService extends ChangeNotifier { Future _discoverAvailableLanguages() async { _availableLanguages = []; - + try { - // Get the asset manifest to find all localization files - final manifestContent = await rootBundle.loadString('AssetManifest.json'); - final Map manifestMap = json.decode(manifestContent); - - // Find all .json files in lib/localizations/ - final localizationFiles = manifestMap.keys - .where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json')) + final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final localizationAssets = assetManifest.listAssets() + .where((path) => path.startsWith('lib/localizations/') && path.endsWith('.json')) .toList(); - - for (final filePath in localizationFiles) { - // Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt') - final fileName = filePath.split('/').last; - final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json' - + + for (final assetPath in localizationAssets) { try { - // Try to load and parse the file to ensure it's valid - final jsonString = await rootBundle.loadString(filePath); + final jsonString = await rootBundle.loadString(assetPath); final parsedJson = json.decode(jsonString); - - // Basic validation - ensure it has the expected structure + if (parsedJson is Map && parsedJson.containsKey('language')) { + final languageCode = assetPath.split('/').last.replaceAll('.json', ''); _availableLanguages.add(languageCode); debugPrint('Found localization: $languageCode'); } } catch (e) { - debugPrint('Failed to load localization file $filePath: $e'); + debugPrint('Failed to load localization file $assetPath: $e'); } } } catch (e) { - debugPrint('Failed to read AssetManifest.json: $e'); - // If manifest reading fails, we'll have an empty list - // The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage + debugPrint('Failed to load asset manifest: $e'); + _availableLanguages = ['en']; } - + debugPrint('Available languages: $_availableLanguages'); } @@ -119,28 +109,31 @@ class LocalizationService extends ChangeNotifier { notifyListeners(); } - String t(String key, {List? params}) { + String t(String key, {List? params}) => + lookup(_strings, key, params: params); + + /// Pure lookup function used by [t] and available for testing. + static String lookup(Map strings, String key, + {List? params}) { List keys = key.split('.'); - dynamic current = _strings; - + 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; } } - + String result = current is String ? current : key; - - // Replace parameters if provided - replace first occurrence only for each parameter + if (params != null) { for (int i = 0; i < params.length; i++) { result = result.replaceFirst('{}', params[i]); } } - + return result; } diff --git a/pubspec.lock b/pubspec.lock index 2a86471..c266e82 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -106,7 +106,7 @@ packages: source: hosted version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -601,7 +601,7 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" diff --git a/scripts/validate_localizations.dart b/scripts/validate_localizations.dart deleted file mode 100644 index aad88ff..0000000 --- a/scripts/validate_localizations.dart +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env dart - -import 'dart:convert'; -import 'dart:io'; - -const String localizationsDir = 'lib/localizations'; -const String referenceFile = 'en.json'; - -void main() async { - print('šŸŒ Validating localization files...\n'); - - try { - final result = await validateLocalizations(); - if (result) { - print('āœ… All localization files are valid!'); - exit(0); - } else { - print('āŒ Localization validation failed!'); - exit(1); - } - } catch (e) { - print('šŸ’„ Error during validation: $e'); - exit(1); - } -} - -Future validateLocalizations() async { - // Get all JSON files in localizations directory - final locDir = Directory(localizationsDir); - if (!locDir.existsSync()) { - print('āŒ Localizations directory not found: $localizationsDir'); - return false; - } - - final jsonFiles = locDir - .listSync() - .where((file) => file.path.endsWith('.json')) - .map((file) => file.path.split('/').last) - .toList(); - - if (jsonFiles.isEmpty) { - print('āŒ No JSON localization files found'); - return false; - } - - print('šŸ“ Found ${jsonFiles.length} localization files:'); - for (final file in jsonFiles) { - print(' • $file'); - } - print(''); - - // Load reference file (English) - final refFile = File('$localizationsDir/$referenceFile'); - if (!refFile.existsSync()) { - print('āŒ Reference file not found: $referenceFile'); - return false; - } - - Map referenceData; - try { - final refContent = await refFile.readAsString(); - referenceData = json.decode(refContent) as Map; - } catch (e) { - print('āŒ Failed to parse reference file $referenceFile: $e'); - return false; - } - - final referenceKeys = _extractAllKeys(referenceData); - print('šŸ”‘ Reference file ($referenceFile) has ${referenceKeys.length} keys'); - - bool allValid = true; - - // Validate each localization file - for (final fileName in jsonFiles) { - if (fileName == referenceFile) continue; // Skip reference file - - print('\nšŸ” Validating $fileName...'); - - final file = File('$localizationsDir/$fileName'); - Map fileData; - - try { - final content = await file.readAsString(); - fileData = json.decode(content) as Map; - } catch (e) { - print(' āŒ Failed to parse $fileName: $e'); - allValid = false; - continue; - } - - final fileKeys = _extractAllKeys(fileData); - final validation = _validateKeys(referenceKeys, fileKeys, fileName); - - if (validation.isValid) { - print(' āœ… Structure matches reference (${fileKeys.length} keys)'); - } else { - print(' āŒ Structure validation failed:'); - for (final error in validation.errors) { - print(' • $error'); - } - allValid = false; - } - } - - return allValid; -} - -/// Extract all nested keys from a JSON object using dot notation -/// Example: {"user": {"name": "John"}} -> ["user.name"] -Set _extractAllKeys(Map data, {String prefix = ''}) { - final keys = {}; - - for (final entry in data.entries) { - final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}'; - - if (entry.value is Map) { - // Recurse into nested objects - keys.addAll(_extractAllKeys(entry.value as Map, prefix: key)); - } else { - // Add leaf key - keys.add(key); - } - } - - return keys; -} - -class ValidationResult { - final bool isValid; - final List errors; - - ValidationResult({required this.isValid, required this.errors}); -} - -ValidationResult _validateKeys(Set referenceKeys, Set fileKeys, String fileName) { - final errors = []; - - // Find missing keys - final missingKeys = referenceKeys.difference(fileKeys); - if (missingKeys.isNotEmpty) { - errors.add('Missing ${missingKeys.length} keys: ${missingKeys.take(5).join(', ')}${missingKeys.length > 5 ? '...' : ''}'); - } - - // Find extra keys - final extraKeys = fileKeys.difference(referenceKeys); - if (extraKeys.isNotEmpty) { - errors.add('Extra ${extraKeys.length} keys not in reference: ${extraKeys.take(5).join(', ')}${extraKeys.length > 5 ? '...' : ''}'); - } - - return ValidationResult( - isValid: errors.isEmpty, - errors: errors, - ); -} \ No newline at end of file diff --git a/test/services/localization_service_test.dart b/test/services/localization_service_test.dart new file mode 100644 index 0000000..2f8d44e --- /dev/null +++ b/test/services/localization_service_test.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:deflockapp/services/localization_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; + +const String localizationsDir = 'lib/localizations'; + +/// Recursively extract all dot-notation leaf keys from a JSON map. +Set extractLeafKeys(Map data, {String prefix = ''}) { + final keys = {}; + for (final entry in data.entries) { + final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}'; + if (entry.value is Map) { + keys.addAll( + extractLeafKeys(entry.value as Map, prefix: key), + ); + } else { + keys.add(key); + } + } + return keys; +} + +void main() { + // ── Group 1: Localization file integrity ────────────────────────────── + + group('Localization file integrity', () { + late Directory locDir; + late List jsonFiles; + + setUpAll(() { + locDir = Directory(localizationsDir); + if (locDir.existsSync()) { + jsonFiles = locDir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.json')) + .toList(); + } else { + jsonFiles = []; + } + }); + + test('localization directory exists and contains JSON files', () { + expect(locDir.existsSync(), isTrue); + expect(jsonFiles, isNotEmpty); + }); + + test('en.json exists (required fallback language)', () { + final enFile = File('$localizationsDir/en.json'); + expect(enFile.existsSync(), isTrue); + }); + + test('every JSON file is valid JSON with a language.name key', () { + for (final file in jsonFiles) { + final name = p.basename(file.path); + final content = file.readAsStringSync(); + final Map data; + try { + data = json.decode(content) as Map; + } catch (e) { + fail('$name is not valid JSON: $e'); + return; // unreachable, keeps analyzer happy + } + expect( + data['language'], + isA(), + reason: '$name missing "language" object', + ); + expect( + (data['language'] as Map)['name'], + isA(), + reason: '$name missing "language.name" string', + ); + } + }); + + test('file names are valid 2-3 letter language codes', () { + final codePattern = RegExp(r'^[a-z]{2,3}$'); + for (final file in jsonFiles) { + final code = p.basenameWithoutExtension(file.path); + expect( + codePattern.hasMatch(code), + isTrue, + reason: '"$code" is not a valid 2-3 letter language code', + ); + } + }); + + test('every locale file has exactly the same keys as en.json', () { + final enData = json.decode( + File('$localizationsDir/en.json').readAsStringSync(), + ) as Map; + final referenceKeys = extractLeafKeys(enData); + + for (final file in jsonFiles) { + final name = p.basename(file.path); + if (name == 'en.json') continue; + + final data = json.decode(file.readAsStringSync()) + as Map; + final fileKeys = extractLeafKeys(data); + + final missing = referenceKeys.difference(fileKeys); + final extra = fileKeys.difference(referenceKeys); + + expect( + missing, + isEmpty, + reason: '$name is missing keys: $missing', + ); + expect( + extra, + isEmpty, + reason: '$name has extra keys not in en.json: $extra', + ); + } + }); + }); + + // ── Group 2: t() translation lookup ─────────────────────────────────── + + group('t() translation lookup', () { + late Map enData; + + setUpAll(() { + enData = json.decode( + File('$localizationsDir/en.json').readAsStringSync(), + ) as Map; + }); + + test('simple nested key lookup', () { + expect( + LocalizationService.lookup(enData, 'app.title'), + equals('DeFlock'), + ); + }); + + test('deeper nested key lookup', () { + expect( + LocalizationService.lookup(enData, 'actions.cancel'), + equals('Cancel'), + ); + }); + + test('missing key returns the key string as fallback', () { + expect( + LocalizationService.lookup(enData, 'this.key.does.not.exist'), + equals('this.key.does.not.exist'), + ); + }); + + test('single {} parameter substitution', () { + expect( + LocalizationService.lookup(enData, 'node.title', params: ['42']), + equals('Node #42'), + ); + }); + + test('multiple {} parameter substitution', () { + expect( + LocalizationService.lookup(enData, 'proximityAlerts.rangeInfo', + params: ['50', '500', 'm', '200']), + equals('Range: 50-500 m (default: 200)'), + ); + }); + + test('partial path resolving to a Map returns the key as fallback', () { + expect( + LocalizationService.lookup(enData, 'actions'), + equals('actions'), + ); + }); + }); +}