diff --git a/Makefile b/Makefile index 85768a786..0930c5039 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup setup-dev ensure-fvm get doppler-check doppler-login secrets-print update-flutter format fmt format-check analyze analytics-gen analytics-guard analytics-check firestore-guard no-dynamic-guard no-shape-parse-guard env-guard system-ui-guard secrets-guard version-sync version-guard file-gen pigeon-gen run build build-aab size-android attach ios-setup build-ios build-ipa ci test find-unused find-unused-html find-unused-ci +.PHONY: setup setup-dev ensure-fvm get doppler-check doppler-login secrets-print update-flutter format fmt format-check analyze analytics-gen analytics-guard analytics-check firestore-guard no-dynamic-guard no-shape-parse-guard env-guard system-ui-guard secrets-guard version-sync version-guard file-gen pigeon-gen run build build-aab size-android attach ios-setup build-ios build-ipa ci test find-unused find-unused-html find-unused-ci strings-extract strings-gen strings strings-check DART_FORMAT_LINE_LENGTH ?= 120 DART_FORMAT_PATHS ?= lib test @@ -74,6 +74,19 @@ format-check: ensure-fvm analyze: ensure-fvm env-guard @$(FLUTTER) analyze --no-pub $(ANALYZE_FLAGS) +strings-extract: ensure-fvm + @$(DART) run tool/extract_strings.dart + +strings-gen: ensure-fvm + @$(DART) run tool/generate_strings.dart + @$(DART) format --line-length $(DART_FORMAT_LINE_LENGTH) lib/core/l10n/app_strings.g.dart + +strings: strings-extract strings-gen + +strings-check: ensure-fvm + @$(MAKE) strings-gen + @git diff --exit-code -- lib/core/l10n/app_strings.g.dart ios/Runner/Base.lproj/Localizable.strings + analytics-gen: ensure-fvm @$(DART) run tool/generate_analytics_schema.dart @$(DART) format --line-length $(DART_FORMAT_LINE_LENGTH) lib/core/analytics/events/generated/analytics_events.g.dart @@ -212,7 +225,7 @@ update-flutter: ensure-fvm @$(FLUTTER) pub get @echo "Pinned Flutter version updated to $(VERSION). Commit .fvmrc." -ci: get format-check env-guard secrets-guard version-guard analytics-check analyze no-dynamic-guard find-unused-ci +ci: get format-check env-guard secrets-guard version-guard analytics-check strings-check analyze no-dynamic-guard find-unused-ci test: ensure-fvm @if ls test/*_test.dart >/dev/null 2>&1 || find test -name '*_test.dart' -print -quit | grep -q .; then \ diff --git a/ios/Runner/Base.lproj/Localizable.strings b/ios/Runner/Base.lproj/Localizable.strings new file mode 100644 index 000000000..e0fe7bcee --- /dev/null +++ b/ios/Runner/Base.lproj/Localizable.strings @@ -0,0 +1,11 @@ +/* GENERATED CODE - DO NOT MODIFY BY HAND. */ +/* Run: dart run tool/generate_strings.dart */ + +"appwidget_text" = "EXAMPLE"; +"configure" = "Select text"; +"add_widget" = "Add widget"; +"example" = "Example"; +"content_desc" = "Refresh widget"; +"quick_settings" = "Shuffle wallpaper"; +"quick_settings_wotd" = "Wall of the Day"; +"quick_settings_favs" = "Random Favourite"; diff --git a/lib/core/l10n/app_strings.g.dart b/lib/core/l10n/app_strings.g.dart new file mode 100644 index 000000000..f2ffd564b --- /dev/null +++ b/lib/core/l10n/app_strings.g.dart @@ -0,0 +1,18 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND. +// +// Run: dart run tool/generate_strings.dart + +// ignore_for_file: lines_longer_than_80_chars + +class AppStrings { + AppStrings._(); + + static const String appwidgetText = 'EXAMPLE'; + static const String configure = 'Select text'; + static const String addWidget = 'Add widget'; + static const String example = 'Example'; + static const String contentDesc = 'Refresh widget'; + static const String quickSettings = 'Shuffle wallpaper'; + static const String quickSettingsWotd = 'Wall of the Day'; + static const String quickSettingsFavs = 'Random Favourite'; +} diff --git a/tool/extract_strings.dart b/tool/extract_strings.dart new file mode 100644 index 000000000..c9417d799 --- /dev/null +++ b/tool/extract_strings.dart @@ -0,0 +1,236 @@ +import 'dart:collection'; +import 'dart:io'; + +const String _defaultXmlPath = 'android/app/src/main/res/values/strings.xml'; +const String _defaultLibPath = 'lib'; + +/// Extraction patterns: (label, regex for single-quoted, regex for double-quoted) +final List<(String, RegExp, RegExp)> _patterns = [ + // Text('...') / Text("...") + ( + 'Text', + RegExp(r"""Text\s*\(\s*'([^'$\\\n]{2,})'\s*[,)]"""), + RegExp(r'''Text\s*\(\s*"([^"$\\\n]{2,})"\s*[,)]'''), + ), + // Named UI parameters + ( + 'label', + RegExp(r"""(? args) { + final String xmlPath = args.isNotEmpty ? args.first : _defaultXmlPath; + final String libPath = args.length > 1 ? args[1] : _defaultLibPath; + + // Load existing strings.xml preserving order + final LinkedHashMap existing = _loadXml(xmlPath); + + // Build reverse map: value → key (for dedup against existing) + final Map valueToKey = {}; + for (final MapEntry entry in existing.entries) { + valueToKey[entry.value] = entry.key; + } + + // Scan lib/ for UI strings + final List extracted = _scanDirectory(Directory(libPath)); + + // Merge: add new entries, update changed values for same key is not done + // (key is preserved, value updates are manual for safety). + // Only new strings (not already in valueToKey) are added. + int added = 0; + for (final String value in extracted) { + if (valueToKey.containsKey(value)) continue; + final String key = _generateKey(value, existing.keys.toSet()); + existing[key] = value; + valueToKey[value] = key; + added++; + } + + _writeXml(xmlPath, existing); + stdout.writeln('Scanned ${extracted.length} unique strings, added $added new entries to $xmlPath'); +} + +// --------------------------------------------------------------------------- +// XML I/O +// --------------------------------------------------------------------------- + +final RegExp _entryRe = RegExp(r'(.*?)', dotAll: true); + +LinkedHashMap _loadXml(String path) { + final LinkedHashMap result = LinkedHashMap(); + final File file = File(path); + if (!file.existsSync()) return result; + final String content = file.readAsStringSync(); + for (final RegExpMatch m in _entryRe.allMatches(content)) { + result[m.group(1)!] = _xmlUnescape(m.group(2)!); + } + return result; +} + +void _writeXml(String path, LinkedHashMap entries) { + final StringBuffer buf = StringBuffer(); + buf.writeln(''); + buf.writeln(''); + for (final MapEntry e in entries.entries) { + buf.writeln(' ${_xmlEscape(e.value)}'); + } + buf.write(''); + File(path).writeAsStringSync(buf.toString()); +} + +String _xmlEscape(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + +String _xmlUnescape(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); + +// --------------------------------------------------------------------------- +// Directory scanning +// --------------------------------------------------------------------------- + +List _scanDirectory(Directory dir) { + if (!dir.existsSync()) { + stderr.writeln('Directory not found: ${dir.path}'); + return const []; + } + + final List results = []; + final Iterable dartFiles = dir + .listSync(recursive: true) + .whereType() + .where((File f) { + final String name = f.uri.pathSegments.last; + return name.endsWith('.dart') && + !name.endsWith('.g.dart') && + !name.endsWith('.freezed.dart') && + !name.endsWith('.gr.dart'); + }); + + for (final File file in dartFiles) { + String content; + try { + content = file.readAsStringSync(); + } catch (_) { + continue; + } + results.addAll(_extractStrings(content)); + } + + // Deduplicate while preserving first-seen order + final Set seen = {}; + return results.where(seen.add).toList(growable: false); +} + +// --------------------------------------------------------------------------- +// String extraction +// --------------------------------------------------------------------------- + +List _extractStrings(String content) { + final List results = []; + for (final (_, RegExp single, RegExp double_) in _patterns) { + for (final RegExpMatch m in single.allMatches(content)) { + final String v = m.group(1)!.trim(); + if (!_shouldExclude(v)) results.add(v); + } + for (final RegExpMatch m in double_.allMatches(content)) { + final String v = m.group(1)!.trim(); + if (!_shouldExclude(v)) results.add(v); + } + } + return results; +} + +bool _shouldExclude(String value) { + if (value.length < 2) return true; + // URLs + if (value.contains('://')) return true; + if (value.startsWith('www.')) return true; + // File paths / asset keys + if (value.startsWith('assets/')) return true; + if (value.startsWith('/')) return true; + if (RegExp(r'\.(png|jpg|jpeg|svg|gif|webp|json|yaml|xml|dart|js)$').hasMatch(value)) return true; + // Only digits, whitespace, or punctuation — no letters + if (!RegExp(r'[A-Za-z]').hasMatch(value)) return true; + // Looks like a programmatic key: all lowercase + underscores + digits, no spaces + // e.g. 'screen_view', 'content_type' — analytics / Firebase keys + if (RegExp(r'^[a-z][a-z0-9_]*$').hasMatch(value) && value.contains('_')) return true; + // Package names / import paths + if (value.startsWith('package:')) return true; + // Regex patterns + if (value.contains(r'\d') || value.contains(r'\w') || value.contains(r'\s')) return true; + return false; +} + +// --------------------------------------------------------------------------- +// Key generation +// --------------------------------------------------------------------------- + +String _generateKey(String value, Set existingKeys) { + String key = _toSnakeCase(value); + if (key.isEmpty) key = 'string'; + if (!existingKeys.contains(key)) return key; + int i = 2; + while (existingKeys.contains('${key}_$i')) { + i++; + } + return '${key}_$i'; +} + +String _toSnakeCase(String value) { + // Lowercase, replace runs of non-alphanumeric chars with single underscore + String result = value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_'); + // Strip leading/trailing underscores + result = result.replaceAll(RegExp(r'^_+|_+$'), ''); + // Truncate to 50 chars at a word boundary + if (result.length > 50) { + result = result.substring(0, 50); + final int lastUnderscore = result.lastIndexOf('_'); + if (lastUnderscore > 10) result = result.substring(0, lastUnderscore); + } + return result; +} diff --git a/tool/generate_strings.dart b/tool/generate_strings.dart new file mode 100644 index 000000000..9d475e39e --- /dev/null +++ b/tool/generate_strings.dart @@ -0,0 +1,122 @@ +import 'dart:collection'; +import 'dart:io'; + +const String _defaultXmlPath = 'android/app/src/main/res/values/strings.xml'; +const String _defaultDartPath = 'lib/core/l10n/app_strings.g.dart'; +const String _defaultIosPath = 'ios/Runner/Base.lproj/Localizable.strings'; + +void main(List args) { + final String xmlPath = args.isNotEmpty ? args.first : _defaultXmlPath; + final String dartPath = args.length > 1 ? args[1] : _defaultDartPath; + final String iosPath = args.length > 2 ? args[2] : _defaultIosPath; + + final LinkedHashMap entries = _loadXml(xmlPath); + if (entries.isEmpty) { + stderr.writeln('No entries found in $xmlPath'); + exitCode = 1; + return; + } + + _writeDart(dartPath, entries); + _writeIos(iosPath, entries); + + stdout.writeln('Generated ${entries.length} strings:'); + stdout.writeln(' Dart → $dartPath'); + stdout.writeln(' iOS → $iosPath'); +} + +// --------------------------------------------------------------------------- +// XML parsing +// --------------------------------------------------------------------------- + +final RegExp _entryRe = RegExp(r'(.*?)', dotAll: true); + +LinkedHashMap _loadXml(String path) { + final LinkedHashMap result = LinkedHashMap(); + final File file = File(path); + if (!file.existsSync()) { + stderr.writeln('strings.xml not found: $path'); + return result; + } + final String content = file.readAsStringSync(); + for (final RegExpMatch m in _entryRe.allMatches(content)) { + result[m.group(1)!] = _xmlUnescape(m.group(2)!); + } + return result; +} + +String _xmlUnescape(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll(''', "'"); + +// --------------------------------------------------------------------------- +// Dart generation +// --------------------------------------------------------------------------- + +void _writeDart(String path, LinkedHashMap entries) { + final StringBuffer buf = StringBuffer(); + buf.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND.'); + buf.writeln('//'); + buf.writeln('// Run: dart run tool/generate_strings.dart'); + buf.writeln(); + buf.writeln('// ignore_for_file: lines_longer_than_80_chars'); + buf.writeln(); + buf.writeln('class AppStrings {'); + buf.writeln(' AppStrings._();'); + buf.writeln(); + for (final MapEntry e in entries.entries) { + final String dartKey = _snakeToCamel(e.key); + final String escaped = _dartEscape(e.value); + buf.writeln(" static const String $dartKey = '$escaped';"); + } + buf.writeln('}'); + + final File file = File(path); + file.parent.createSync(recursive: true); + file.writeAsStringSync(buf.toString()); +} + +String _dartEscape(String s) => s + .replaceAll('\\', '\\\\') + .replaceAll('\$', '\\\$') + .replaceAll("'", "\\'"); + +// --------------------------------------------------------------------------- +// iOS Localizable.strings generation +// --------------------------------------------------------------------------- + +void _writeIos(String path, LinkedHashMap entries) { + final StringBuffer buf = StringBuffer(); + buf.writeln('/* GENERATED CODE - DO NOT MODIFY BY HAND. */'); + buf.writeln('/* Run: dart run tool/generate_strings.dart */'); + buf.writeln(); + for (final MapEntry e in entries.entries) { + final String escaped = _iosEscape(e.value); + buf.writeln('"${e.key}" = "$escaped";'); + } + + final File file = File(path); + file.parent.createSync(recursive: true); + file.writeAsStringSync(buf.toString()); +} + +String _iosEscape(String s) => s + .replaceAll('\\', '\\\\') + .replaceAll('"', '\\"') + .replaceAll('\n', '\\n'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +String _snakeToCamel(String key) { + final List parts = key.split('_').where((String p) => p.isNotEmpty).toList(growable: false); + if (parts.isEmpty) return key; + final String first = parts.first; + if (parts.length == 1) return first; + final String rest = parts.skip(1).map((String p) => p[0].toUpperCase() + p.substring(1)).join(); + return '$first$rest'; +}