Skip to content

Commit 7a6874c

Browse files
committed
fix(examples): retry on recipients=0 response
1 parent 1101b49 commit 7a6874c

9 files changed

Lines changed: 127 additions & 84 deletions

File tree

examples/build.md

Lines changed: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,14 @@ rm assets/onesignal_logo_icon_padded.png
3636
dependencies:
3737
flutter:
3838
sdk: flutter
39-
onesignal_flutter: ^5.4.0
39+
onesignal_flutter:
40+
path: ../../
4041
provider: ^6.1.0
4142
shared_preferences: ^2.3.0
4243
http: ^1.2.0
44+
url_launcher: ^6.2.0
4345
flutter_svg: ^2.0.0
46+
flutter_dotenv: ^5.2.1
4447
4548
dev_dependencies:
4649
flutter_test:
@@ -57,6 +60,20 @@ flutter_launcher_icons:
5760
adaptive_icon_foreground: "assets/onesignal_logo_icon_padded.png"
5861
```
5962

63+
`assets/onesignal_logo_icon_padded.png` is referenced in `pubspec.yaml` but is not checked in -- it is generated via `flutter_launcher_icons` from `assets/onesignal_logo.svg`. Remove the generated PNG after icon generation if desired.
64+
65+
### Environment variables
66+
67+
- `flutter_dotenv` loads `.env` from the `pubspec.yaml` `assets:` block (`.env` is listed alongside `assets/onesignal_logo.svg`).
68+
- `ONESIGNAL_APP_ID` -- OneSignal app id. Falls back to the hard-coded `_defaultAppId` in `main.dart` when unset or empty.
69+
- `ONESIGNAL_API_KEY` -- REST API key used by `OneSignalApiService` for Live Activity update/end and notification sends.
70+
- `ONESIGNAL_ANDROID_CHANNEL_ID` -- optional Android notification channel id used by the REST send paths.
71+
72+
### Theme types
73+
74+
- `lib/theme.dart` exports `AppSpacing`, `AppColors`, and `AppTheme` classes alongside the `AppSnackBar` extension on `BuildContext`.
75+
- `AppTheme.light` is the Material 3 `ThemeData` applied to `MaterialApp`.
76+
6077
---
6178

6279
## State Management
@@ -68,9 +85,12 @@ Use **Provider** for dependency injection and **ChangeNotifier** for reactive st
6885
- Exposes action methods that update state and call `notifyListeners()`
6986
- Receives `OneSignalApiService` and `PreferencesService` via constructor injection
7087
- Initialize OneSignal SDK before `runApp()`
71-
- Use `Consumer`/`Selector` from Provider to scope rebuilds and minimize re-renders
88+
- Section widgets read the viewmodel via `context.watch<AppViewModel>()` (for rebuilds) or `context.read<AppViewModel>()` (one-shot, for callbacks). No `Consumer`/`Selector` usage anywhere in `lib/`.
7289
- SDK calls (`OneSignal.User.*`, `OneSignal.Notifications.*`, `OneSignal.InAppMessages.*`, etc.) are invoked directly from `AppViewModel`. There is no repository wrapper.
73-
- `OneSignalApiService` is a plain Dart class that owns the OneSignal REST API calls (send notification, live activity update/end, fetch user). Not a ChangeNotifier.
90+
91+
### REST client
92+
93+
- `OneSignalApiService` is a plain Dart class (not a `ChangeNotifier`) that owns the OneSignal REST API calls -- send notification, fetch user, Live Activity update/end.
7494

7595
### Persistence
7696

@@ -83,21 +103,44 @@ Use **Provider** for dependency injection and **ChangeNotifier** for reactive st
83103

84104
In `main.dart`, restore SDK state from `SharedPreferences` cache BEFORE `initialize`:
85105
```dart
86-
OneSignal.consentRequired(cachedConsentRequired);
87-
OneSignal.consentGiven(cachedPrivacyConsent);
88-
OneSignal.initialize(appId);
106+
OneSignal.Debug.setLogLevel(OSLogLevel.verbose);
107+
OneSignal.consentRequired(prefs.consentRequired);
108+
OneSignal.consentGiven(prefs.privacyConsent);
109+
await OneSignal.initialize(appId);
89110
```
90111

91112
Then AFTER initialize:
92113
```dart
93-
OneSignal.InAppMessages.paused(cachedPausedStatus);
94-
OneSignal.Location.setShared(cachedLocationShared);
114+
OneSignal.LiveActivities.setupDefault(
115+
options: LiveActivitySetupOptions(
116+
enablePushToStart: true,
117+
enablePushToUpdate: true,
118+
),
119+
);
120+
OneSignal.InAppMessages.paused(prefs.iamPaused);
121+
OneSignal.Location.setShared(prefs.locationShared);
122+
```
123+
124+
`main.dart` also registers listeners before `runApp()`:
125+
- IAM: `addWillDisplayListener`, `addDidDisplayListener`, `addWillDismissListener`, `addDidDismissListener`, `addClickListener`
126+
- Notifications: `addClickListener`, `addForegroundWillDisplayListener` (calls `event.notification.display()`)
127+
- `TooltipHelper().init()` fetches tooltip content in the background.
128+
- `appId` falls back to a hard-coded `_defaultAppId` constant when `ONESIGNAL_APP_ID` is not set in `.env`.
129+
130+
`PreferencesService` is the source of truth for restored UI state. In `AppViewModel.loadInitialState(appId)`, read UI state from `_prefs` (not from the SDK):
131+
```dart
132+
_consentRequired = _prefs.consentRequired;
133+
_privacyConsentGiven = _prefs.privacyConsent;
134+
_externalUserId = _prefs.externalUserId;
135+
_iamPaused = _prefs.iamPaused;
136+
_locationShared = _prefs.locationShared;
137+
138+
if (_externalUserId != null) {
139+
OneSignal.login(_externalUserId!);
140+
}
95141
```
96142

97-
In `AppViewModel.loadInitialState()`, read UI state from the SDK (not cached prefs):
98-
- `OneSignal.InAppMessages.arePaused()` for IAM paused state
99-
- `OneSignal.Location.isShared()` for location state
100-
- `OneSignal.User.getExternalId()` for external user ID
143+
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()`.
101144

