@@ -2,9 +2,18 @@ import 'dart:async';
22import 'dart:convert' ;
33import 'dart:io' ;
44
5+ import 'package:flutter/foundation.dart' ;
56import 'package:flutter/services.dart' show rootBundle;
7+ import 'package:flutter/widgets.dart' show AppLifecycleListener;
68import 'package:flutter_riverpod/flutter_riverpod.dart' ;
7- import 'package:supabase_flutter/supabase_flutter.dart' ;
9+ import 'package:supabase_flutter/supabase_flutter.dart' as supabase_flutter;
10+ import 'package:supabase_flutter/supabase_flutter.dart'
11+ show
12+ AuthChangeEvent,
13+ AuthException,
14+ OAuthProvider,
15+ Supabase,
16+ User;
817import 'package:url_launcher/url_launcher.dart' ;
918
1019import '../../core/config/supabase_config.dart' ;
@@ -27,6 +36,11 @@ class AuthState {
2736 });
2837}
2938
39+ /// Sentinel returned by [AuthNotifier.signInWithOAuth] when the mobile
40+ /// deep-link callback never arrives after the user returns from the browser.
41+ /// The UI layer should replace this with a localised message.
42+ const oauthDeepLinkTimeout = '__OAUTH_DEEP_LINK_TIMEOUT__' ;
43+
3044/// Manages Supabase auth state. When Supabase is not configured the notifier
3145/// stays inert (state is always null) and the app runs fully offline.
3246class AuthNotifier extends StateNotifier <AuthState ?> {
@@ -48,6 +62,9 @@ class AuthNotifier extends StateNotifier<AuthState?> {
4862 }
4963
5064 // React to future auth changes (sign-in, sign-out, token refresh).
65+ // The onError handler is critical: without it, any error on the stream
66+ // (e.g. from a failed deep-link exchange) cancels the subscription and
67+ // the app can never detect sign-in / sign-out events again.
5168 _sub = client.auth.onAuthStateChange
5269 .map ((data) {
5370 final user = data.session? .user;
@@ -61,7 +78,12 @@ class AuthNotifier extends StateNotifier<AuthState?> {
6178 createdAt: DateTime .tryParse (user.createdAt),
6279 );
6380 })
64- .listen ((authState) => state = authState);
81+ .listen (
82+ (authState) => state = authState,
83+ onError: (Object error, StackTrace stackTrace) {
84+ debugPrint ('Auth stream error: $error ' );
85+ },
86+ );
6587 }
6688
6789 void _setFromUser (User user) {
@@ -118,6 +140,14 @@ class AuthNotifier extends StateNotifier<AuthState?> {
118140 /// Mobile: open browser with deep-link redirect.
119141 /// supabase_flutter intercepts the callback and exchanges the code
120142 /// for a session automatically via its built-in deep link handler.
143+ ///
144+ /// Unlike the desktop flow (which blocks on a local HTTP server), the mobile
145+ /// flow must wait for the deep-link round-trip. We subscribe to
146+ /// [onAuthStateChange] and wait for a [signedIn] event or an error
147+ /// (e.g. PKCE exchange failure). When the app resumes from the browser, a
148+ /// short grace period is given for supabase_flutter to process the deep link.
149+ /// If the deep link never arrives (e.g. redirect URL not configured in
150+ /// the Supabase dashboard), the user gets a clear error message.
121151 Future <String ?> _signInWithOAuthMobile (OAuthProvider provider) async {
122152 try {
123153 const redirectUrl = 'com.elymsyr.dungeonmastertool://auth-callback' ;
@@ -127,12 +157,55 @@ class AuthNotifier extends StateNotifier<AuthState?> {
127157 redirectTo: redirectUrl,
128158 );
129159
130- await launchUrl (Uri .parse (res.url), mode: LaunchMode .externalApplication);
160+ // Listen for the auth result BEFORE launching the browser so we never
161+ // miss the event.
162+ final completer = Completer <String ?>();
163+
164+ late final StreamSubscription <supabase_flutter.AuthState > authSub;
165+ authSub = Supabase .instance.client.auth.onAuthStateChange.listen (
166+ (data) {
167+ if (data.event == AuthChangeEvent .signedIn &&
168+ ! completer.isCompleted) {
169+ authSub.cancel ();
170+ completer.complete (null );
171+ }
172+ },
173+ onError: (Object error) {
174+ if (! completer.isCompleted) {
175+ authSub.cancel ();
176+ completer.complete (
177+ error is AuthException ? error.message : error.toString (),
178+ );
179+ }
180+ },
181+ );
131182
132- // supabase_flutter handles the deep link callback and session
133- // exchange. The onAuthStateChange listener in _init() will
134- // update the provider state and the UI will navigate to hub.
135- return null ;
183+ // When the app resumes from the browser, give supabase_flutter a
184+ // few seconds to receive and process the deep-link callback.
185+ // If nothing arrives, surface an actionable error.
186+ AppLifecycleListener ? lifecycleListener;
187+ lifecycleListener = AppLifecycleListener (
188+ onResume: () {
189+ lifecycleListener? .dispose ();
190+ lifecycleListener = null ;
191+ Future <void >.delayed (const Duration (seconds: 8 ), () {
192+ if (! completer.isCompleted) {
193+ authSub.cancel ();
194+ completer.complete (oauthDeepLinkTimeout);
195+ }
196+ });
197+ },
198+ );
199+
200+ await launchUrl (
201+ Uri .parse (res.url), mode: LaunchMode .externalApplication);
202+
203+ final result = await completer.future;
204+
205+ // Clean up in case auth succeeded before resume.
206+ lifecycleListener? .dispose ();
207+
208+ return result;
136209 } on AuthException catch (e) {
137210 return e.message;
138211 } catch (e) {
0 commit comments