diff --git a/android/src/main/java/com/onesignal/flutter/OneSignalUser.java b/android/src/main/java/com/onesignal/flutter/OneSignalUser.java index f5facb0b..99869e67 100644 --- a/android/src/main/java/com/onesignal/flutter/OneSignalUser.java +++ b/android/src/main/java/com/onesignal/flutter/OneSignalUser.java @@ -63,9 +63,6 @@ private void handleMethodCall(MethodCall call, Result result) { private void setLanguage(MethodCall call, Result result) { String language = call.argument("language"); - if (language != null && language.length() == 0) { - language = null; - } OneSignal.getUser().setLanguage(language); replySuccess(result, null); } diff --git a/examples/build.md b/examples/build.md index a0bf28aa..bc78bddf 100644 --- a/examples/build.md +++ b/examples/build.md @@ -140,7 +140,7 @@ if (_externalUserId != null) { } ``` -The cached `externalUserId` is the only value that drives an SDK call inside `loadInitialState()` -- it triggers `OneSignal.login(...)` so the SDK identity matches the persisted UI state. Push subscription id, opted-in flag, and permission are then read from the live SDK state, and the OneSignal ID is fetched via `OneSignal.User.getOnesignalId()`. +The cached `externalUserId` is the only value that drives an SDK call inside `loadInitialState()` -- it triggers `OneSignal.login(...)` so the SDK identity matches the persisted UI state. Push subscription id, opted-in flag, token, and permission are then read from the live SDK state, and the OneSignal ID is fetched via `OneSignal.User.getOnesignalId()`. ### Observers @@ -162,6 +162,13 @@ OneSignal.User.addObserver(...) - No global loading overlay. `vm.isLoading` is passed to `PairList(loading: ...)` in **aliases**, **emails**, **sms**, and **tags** sections only. `user_section.dart` has no loading UI. - The viewmodel uses a request-sequence counter (`_fetchSequence`) so stale REST results are dropped when a newer fetch is already in flight. +### Push Status +- The Push section shows a read-only **Status** row above the **Opted In** toggle: `Subscribed` when `pushSubscription.optedIn` is true, notification permission is granted, and a push token is present; otherwise `Unsubscribed`. `AppViewModel.isPushSubscribed` derives this from the push subscription and permission observers. + +### Language +- The User section has a **Language** dropdown (`Device Default` plus the shared language list) after External ID. The selection is cached in `PreferencesService` and reset to `Device Default` on logout. +- `Device Default` calls `OneSignal.User.setLanguage('')`, which the native SDK resolves to the device language. + ### SnackBar Messages - `AppSnackBar` extension on `BuildContext` defined in `lib/theme.dart` exposes `context.showSnackBar(message)`. - The extension calls `ScaffoldMessenger.of(this)..hideCurrentSnackBar()..showSnackBar(SnackBar(content: Text(message), duration: _toastDuration))` for replace-on-show behavior. diff --git a/examples/demo-pods/lib/services/preferences_service.dart b/examples/demo-pods/lib/services/preferences_service.dart index 7fe89283..4df34f7c 100644 --- a/examples/demo-pods/lib/services/preferences_service.dart +++ b/examples/demo-pods/lib/services/preferences_service.dart @@ -6,6 +6,7 @@ class PreferencesService { static const _keyExternalUserId = 'external_user_id'; static const _keyLocationShared = 'location_shared'; static const _keyIamPaused = 'iam_paused'; + static const _keyLanguage = 'language'; late final SharedPreferences _prefs; @@ -39,4 +40,9 @@ class PreferencesService { bool get iamPaused => _prefs.getBool(_keyIamPaused) ?? false; Future setIamPaused(bool value) => _prefs.setBool(_keyIamPaused, value); + + // Language + String get language => _prefs.getString(_keyLanguage) ?? ''; + Future setLanguage(String value) => + _prefs.setString(_keyLanguage, value); } diff --git a/examples/demo-pods/lib/viewmodels/app_viewmodel.dart b/examples/demo-pods/lib/viewmodels/app_viewmodel.dart index 18d4429d..3ce94a82 100644 --- a/examples/demo-pods/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo-pods/lib/viewmodels/app_viewmodel.dart @@ -76,6 +76,17 @@ class AppViewModel extends ChangeNotifier { bool _hasNotificationPermission = false; bool get hasNotificationPermission => _hasNotificationPermission; + String? _pushToken; + + bool get isPushSubscribed => + _pushEnabled && + _hasNotificationPermission && + (_pushToken?.isNotEmpty ?? false); + + // Empty means device default. + String _language = ''; + String get language => _language; + // IAM state bool _iamPaused = false; bool get iamPaused => _iamPaused; @@ -132,6 +143,7 @@ class AppViewModel extends ChangeNotifier { _iamPaused = _prefs.iamPaused; _locationShared = _prefs.locationShared; + _language = _prefs.language; if (_externalUserId != null) { OneSignal.login(_externalUserId!); @@ -139,6 +151,7 @@ class AppViewModel extends ChangeNotifier { _pushSubscriptionId = OneSignal.User.pushSubscription.id; _pushEnabled = OneSignal.User.pushSubscription.optedIn ?? false; + _pushToken = OneSignal.User.pushSubscription.token; _hasNotificationPermission = OneSignal.Notifications.permission; final onesignalId = await OneSignal.User.getOnesignalId(); @@ -165,6 +178,7 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.pushSubscription.addObserver((state) { _pushSubscriptionId = state.current.id; _pushEnabled = state.current.optedIn; + _pushToken = state.current.token; String fmtToken(String? t) { if (t == null || t.isEmpty) return 'null'; @@ -255,12 +269,29 @@ class AppViewModel extends ChangeNotifier { try { await OneSignal.logout(); await _prefs.setExternalUserId(null); + await setLanguage(''); debugPrint('Logged out'); } catch (e) { debugPrint('Logout error: $e'); } } + // Language + Future setLanguage(String language) async { + _language = language; + notifyListeners(); + + try { + await OneSignal.User.setLanguage(language); + await _prefs.setLanguage(language); + debugPrint( + 'Language set to: ${language.isEmpty ? 'device default' : language}', + ); + } catch (e) { + debugPrint('Set language error: $e'); + } + } + // Consent Future setConsentRequired(bool value) async { _consentRequired = value; diff --git a/examples/demo-pods/lib/widgets/sections/push_section.dart b/examples/demo-pods/lib/widgets/sections/push_section.dart index ee9b3aee..b0b0aa6a 100644 --- a/examples/demo-pods/lib/widgets/sections/push_section.dart +++ b/examples/demo-pods/lib/widgets/sections/push_section.dart @@ -51,8 +51,26 @@ class PushSection extends StatelessWidget { ], ), const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Status', + style: Theme.of(context).textTheme.bodyMedium, + ), + Semantics( + identifier: 'push_subscription_status', + container: true, + child: Text( + vm.isPushSubscribed ? 'Subscribed' : 'Unsubscribed', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const Divider(), ToggleRow( - label: 'Enabled', + label: 'Opted In', value: vm.pushEnabled, semanticsLabel: 'push_enabled_toggle', onChanged: vm.hasNotificationPermission diff --git a/examples/demo-pods/lib/widgets/sections/user_section.dart b/examples/demo-pods/lib/widgets/sections/user_section.dart index ea5fd715..48fb2968 100644 --- a/examples/demo-pods/lib/widgets/sections/user_section.dart +++ b/examples/demo-pods/lib/widgets/sections/user_section.dart @@ -7,6 +7,53 @@ import '../dialogs.dart'; import '../action_button.dart'; import '../section_card.dart'; +const _languageOptions = [ + ('Device Default', ''), + ('English', 'en'), + ('Arabic', 'ar'), + ('Azerbaijani', 'az'), + ('Bosnian', 'bs'), + ('Catalan', 'ca'), + ('Chinese (Simplified)', 'zh-Hans'), + ('Chinese (Traditional)', 'zh-Hant'), + ('Croatian', 'hr'), + ('Czech', 'cs'), + ('Danish', 'da'), + ('Dutch', 'nl'), + ('Estonian', 'et'), + ('Finnish', 'fi'), + ('French', 'fr'), + ('Georgian', 'ka'), + ('Bulgarian', 'bg'), + ('German', 'de'), + ('Greek', 'el'), + ('Hindi', 'hi'), + ('Hebrew', 'he'), + ('Hungarian', 'hu'), + ('Indonesian', 'id'), + ('Italian', 'it'), + ('Japanese', 'ja'), + ('Korean', 'ko'), + ('Latvian', 'lv'), + ('Lithuanian', 'lt'), + ('Malay', 'ms'), + ('Norwegian', 'nb'), + ('Persian', 'fa'), + ('Polish', 'pl'), + ('Portuguese', 'pt'), + ('Punjabi', 'pa'), + ('Romanian', 'ro'), + ('Russian', 'ru'), + ('Serbian', 'sr'), + ('Slovak', 'sk'), + ('Spanish', 'es'), + ('Swedish', 'sv'), + ('Thai', 'th'), + ('Turkish', 'tr'), + ('Ukrainian', 'uk'), + ('Vietnamese', 'vi'), +]; + class UserSection extends StatelessWidget { final VoidCallback? onInfoTap; @@ -68,6 +115,38 @@ class UserSection extends StatelessWidget { ), ], ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Language', + style: Theme.of(context).textTheme.bodyMedium, + ), + Semantics( + identifier: 'user_language_dropdown', + container: true, + child: DropdownButton( + value: + _languageOptions.any((o) => o.$2 == vm.language) + ? vm.language + : '', + isDense: true, + menuMaxHeight: 320, + alignment: AlignmentDirectional.centerEnd, + underline: const SizedBox.shrink(), + style: Theme.of(context).textTheme.bodySmall, + items: [ + for (final (name, code) in _languageOptions) + DropdownMenuItem(value: code, child: Text(name)), + ], + onChanged: (code) { + if (code != null) vm.setLanguage(code); + }, + ), + ), + ], + ), ], ), ), diff --git a/examples/demo/lib/services/preferences_service.dart b/examples/demo/lib/services/preferences_service.dart index 7fe89283..4df34f7c 100644 --- a/examples/demo/lib/services/preferences_service.dart +++ b/examples/demo/lib/services/preferences_service.dart @@ -6,6 +6,7 @@ class PreferencesService { static const _keyExternalUserId = 'external_user_id'; static const _keyLocationShared = 'location_shared'; static const _keyIamPaused = 'iam_paused'; + static const _keyLanguage = 'language'; late final SharedPreferences _prefs; @@ -39,4 +40,9 @@ class PreferencesService { bool get iamPaused => _prefs.getBool(_keyIamPaused) ?? false; Future setIamPaused(bool value) => _prefs.setBool(_keyIamPaused, value); + + // Language + String get language => _prefs.getString(_keyLanguage) ?? ''; + Future setLanguage(String value) => + _prefs.setString(_keyLanguage, value); } diff --git a/examples/demo/lib/viewmodels/app_viewmodel.dart b/examples/demo/lib/viewmodels/app_viewmodel.dart index 86ee62d9..794ed269 100644 --- a/examples/demo/lib/viewmodels/app_viewmodel.dart +++ b/examples/demo/lib/viewmodels/app_viewmodel.dart @@ -88,6 +88,17 @@ class AppViewModel extends ChangeNotifier { bool _hasNotificationPermission = false; bool get hasNotificationPermission => _hasNotificationPermission; + String? _pushToken; + + bool get isPushSubscribed => + _pushEnabled && + _hasNotificationPermission && + (_pushToken?.isNotEmpty ?? false); + + // Empty means device default. + String _language = ''; + String get language => _language; + // IAM state bool _iamPaused = false; bool get iamPaused => _iamPaused; @@ -144,6 +155,7 @@ class AppViewModel extends ChangeNotifier { _iamPaused = _prefs.iamPaused; _locationShared = _prefs.locationShared; + _language = _prefs.language; if (_externalUserId != null) { OneSignal.login(_externalUserId!); @@ -151,6 +163,7 @@ class AppViewModel extends ChangeNotifier { _pushSubscriptionId = OneSignal.User.pushSubscription.id; _pushEnabled = OneSignal.User.pushSubscription.optedIn ?? false; + _pushToken = OneSignal.User.pushSubscription.token; _hasNotificationPermission = OneSignal.Notifications.permission; final onesignalId = await OneSignal.User.getOnesignalId(); @@ -171,6 +184,7 @@ class AppViewModel extends ChangeNotifier { OneSignal.User.pushSubscription.addObserver((state) { _pushSubscriptionId = state.current.id; _pushEnabled = state.current.optedIn; + _pushToken = state.current.token; String fmtToken(String? t) { if (t == null || t.isEmpty) return 'null'; @@ -291,12 +305,29 @@ class AppViewModel extends ChangeNotifier { try { await OneSignal.logout(); await _prefs.setExternalUserId(null); + await setLanguage(''); LogService.info('Logged out'); } catch (e) { LogService.error('Logout error: $e'); } } + // Language + Future setLanguage(String language) async { + _language = language; + notifyListeners(); + + try { + await OneSignal.User.setLanguage(language); + await _prefs.setLanguage(language); + LogService.info( + 'Language set to: ${language.isEmpty ? 'device default' : language}', + ); + } catch (e) { + LogService.error('Set language error: $e'); + } + } + // Consent Future setConsentRequired(bool value) async { _consentRequired = value; diff --git a/examples/demo/lib/widgets/sections/push_section.dart b/examples/demo/lib/widgets/sections/push_section.dart index ee9b3aee..b0b0aa6a 100644 --- a/examples/demo/lib/widgets/sections/push_section.dart +++ b/examples/demo/lib/widgets/sections/push_section.dart @@ -51,8 +51,26 @@ class PushSection extends StatelessWidget { ], ), const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Status', + style: Theme.of(context).textTheme.bodyMedium, + ), + Semantics( + identifier: 'push_subscription_status', + container: true, + child: Text( + vm.isPushSubscribed ? 'Subscribed' : 'Unsubscribed', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + const Divider(), ToggleRow( - label: 'Enabled', + label: 'Opted In', value: vm.pushEnabled, semanticsLabel: 'push_enabled_toggle', onChanged: vm.hasNotificationPermission diff --git a/examples/demo/lib/widgets/sections/user_section.dart b/examples/demo/lib/widgets/sections/user_section.dart index efd53193..8e1a23b3 100644 --- a/examples/demo/lib/widgets/sections/user_section.dart +++ b/examples/demo/lib/widgets/sections/user_section.dart @@ -7,6 +7,53 @@ import '../dialogs.dart'; import '../action_button.dart'; import '../section_card.dart'; +const _languageOptions = [ + ('Device Default', ''), + ('English', 'en'), + ('Arabic', 'ar'), + ('Azerbaijani', 'az'), + ('Bosnian', 'bs'), + ('Catalan', 'ca'), + ('Chinese (Simplified)', 'zh-Hans'), + ('Chinese (Traditional)', 'zh-Hant'), + ('Croatian', 'hr'), + ('Czech', 'cs'), + ('Danish', 'da'), + ('Dutch', 'nl'), + ('Estonian', 'et'), + ('Finnish', 'fi'), + ('French', 'fr'), + ('Georgian', 'ka'), + ('Bulgarian', 'bg'), + ('German', 'de'), + ('Greek', 'el'), + ('Hindi', 'hi'), + ('Hebrew', 'he'), + ('Hungarian', 'hu'), + ('Indonesian', 'id'), + ('Italian', 'it'), + ('Japanese', 'ja'), + ('Korean', 'ko'), + ('Latvian', 'lv'), + ('Lithuanian', 'lt'), + ('Malay', 'ms'), + ('Norwegian', 'nb'), + ('Persian', 'fa'), + ('Polish', 'pl'), + ('Portuguese', 'pt'), + ('Punjabi', 'pa'), + ('Romanian', 'ro'), + ('Russian', 'ru'), + ('Serbian', 'sr'), + ('Slovak', 'sk'), + ('Spanish', 'es'), + ('Swedish', 'sv'), + ('Thai', 'th'), + ('Turkish', 'tr'), + ('Ukrainian', 'uk'), + ('Vietnamese', 'vi'), +]; + class UserSection extends StatelessWidget { final VoidCallback? onInfoTap; @@ -68,6 +115,38 @@ class UserSection extends StatelessWidget { ), ], ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Language', + style: Theme.of(context).textTheme.bodyMedium, + ), + Semantics( + identifier: 'user_language_dropdown', + container: true, + child: DropdownButton( + value: + _languageOptions.any((o) => o.$2 == vm.language) + ? vm.language + : '', + isDense: true, + menuMaxHeight: 320, + alignment: AlignmentDirectional.centerEnd, + underline: const SizedBox.shrink(), + style: Theme.of(context).textTheme.bodySmall, + items: [ + for (final (name, code) in _languageOptions) + DropdownMenuItem(value: code, child: Text(name)), + ], + onChanged: (code) { + if (code != null) vm.setLanguage(code); + }, + ), + ), + ], + ), ], ), ),