102145
### Observers
103146

@@ -113,29 +156,42 @@ OneSignal.User.addObserver(...)
113156
## Flutter-Specific UI Details
114157

115158
### Notification Permission
116-
- Call `viewModel.promptPush()` in `initState()` of `HomeScreen`
159+
- Wraps the call in `WidgetsBinding.instance.addPostFrameCallback` inside `initState()` (`home_screen.dart` lines ~39-41) so the prompt fires after the first frame.
117160

118161
### Loading State
119-
- No global loading overlay; render an inline `CircularProgressIndicator` inside the section that owns the in-flight work (e.g. user section while `isLoading` is true).
162+
- 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.
120163
- The viewmodel uses a request-sequence counter (`_fetchSequence`) so stale REST results are dropped when a newer fetch is already in flight.
121164

122165
### SnackBar Messages
123-
- `AppSnackBar` extension on `BuildContext` defined in `theme.dart`
124-
- Call `context.showSnackBar(message)` directly from widget callbacks
125-
- Automatically hides the current SnackBar before showing the new one
126-
127-
### Send In-App Message Icons
128-
- TOP BANNER: `Icons.vertical_align_top`
129-
- BOTTOM BANNER: `Icons.vertical_align_bottom`
130-
- CENTER MODAL: `Icons.crop_square`
131-
- FULL SCREEN: `Icons.fullscreen`
166+
- `AppSnackBar` extension on `BuildContext` defined in `lib/theme.dart` exposes `context.showSnackBar(message)`.
167+
- The extension calls `ScaffoldMessenger.of(this)..hideCurrentSnackBar()..showSnackBar(SnackBar(content: Text(message), duration: _toastDuration))` for replace-on-show behavior.
168+
- Duration is the file-private constant `const Duration _toastDuration = Duration(seconds: 3);` in `theme.dart`.
169+
- Section widgets call `context.showSnackBar(...)` from button callbacks, guarded by `if (context.mounted)`. Only Outcomes, Custom Events, and Location check trigger snackbars; everything else uses `debugPrint(...)`. Outcomes and Custom Events call synchronous viewmodel methods then show the snackbar (no `await` on the SDK). Only Location's `CHECK LOCATION SHARED` button truly awaits (`vm.checkLocationShared()`) before showing the result.
170+
- The `ChangeNotifier` / viewmodel must not hold snackbar state or expose snackbar messages.
132171

