Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/core/data/data_source/config_data_source.dart
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ class ConfigDataSource {
await _update((c) => c.selectedLocale = locale);
}

Future<bool> getLocaleSyncSeeded() async {
return _readMerged().localeSyncSeeded ?? false;
}

Future<void> setLocaleSyncSeeded() async {
await _update((c) => c.localeSyncSeeded = true);
}

Future<void> setConfigShowMicronutrients(bool show) async {
await _update((c) => c.showMicronutrients = show);
}
Expand Down
9 changes: 9 additions & 0 deletions lib/core/data/dbo/config_dbo.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ class ConfigDBO extends HiveObject {
// nothing has been deleted.
@HiveField(36)
List<String>? healthDeletedExternalIds;
// One-shot marker for [reconcileAppLocale]'s migration push: set once the
// saved language has been offered to Android's per-app language picker (or
// the picker was seen holding a value). Null means the push has not
// happened yet. Without it, an override the user cleared in the OS picker
// is indistinguishable from one that was never seeded, and gets silently
// pushed back on the next launch.
@HiveField(37)
bool? localeSyncSeeded;

ConfigDBO(
this.hasAcceptedDisclaimer,
Expand Down Expand Up @@ -188,6 +196,7 @@ class ConfigDBO extends HiveObject {
this.healthWorkoutKcalMultiplier,
this.healthLastImportAt,
this.healthDeletedExternalIds,
this.localeSyncSeeded,
});

factory ConfigDBO.empty() =>
Expand Down
9 changes: 7 additions & 2 deletions lib/core/data/dbo/config_dbo.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions lib/core/data/repository/config_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ class ConfigRepository {
await _configDataSource.setSelectedLocale(locale);
}

Future<bool> getLocaleSyncSeeded() async {
return await _configDataSource.getLocaleSyncSeeded();
}

Future<void> setLocaleSyncSeeded() async {
await _configDataSource.setLocaleSyncSeeded();
}

Future<void> setConfigShowMicronutrients(bool show) async {
await _configDataSource.setConfigShowMicronutrients(show);
}
Expand Down
22 changes: 16 additions & 6 deletions lib/core/utils/app_locale_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,25 @@ import 'package:flutter/services.dart';
class AppLocaleService {
static const _channel = MethodChannel('com.opennutritracker/locale');

/// The language tag the user chose in Android's Settings, or null when they
/// have not overridden it and the app should follow its own saved choice.
static Future<String?> getApplicationLocale() async {
/// The language tag the user chose in Android's Settings, or a null tag
/// when they have not overridden it and the app should follow its own saved
/// choice.
///
/// `readFailed` separates "the platform says there is no override" from
/// "the platform could not be asked". [reconcileAppLocale] treats a missing
/// override as the user having cleared it, so a transient channel failure
/// reported as a plain null would destroy their saved language.
/// [MissingPluginException] is not a failure: no registered handler means a
/// platform with no per-app language setting at all, where "no override" is
/// the honest answer.
static Future<({String? tag, bool readFailed})> getApplicationLocale() async {
try {
return await _channel.invokeMethod<String?>('getApplicationLocale');
final tag = await _channel.invokeMethod<String?>('getApplicationLocale');
return (tag: tag, readFailed: false);
} on PlatformException {
return null;
return (tag: null, readFailed: true);
} on MissingPluginException {
return null;
return (tag: null, readFailed: false);
}
}

Expand Down
75 changes: 64 additions & 11 deletions lib/core/utils/app_locale_sync.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,82 @@ import 'dart:ui';
/// and Settings -> Apps -> OpenNutriTracker -> Language. Whichever one someone
/// reaches for, they should get the same answer afterwards.
///
/// The system override wins when it exists, because it is the one the user can
/// see from outside the app. When it does not exist but we have a saved choice
/// -- an install that predates this wiring, or a fresh one where only the
/// in-app picker has been used -- the saved choice is pushed out to the system
/// so both sides start from the same place.
/// The cases are decided on the raw [systemLocaleTag] rather than on the
/// language it resolves to, because "no override at all" and "an override we
/// cannot render" mean opposite things. [localeSyncSeeded] records one fact
/// and only that fact: **the OS has been seen holding an override** -- it is
/// what makes a later absence readable as the user having cleared it. It is
/// never set on a platform that reports no override, because there the
/// absence is permanent and means nothing.
///
/// Off Android and below API 33 there is no system override at all, and this
/// collapses to returning the saved choice untouched.
/// - **A supported override.** The system wins -- it is the side the user can
/// see from outside the app -- and is saved if it differs. The OS holds a
/// value, so seeded is recorded.
/// - **A tag we do not ship** (`ja`, say). Neither side is followed: the app
/// cannot render that language, and the saved choice is not pushed over it
/// either -- overwriting an explicit OS-level choice is a louder wrong
/// answer than ignoring it. The OS still demonstrably holds a value, so
/// seeded is recorded; switching it to System default later reads as the
/// clear it is.
/// - **The read failed.** Nothing is decided on a failed read: the saved
/// choice is returned untouched. Treating it as "no override" would clear
/// a language the user never cleared.
/// - **No override, never seeded.** The upgrade path: an install that
/// predates this wiring, or one where only the in-app picker has been
/// used. The saved choice is pushed out so both sides start from the same
/// place. Seeded is recorded only by *observing* the pushed value, read
/// back in this same call -- never by the attempt alone. On Android 13+
/// the read-back sees the value immediately, so the record lands before
/// this call returns and a clear arriving any time after it is honoured;
/// on platforms with no per-app override the push no-ops, the read-back
/// stays empty, and this branch harmlessly repeats instead of ever
/// mistaking the platform for a user who cleared it.
/// - **No override, seeded.** The OS held a value and no longer does, which
/// only happens when the user picked "System default" in Android's picker.
/// That is a deliberate choice, so the in-app override is cleared to
/// follow it -- without the seeded record this case is indistinguishable
/// from the one above and the cleared value got silently pushed back on
/// the next cold start.
Future<String?> reconcileAppLocale({
required String? savedLocaleCode,
required String? systemLocaleTag,
required bool systemTagReadFailed,
required bool localeSyncSeeded,
required Iterable<Locale> supportedLocales,
required Future<void> Function(String? localeCode) persistSelectedLocale,
required Future<void> Function(String? languageTag) pushToSystem,
required Future<({String? tag, bool readFailed})> Function() readSystemTag,
required Future<void> Function() markLocaleSyncSeeded,
}) async {
if (systemTagReadFailed) return savedLocaleCode;

final systemCode = supportedLanguageCode(systemLocaleTag, supportedLocales);

if (systemCode != null) {
if (systemCode != savedLocaleCode) await persistSelectedLocale(systemCode);
if (!localeSyncSeeded) await markLocaleSyncSeeded();
return systemCode;
}

if (savedLocaleCode != null) await pushToSystem(savedLocaleCode);
return savedLocaleCode;
final hasSystemTag = systemLocaleTag != null && systemLocaleTag.isNotEmpty;
if (hasSystemTag) {
if (!localeSyncSeeded) await markLocaleSyncSeeded();
return savedLocaleCode;
}

if (!localeSyncSeeded) {
if (savedLocaleCode != null) {
await pushToSystem(savedLocaleCode);
final verify = await readSystemTag();
if (!verify.readFailed && verify.tag != null && verify.tag!.isNotEmpty) {
await markLocaleSyncSeeded();
}
}
return savedLocaleCode;
}

if (savedLocaleCode != null) await persistSelectedLocale(null);
return null;
}

/// The supported language code behind a platform language tag, or null when
Expand All @@ -47,7 +99,8 @@ String? supportedLanguageCode(
if (languageTag == null || languageTag.isEmpty) return null;
final code = languageTag.split(RegExp(r'[-_]')).first.toLowerCase();
if (code.isEmpty) return null;
final isSupported =
supportedLocales.any((locale) => locale.languageCode == code);
final isSupported = supportedLocales.any(
(locale) => locale.languageCode == code,
);
return isSupported ? code : null;
}
7 changes: 6 additions & 1 deletion lib/dev/main_dev.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,17 @@ Future<void> main() async {
final config = await configRepo.getConfig();
// Mirrors the reconciliation in main.dart so a dev build behaves the same
// way when the OS holds a per-app language.
final systemLocale = await AppLocaleService.getApplicationLocale();
final localeCode = await reconcileAppLocale(
savedLocaleCode: await configRepo.getSelectedLocale(),
systemLocaleTag: await AppLocaleService.getApplicationLocale(),
systemLocaleTag: systemLocale.tag,
systemTagReadFailed: systemLocale.readFailed,
localeSyncSeeded: await configRepo.getLocaleSyncSeeded(),
supportedLocales: S.supportedLocales,
persistSelectedLocale: configRepo.setSelectedLocale,
pushToSystem: AppLocaleService.setApplicationLocale,
readSystemTag: AppLocaleService.getApplicationLocale,
markLocaleSyncSeeded: configRepo.setLocaleSyncSeeded,
);
final savedLocale = localeCode != null ? Locale(localeCode) : null;
final savedAppTheme = await configRepo.getConfigAppTheme();
Expand Down
66 changes: 58 additions & 8 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,17 @@ Future<void> _bootstrapApp() async {
// Android's own per-app language picker and ours are two doors into the
// same setting, so ask the system what it holds before trusting what we
// saved. See [reconcileAppLocale] for which side wins and why.
final systemLocale = await AppLocaleService.getApplicationLocale();
final localeCode = await reconcileAppLocale(
savedLocaleCode: await configRepo.getSelectedLocale(),
systemLocaleTag: await AppLocaleService.getApplicationLocale(),
systemLocaleTag: systemLocale.tag,
systemTagReadFailed: systemLocale.readFailed,
localeSyncSeeded: await configRepo.getLocaleSyncSeeded(),
supportedLocales: S.supportedLocales,
persistSelectedLocale: configRepo.setSelectedLocale,
pushToSystem: AppLocaleService.setApplicationLocale,
readSystemTag: AppLocaleService.getApplicationLocale,
markLocaleSyncSeeded: configRepo.setLocaleSyncSeeded,
);
final savedLocale = localeCode != null ? Locale(localeCode) : null;

Expand Down Expand Up @@ -235,18 +240,63 @@ class _OpenNutriTrackerAppState extends State<OpenNutriTrackerApp>
unawaited(_adoptSystemLocale());
}

/// True while [_adoptSystemLocale] is between its awaits; a second entry
/// then only queues a re-run instead of racing the first.
bool _adoptingSystemLocale = false;
bool _adoptSystemLocaleQueued = false;

/// The same [reconcileAppLocale] the launch path runs, deliberately: this
/// fires for any locale change — the per-app picker, but also a plain
/// system-language change on platforms with no per-app override — and a
/// second hand-rolled copy of the decision table here could disagree with
/// the launch one about the same OS state within one session. Only the
/// screen update is local; every persistence decision stays in one place.
///
/// Serialized against itself: reconciling can change the locale (the
/// migration push), which re-fires [didChangeLocales] while the first run
/// is still mid-flight. Overlapping runs would read the same pre-write
/// config snapshot and double-apply it, so a run arriving early is queued
/// and replayed once the current one finishes — dropped instead of
/// queued, it could miss a clear that arrived mid-run.
Future<void> _adoptSystemLocale() async {
final systemCode = supportedLanguageCode(
await AppLocaleService.getApplicationLocale(),
S.supportedLocales,
if (_adoptingSystemLocale) {
_adoptSystemLocaleQueued = true;
return;
}
_adoptingSystemLocale = true;
try {
do {
_adoptSystemLocaleQueued = false;
await _reconcileAndAdoptLocale();
} while (_adoptSystemLocaleQueued);
} finally {
_adoptingSystemLocale = false;
}
}

Future<void> _reconcileAndAdoptLocale() async {
final configRepo = locator<ConfigRepository>();
final systemLocale = await AppLocaleService.getApplicationLocale();
final localeCode = await reconcileAppLocale(
savedLocaleCode: await configRepo.getSelectedLocale(),
systemLocaleTag: systemLocale.tag,
systemTagReadFailed: systemLocale.readFailed,
localeSyncSeeded: await configRepo.getLocaleSyncSeeded(),
supportedLocales: S.supportedLocales,
persistSelectedLocale: configRepo.setSelectedLocale,
pushToSystem: AppLocaleService.setApplicationLocale,
readSystemTag: AppLocaleService.getApplicationLocale,
markLocaleSyncSeeded: configRepo.setLocaleSyncSeeded,
);
if (systemCode == null || !mounted) return;
// Persistence above deliberately ran to completion regardless of widget
// lifetime — what is saved must not depend on whether this State is
// still mounted. Only the on-screen update needs the live context.
if (!mounted) return;

final localeProvider = Provider.of<LocaleProvider>(context, listen: false);
if (localeProvider.locale?.languageCode == systemCode) return;
if (localeProvider.locale?.languageCode == localeCode) return;

localeProvider.updateLocale(Locale(systemCode));
await locator<ConfigRepository>().setSelectedLocale(systemCode);
localeProvider.updateLocale(localeCode != null ? Locale(localeCode) : null);
}

@override
Expand Down
23 changes: 19 additions & 4 deletions test/unit_test/app_locale_service_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ void main() {
test('reads the language the OS holds', () async {
handleWith((_) async => 'pl');

expect(await AppLocaleService.getApplicationLocale(), 'pl');
expect(await AppLocaleService.getApplicationLocale(), (
tag: 'pl',
readFailed: false,
));
expect(calls.single.method, 'getApplicationLocale');
});

Expand All @@ -53,19 +56,31 @@ void main() {
expect(calls.single.arguments, {'tag': null});
});

test('a platform failure does not escape', () async {
// The read must say it failed, not answer "no override": downstream, a
// missing override from a working read means the user cleared their
// language, and a transient failure must never read as that.
test('a platform failure does not escape, and says it failed', () async {
handleWith((_) => throw PlatformException(code: 'unavailable'));

await expectLater(AppLocaleService.getApplicationLocale(), completion(isNull));
expect(await AppLocaleService.getApplicationLocale(), (
tag: null,
readFailed: true,
));
await expectLater(AppLocaleService.setApplicationLocale('de'), completes);
});
});

// Nothing on the other side of the channel is the normal case on iOS, on
// desktop, and in any widget test that pumps the settings screen. Failing
// there would break a screen someone opened to fix their language.
// Reported as a clean "no override", not as a failure: on these platforms
// the absence is permanent and true, and marking it failed would freeze
// locale handling there forever.
test('a missing native side is not an error', () async {
expect(await AppLocaleService.getApplicationLocale(), isNull);
expect(await AppLocaleService.getApplicationLocale(), (
tag: null,
readFailed: false,
));
await expectLater(AppLocaleService.setApplicationLocale('de'), completes);
});
}
Loading
Loading