Skip to content

Commit 05ab8d4

Browse files
code418claude
andcommitted
fix(widget): a widget tap no longer auto-scans again after sign-out and sign-in
The home-screen widget's "open Claim and scan" request was a counter in _PostboxGameState that only ever grew: nothing reset it on sign-out. So after any widget launch or tap, every later sign-in for the rest of the process (including a different account, or a new account after deletion) opened on the Claim tab and started a GPS scan unasked. The counter now lives in WidgetAutoScanRequests, which drops a pending scan on a real sign-out (Authenticated -> Unauthenticated) but keeps one made while signed out, so a cold start from the widget, or a tap on the login screen, still scans once the player has signed in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cf475be commit 05ab8d4

3 files changed

Lines changed: 82 additions & 7 deletions

File tree

‎lib/deep_links.dart‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,34 @@ bool isWidgetClaimDeepLink(Uri? uri) =>
3232
uri.host == 'claim' &&
3333
uri.queryParameters['source'] == 'widget';
3434

35+
/// Counts home-screen-widget taps that should open Claim and auto-scan.
36+
///
37+
/// The app keys its Home on [epoch], so each bump remounts Home on the Claim
38+
/// tab with a scan running. A tap asks for ONE scan: once a signed-in Home
39+
/// has used it, signing out must drop it, or the next sign-in (possibly a
40+
/// different account) would land on Claim and start a GPS scan unasked. A
41+
/// tap made while signed out (including a cold start from the widget) still
42+
/// carries through the login, which is what the tap asked for.
43+
class WidgetAutoScanRequests {
44+
WidgetAutoScanRequests({bool launchedFromWidget = false})
45+
: _epoch = launchedFromWidget ? 1 : 0;
46+
47+
int _epoch;
48+
bool _signedIn = false;
49+
50+
/// 0 when no scan is pending; otherwise changes on every tap.
51+
int get epoch => _epoch;
52+
53+
void tapped() => _epoch++;
54+
55+
void signedIn() => _signedIn = true;
56+
57+
void signedOut() {
58+
if (_signedIn) _epoch = 0;
59+
_signedIn = false;
60+
}
61+
}
62+
3563
/// Whether [routeName] is the engine's named-route echo of one of the app's
3664
/// own claim deep links: `postbox://claim?source=widget` has no path, so it
3765
/// arrives as `/?source=widget`. See [unknownRoute].