133172
### Dialogs
134-
- All dialogs use `insetPadding: EdgeInsets.symmetric(horizontal: 16)` and `SizedBox(width: double.maxFinite)` on content for full-width layout
135-
- `MultiSelectRemoveDialog` uses `CheckboxListTile`
136-
- `TextEditingController`s are properly disposed in `StatefulWidget`s
137-
- JSON parsing via `jsonDecode` returns `Map<String, dynamic>` for Track Event
138-
- Single-field input prompts (e.g. login, add email/SMS, add trigger) reuse a single `SingleInputDialog` widget that takes `title`, `fieldLabel`, `confirmLabel`, and `semanticsLabel`. Avoid creating a per-screen dialog widget when one input is needed.
173+
- The home screen widget owns layout + the tooltip dialog only. Tooltip presentation is via a private method `_showTooltipDialog(BuildContext, String key)` on the home state (`home_screen.dart` line ~44), wired to each section through an `onInfoTap` callback. No `_activeTooltipKey` field, no `ChangeNotifier` involvement.
174+
- Section action dialogs call `showDialog<T>(context: context, builder: ...)` inline inside each button's `onPressed` handler (see `aliases_section.dart`, `outcomes_section.dart`) and `await` the typed result. Only `HomeScreen` has a private `_show*Dialog` method, and only for the tooltip. No `*Open` booleans are required because Flutter presents dialogs imperatively; the awaited result drives the SDK call + optional `context.showSnackBar(...)`.
175+
- Shared dialog widgets live in `lib/widgets/dialogs.dart` (or `lib/widgets/dialogs/`). Reuse the single `SingleInputDialog` widget for any one-field input (takes `title`, `fieldLabel`, `confirmLabel`, `semanticsLabel`) -- do not create per-screen one-field dialogs.
176+
- All dialogs use `insetPadding: EdgeInsets.symmetric(horizontal: 16)` and `SizedBox(width: double.maxFinite)` on content for full-width layout. `MultiSelectRemoveDialog` uses `CheckboxListTile`. `TextEditingController`s are disposed in the dialog's `StatefulWidget`. JSON parsing via `jsonDecode` returns `Map<String, dynamic>` for Track Event.
177+
- `dialogs.dart` also defines `PairInputDialog`, `MultiPairInputDialog`, `OutcomeDialog`, `TrackEventDialog`, `CustomNotificationDialog`, and `TooltipDialog` beyond `SingleInputDialog` and `MultiSelectRemoveDialog`.
178+
- The viewmodel must not hold dialog visibility flags or dialog input drafts.
179+
180+
### Live Activities (iOS only)
181+
- `LiveActivitiesSection` is rendered only when `defaultTargetPlatform == TargetPlatform.iOS` (`home_screen.dart` line ~118).
182+
- `OneSignal.LiveActivities.setupDefault(options: LiveActivitySetupOptions(enablePushToStart: true, enablePushToUpdate: true))` is called in `main.dart` after `OneSignal.initialize`.
183+
- REST update/end is performed via `OneSignalApiService` using `ONESIGNAL_API_KEY`. The update/end buttons are disabled when `vm.hasApiKey` is false.
184+
- While an update is in flight, `isLaUpdating` disables the update button rather than showing a spinner (`live_activities_section.dart`).
185+
- Native sources live at `ios/OneSignalWidget/` (Live Activity widget extension) and `ios/OneSignalNotificationServiceExtension/`.
186+
187+
---
188+
189+
## iOS native config
190+
191+
- `ios/Runner/Info.plist` includes:
192+
- `NSSupportsLiveActivities` (enables ActivityKit-backed Live Activities).
193+
- Location usage strings (`NSLocationWhenInUseUsageDescription`, etc.).
194+
- `UIBackgroundModes` containing `remote-notification` for silent push handling.
139195

140196
---
141197