‎lib/main.dart‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -163,13 +163,14 @@ class _PostboxGameState extends State<PostboxGame> with WidgetsBindingObserver {
163163
final UserRepository _userRepository = UserRepository();
164164
final HomeWidgetService _homeWidgetService = HomeWidgetService();
165165
StreamSubscription<Uri?>? _widgetClickSub;
166-
// Monotonic counter of widget-deep-link activations. Each increment is used
167-
// as part of the Home widget's key so a widget tap while the app is warm
166+
// Widget-deep-link activations. Each tap bumps the epoch, which is used as
167+
// part of the Home widget's key so a widget tap while the app is warm
168168
// (already authenticated) forces a fresh Home + Claim mount rather than
169169
// re-using the existing _HomeState — the previous _pages/_selectedIndex
170170
// were initialised from `late final` fields and would otherwise ignore the
171-
// new autoScan/initialIndex props.
172-
int _autoScanEpoch = _pendingWidgetAutoScan ? 1 : 0;
171+
// new autoScan/initialIndex props. Reset on sign-out (see the class).
172+
final WidgetAutoScanRequests _autoScan =
173+
WidgetAutoScanRequests(launchedFromWidget: _pendingWidgetAutoScan);
173174

174175
@override
175176
void initState() {
@@ -182,7 +183,7 @@ class _PostboxGameState extends State<PostboxGame> with WidgetsBindingObserver {
182183
try {
183184
_widgetClickSub = HomeWidget.widgetClicked.listen((uri) {
184185
if (isWidgetClaimDeepLink(uri)) {
185-
setState(() => _autoScanEpoch++);
186+
setState(_autoScan.tapped);
186187
}
187188
});
188189
} catch (_) {
@@ -240,6 +241,7 @@ class _PostboxGameState extends State<PostboxGame> with WidgetsBindingObserver {
240241
home: BlocConsumer<AuthenticationBloc, AuthenticationState?>(
241242
listener: (context, state) {
242243
if (state is Authenticated) {
244+
_autoScan.signedIn();
243245
unawaited(NotificationService.init());
244246
unawaited(_homeWidgetService.refresh());
245247
// Force a fresh Remote Config fetch on login (bypassing the
@@ -258,6 +260,8 @@ class _PostboxGameState extends State<PostboxGame> with WidgetsBindingObserver {
258260
unawaited(publishUserPropertiesFromFirestore(uid));
259261
}
260262
} else if (state is Unauthenticated) {
263+
// Read by the builder that runs right after this listener.
264+
_autoScan.signedOut();
261265
unawaited(NotificationService.reset());
262266
unawaited(_homeWidgetService.refresh());
263267
// Forget any admin-claim result cached against the previous
@@ -285,15 +289,16 @@ class _PostboxGameState extends State<PostboxGame> with WidgetsBindingObserver {
285289
// ConsentGate is a pass-through for anyone who has made the
286290
// analytics choice (new users decide in the intro); existing
287291
// installs get a one-time consent prompt before Home.
288-
if (_autoScanEpoch > 0) {
292+
final autoScanEpoch = _autoScan.epoch;
293+
if (autoScanEpoch > 0) {
289294
// Keying on the epoch forces a fresh Home + Claim mount on
290295
// each widget tap. Without the key, Flutter reconciles the
291296
// existing _HomeState whose `late final _pages` was built
292297
// with autoScan=false, so the new props are ignored and no
293298
// scan fires.
294299
return ConsentGate(
295300
child: Home(
296-
key: ValueKey('claim-widget-$_autoScanEpoch'),
301+
key: ValueKey('claim-widget-$autoScanEpoch'),
297302
initialIndex: 1,
298303
autoScan: true,
299304
),

‎test/widget_test.dart‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,48 @@ void main() {
150150
});
151151
});
152152

153+
group('WidgetAutoScanRequests', () {
154+
test('a sign-out drops a tap that a signed-in Home already used', () {
155+
// Otherwise the next sign-in (maybe another account) opens on Claim
156+
// and starts a GPS scan nobody asked for.
157+
final r = WidgetAutoScanRequests()..signedIn();
158+
expect(r.epoch, 0);
159+
r.tapped();
160+
expect(r.epoch, 1);
161+
r.signedOut();
162+
expect(r.epoch, 0);
163+
r.signedIn();
164+
expect(r.epoch, 0);
165+
});
166+
167+
test('a cold start from the widget while signed out survives login', () {
168+
final r = WidgetAutoScanRequests(launchedFromWidget: true);
169+
expect(r.epoch, 1);
170+
r.signedOut(); // the initial Unauthenticated state, not a sign-out
171+
expect(r.epoch, 1);
172+
r.signedIn();
173+
expect(r.epoch, 1);
174+
});
175+
176+
test('a tap made on the login screen survives login', () {
177+
final r = WidgetAutoScanRequests()
178+
..signedIn()
179+
..signedOut()
180+
..tapped();
181+
r.signedIn();
182+
expect(r.epoch, 1);
183+
});
184+
185+
test('every warm tap changes the epoch so Home remounts', () {
186+
final r = WidgetAutoScanRequests()..signedIn();
187+
final seen = <int>{};
188+
for (var i = 0; i < 3; i++) {
189+
r.tapped();
190+
expect(seen.add(r.epoch), isTrue);
191+
}
192+
});
193+
});
194+
153195
group('unknown deep-link routes', () {
154196
// Regression for a FATAL production crash (Crashlytics
155197
// _WidgetsAppState._onUnknownRoute, "Null check operator used on a null

0 commit comments

Comments
 (0)