@@ -182,11 +238,16 @@ examples/demo/
182238
│ ├── custom_events_section.dart
183239
│ ├── live_activities_section.dart
184240
│ └── location_section.dart
241+
├── assets/
242+
│ └── onesignal_logo.svg
185243
├── android/
244+
│ └── app/
245+
│ └── build.gradle.kts
186246
├── ios/
187-
├── pubspec.yaml
188-
├── google-services.json
189-
└── agconnect-services.json
247+
│ ├── OneSignalWidget/
248+
│ └── OneSignalNotificationServiceExtension/
249+
├── .env
250+
└── pubspec.yaml
190251
```
191252
192253
---
@@ -201,5 +262,11 @@ examples/demo/
201262
- **Semantics** widgets for accessibility and Appium test automation
202263
- **Immutable state** where possible; lists exposed as unmodifiable views from the ViewModel
203264
- **Material 3** theming with `ColorScheme.fromSeed`
204-
- **Minimal rebuilds** via `Consumer`/`Selector` from Provider
265+
- **Scoped rebuilds** via `context.watch<AppViewModel>()` in `build`, `context.read<AppViewModel>()` in callbacks
205266
- **No platform channels needed** since the OneSignal Flutter SDK handles all bridging
267+
268+
---
269+
270+
## Sibling examples
271+
272+
- `examples/demo_pods/` exists alongside the pub-based `examples/demo/` in the SDK repo. It is a separate sample whose dependency wiring differs from `demo/`.
Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
1-
import 'package:flutter/material.dart';
2-
31
enum InAppMessageType {
4-
topBanner('Top Banner', 'top_banner', Icons.vertical_align_top),
5-
bottomBanner('Bottom Banner', 'bottom_banner', Icons.vertical_align_bottom),
6-
centerModal('Center Modal', 'center_modal', Icons.crop_square),
7-
fullScreen('Full Screen', 'full_screen', Icons.fullscreen);
2+
topBanner('Top Banner', 'top_banner'),
3+
bottomBanner('Bottom Banner', 'bottom_banner'),
4+
centerModal('Center Modal', 'center_modal'),
5+
fullScreen('Full Screen', 'full_screen');
86

97
final String label;
108
final String triggerValue;
11-
final IconData icon;
129

13-
const InAppMessageType(this.label, this.triggerValue, this.icon);
10+
const InAppMessageType(this.label, this.triggerValue);
1411
}

examples/demo/lib/services/onesignal_api_service.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ class OneSignalApiService {
7979
// Retry while the OneSignal backend hasn't yet indexed the freshly
8080
// created subscription. The /notifications endpoint reports this race in a
8181
// few different shapes, all of which return HTTP 200:
82-
// - {"errors":{"invalid_player_ids":[...]}}
82+
// - {"id":"...","recipients":0} (user just switched, push token not yet attached)
83+
// - {"id":"...","errors":{"invalid_player_ids":[...]}}
8384
// - {"id":"","errors":["All included players are not subscribed"]}
8485
// - {"id":"","errors":[...]}
85-
// Treat any 200 response without a real notification id as transient.
86+
// Treat any 200 response with no real id, populated errors, or recipients=0 as transient.
8687
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
8788
try {
8889
final response = await http.post(
@@ -123,11 +124,13 @@ class OneSignalApiService {
123124
if (decoded is! Map<String, dynamic>) return false;
124125
final id = decoded['id'];
125126
final errors = decoded['errors'];
127+
final recipients = decoded['recipients'];
126128
final hasErrors =
127129
(errors is List && errors.isNotEmpty) ||
128130
(errors is Map && errors.isNotEmpty);
129131
final missingId = id is! String || id.isEmpty;
130-
return hasErrors || missingId;
132+
final zeroRecipients = recipients is num && recipients == 0;
133+
return hasErrors || missingId || zeroRecipients;
131134
}
132135

133136
Future<bool> updateLiveActivity(

examples/demo/lib/theme.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,16 @@ class AppTheme {
108108
AppTheme._();
109109
}
110110

111+
const Duration _toastDuration = Duration(seconds: 3);
112+
111113
extension AppSnackBar on BuildContext {
112114
void showSnackBar(String message) {
113115
ScaffoldMessenger.of(this)
114116
..hideCurrentSnackBar()
115117
..showSnackBar(
116118
SnackBar(
117119
content: Text(message),
120+
duration: _toastDuration,
118121
dismissDirection: DismissDirection.horizontal,
119122
),
120123
);

examples/demo/lib/widgets/sections/send_iam_section.dart

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
44
import '../../models/in_app_message_type.dart';
55
import '../../theme.dart';
66
import '../../viewmodels/app_viewmodel.dart';
7+
import '../action_button.dart';
78
import '../section_card.dart';
89

910
class SendIamSection extends StatelessWidget {
@@ -22,32 +23,10 @@ class SendIamSection extends StatelessWidget {
2223
child: Column(
2324
spacing: AppSpacing.gap,
2425
children: InAppMessageType.values.map((type) {
25-
return Semantics(
26-
identifier: 'send_iam_${type.triggerValue}_button',
27-
container: true,
28-
button: true,
29-
child: SizedBox(
30-
width: double.infinity,
31-
child: ElevatedButton(
32-
onPressed: () => vm.sendInAppMessage(type),
33-
style: ElevatedButton.styleFrom(
34-
backgroundColor: AppColors.osPrimary,
35-
foregroundColor: Colors.white,
36-
minimumSize: const Size(double.infinity, 48),
37-
shape: RoundedRectangleBorder(
38-
borderRadius: BorderRadius.circular(8),
39-
),
40-
),
41-
child: Row(
42-
mainAxisAlignment: MainAxisAlignment.start,
43-
children: [
44-
Icon(type.icon, size: 18),
45-
const SizedBox(width: 8),
46-
Text(type.label.toUpperCase()),
47-
],
48-
),
49-
),
50-
),
26+
return PrimaryButton(
27+
label: type.label.toUpperCase(),
28+
onPressed: () => vm.sendInAppMessage(type),
29+
semanticsLabel: 'send_iam_${type.triggerValue}_button',
5130
);
5231
}).toList(),
5332
),

examples/demo/lib/widgets/sections/user_section.dart

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,6 @@ class UserSection extends StatelessWidget {
8888
);
8989
if (result != null && context.mounted) {
9090
await vm.loginUser(result);
91-
if (context.mounted) {
92-
context.showSnackBar('Logged in as $result');
93-
}
9491
}
9592
},
9693
),
@@ -101,9 +98,6 @@ class UserSection extends StatelessWidget {
10198
semanticsLabel: 'logout_user_button',
10299
onPressed: () async {
103100
await vm.logoutUser();
104-
if (context.mounted) {
105-
context.showSnackBar('User logged out');
106-
}
107101
},
108102
),
109103
],

examples/demo_pods/lib/services/onesignal_api_service.dart

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,11 @@ class OneSignalApiService {
7979
// Retry while the OneSignal backend hasn't yet indexed the freshly
8080
// created subscription. The /notifications endpoint reports this race in a
8181
// few different shapes, all of which return HTTP 200:
82-
// - {"errors":{"invalid_player_ids":[...]}}
82+
// - {"id":"...","recipients":0} (user just switched, push token not yet attached)
83+
// - {"id":"...","errors":{"invalid_player_ids":[...]}}
8384
// - {"id":"","errors":["All included players are not subscribed"]}
8485
// - {"id":"","errors":[...]}
85-
// Treat any 200 response without a real notification id as transient.
86+
// Treat any 200 response with no real id, populated errors, or recipients=0 as transient.
8687
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
8788
try {
8889
final response = await http.post(
@@ -123,11 +124,13 @@ class OneSignalApiService {
123124
if (decoded is! Map<String, dynamic>) return false;
124125
final id = decoded['id'];
125126
final errors = decoded['errors'];
127+
final recipients = decoded['recipients'];
126128
final hasErrors =
127129
(errors is List && errors.isNotEmpty) ||
128130
(errors is Map && errors.isNotEmpty);
129131
final missingId = id is! String || id.isEmpty;
130-
return hasErrors || missingId;
132+
final zeroRecipients = recipients is num && recipients == 0;
133+
return hasErrors || missingId || zeroRecipients;
131134
}
132135

133136
Future<bool> updateLiveActivity(

examples/demo_pods/lib/theme.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,16 @@ class AppTheme {
108108
AppTheme._();
109109
}
110110

111+
const Duration _toastDuration = Duration(seconds: 3);
112+
111113
extension AppSnackBar on BuildContext {
112114
void showSnackBar(String message) {
113115
ScaffoldMessenger.of(this)
114116
..hideCurrentSnackBar()
115117
..showSnackBar(
116118
SnackBar(
117119
content: Text(message),
120+
duration: _toastDuration,
118121
dismissDirection: DismissDirection.horizontal,
119122
),
120123
);

examples/demo_pods/lib/widgets/sections/user_section.dart

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,6 @@ class UserSection extends StatelessWidget {
8383
);
8484
if (result != null && context.mounted) {
8585
await vm.loginUser(result);
86-
if (context.mounted) {
87-
context.showSnackBar('Logged in as $result');
88-
}
8986
}
9087
},
9188
),
@@ -96,9 +93,6 @@ class UserSection extends StatelessWidget {
9693
semanticsLabel: 'logout_user_button',
9794
onPressed: () async {
9895
await vm.logoutUser();
99-
if (context.mounted) {
100-
context.showSnackBar('User logged out');
101-
}
10296
},
10397
),
10498
],

0 commit comments

Comments
 (0)