Vatxzz commited on
Commit
1d0b7a9
·
1 Parent(s): 3cae21f

feat: post-onboarding login screen (Google primary, anonymous quiet path)

Browse files

RootGate gains a login phase between name and shell, keyed on AppController.needsLogin; returning signed-in users skip it. FakeAuthService moved to test/fakes.dart.

app/lib/main.dart CHANGED
@@ -42,7 +42,7 @@ Future<void> main() async {
42
  final highlightStore = await HighlightStore.open();
43
  final api = ApiClient(baseUrl: await ApiClient.resolveBaseUrl(store: store), store: store);
44
  final repository = CardRepository(api: api, store: store);
45
- final appController = AppController(store);
46
  final localAi = GemmaLocalAiService(store: store);
47
  FlutterNativeSplash.remove();
48
  runApp(CachyApp(
 
42
  final highlightStore = await HighlightStore.open();
43
  final api = ApiClient(baseUrl: await ApiClient.resolveBaseUrl(store: store), store: store);
44
  final repository = CardRepository(api: api, store: store);
45
+ final appController = AppController(store, authService);
46
  final localAi = GemmaLocalAiService(store: store);
47
  FlutterNativeSplash.remove();
48
  runApp(CachyApp(
app/lib/ui/core/app_controller.dart CHANGED
@@ -4,14 +4,39 @@
4
  /// the app presents itself.
5
  library;
6
 
 
 
7
  import 'package:flutter/material.dart';
8
 
 
9
  import '../../data/services/local_store.dart';
10
 
11
  class AppController extends ChangeNotifier {
12
- AppController(this._store) : _themeMode = _decode(_store.themeMode);
 
 
 
 
 
 
 
13
 
14
  final LocalStore _store;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  ThemeMode _themeMode;
17
  ThemeMode get themeMode => _themeMode;
@@ -49,6 +74,12 @@ class AppController extends ChangeNotifier {
49
  notifyListeners();
50
  }
51
 
 
 
 
 
 
 
52
  static ThemeMode _decode(String v) => switch (v) {
53
  'light' => ThemeMode.light,
54
  'dark' => ThemeMode.dark,
 
4
  /// the app presents itself.
5
  library;
6
 
7
+ import 'dart:async';
8
+
9
  import 'package:flutter/material.dart';
10
 
11
+ import '../../data/services/auth_service.dart';
12
  import '../../data/services/local_store.dart';
13
 
14
  class AppController extends ChangeNotifier {
15
+ AppController(this._store, this._auth)
16
+ : _themeMode = _decode(_store.themeMode),
17
+ _authUser = _auth.currentUser {
18
+ _authSub = _auth.userChanges.listen((u) {
19
+ _authUser = u;
20
+ notifyListeners();
21
+ });
22
+ }
23
 
24
  final LocalStore _store;
25
+ final AuthService _auth;
26
+ late final StreamSubscription<AuthUser?> _authSub;
27
+
28
+ AuthUser? _authUser;
29
+
30
+ /// The current Firebase identity (uid = backend owner_id), or null when
31
+ /// signed out. Drives the login gate and the profile account section.
32
+ AuthUser? get authUser => _authUser;
33
+
34
+ /// True once the user has cleared onboarding + name but has no Firebase
35
+ /// identity yet — the one moment [RootGate] shows the login screen.
36
+ bool get needsLogin => seenOnboarding && hasUserName && _authUser == null;
37
+
38
+ Future<void> signInWithGoogle() => _auth.signInWithGoogle();
39
+ Future<void> continueAnonymously() => _auth.signInAnonymously();
40
 
41
  ThemeMode _themeMode;
42
  ThemeMode get themeMode => _themeMode;
 
74
  notifyListeners();
75
  }
76
 
77
+ @override
78
+ void dispose() {
79
+ _authSub.cancel();
80
+ super.dispose();
81
+ }
82
+
83
  static ThemeMode _decode(String v) => switch (v) {
84
  'light' => ThemeMode.light,
85
  'dark' => ThemeMode.dark,
app/lib/ui/core/root_gate.dart CHANGED
@@ -6,13 +6,14 @@ import 'package:flutter/foundation.dart';
6
  import 'package:flutter/material.dart';
7
  import 'package:provider/provider.dart';
8
 
 
9
  import '../features/onboarding/views/name_screen.dart';
10
  import '../features/onboarding/views/onboarding_screen.dart';
11
  import '../features/onboarding/views/splash_screen.dart';
12
  import 'app_controller.dart';
13
  import 'home_shell.dart';
14
 
15
- enum _Phase { splash, onboarding, nameEntry, shell }
16
 
17
  class RootGate extends StatefulWidget {
18
  const RootGate({super.key});
@@ -27,6 +28,7 @@ class _RootGateState extends State<RootGate> {
27
  _Phase _afterSplash(AppController app) {
28
  if (!app.seenOnboarding) return _Phase.onboarding;
29
  if (!app.hasUserName) return _Phase.nameEntry;
 
30
  return _Phase.shell;
31
  }
32
 
@@ -46,10 +48,18 @@ class _RootGateState extends State<RootGate> {
46
  await context.read<AppController>().completeOnboarding();
47
  if (!mounted) return;
48
  final app = context.read<AppController>();
49
- setState(() => _phase = app.hasUserName ? _Phase.shell : _Phase.nameEntry);
 
 
50
  }
51
 
52
  void _finishNameEntry() {
 
 
 
 
 
 
53
  if (mounted) setState(() => _phase = _Phase.shell);
54
  }
55
 
@@ -67,6 +77,8 @@ class _RootGateState extends State<RootGate> {
67
  OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
68
  _Phase.nameEntry =>
69
  NameScreen(key: const ValueKey('nameEntry'), onDone: _finishNameEntry),
 
 
70
  _Phase.shell => const HomeShell(key: ValueKey('shell')),
71
  },
72
  );
 
6
  import 'package:flutter/material.dart';
7
  import 'package:provider/provider.dart';
8
 
9
+ import '../features/onboarding/views/login_screen.dart';
10
  import '../features/onboarding/views/name_screen.dart';
11
  import '../features/onboarding/views/onboarding_screen.dart';
12
  import '../features/onboarding/views/splash_screen.dart';
13
  import 'app_controller.dart';
14
  import 'home_shell.dart';
15
 
16
+ enum _Phase { splash, onboarding, nameEntry, login, shell }
17
 
18
  class RootGate extends StatefulWidget {
19
  const RootGate({super.key});
 
28
  _Phase _afterSplash(AppController app) {
29
  if (!app.seenOnboarding) return _Phase.onboarding;
30
  if (!app.hasUserName) return _Phase.nameEntry;
31
+ if (app.needsLogin) return _Phase.login;
32
  return _Phase.shell;
33
  }
34
 
 
48
  await context.read<AppController>().completeOnboarding();
49
  if (!mounted) return;
50
  final app = context.read<AppController>();
51
+ setState(() => _phase = app.hasUserName
52
+ ? (app.needsLogin ? _Phase.login : _Phase.shell)
53
+ : _Phase.nameEntry);
54
  }
55
 
56
  void _finishNameEntry() {
57
+ if (!mounted) return;
58
+ final app = context.read<AppController>();
59
+ setState(() => _phase = app.needsLogin ? _Phase.login : _Phase.shell);
60
+ }
61
+
62
+ void _finishLogin() {
63
  if (mounted) setState(() => _phase = _Phase.shell);
64
  }
65
 
 
77
  OnboardingScreen(key: const ValueKey('onboarding'), onDone: _finishOnboarding),
78
  _Phase.nameEntry =>
79
  NameScreen(key: const ValueKey('nameEntry'), onDone: _finishNameEntry),
80
+ _Phase.login =>
81
+ LoginScreen(key: const ValueKey('login'), onDone: _finishLogin),
82
  _Phase.shell => const HomeShell(key: ValueKey('shell')),
83
  },
84
  );
app/lib/ui/features/onboarding/views/login_screen.dart ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// Login gate, shown after onboarding + name. Google is primary; anonymous is
2
+ /// a quiet escape hatch with an honest data-loss caveat.
3
+ library;
4
+
5
+ import 'package:flutter/material.dart';
6
+ import 'package:phosphor_flutter/phosphor_flutter.dart';
7
+ import 'package:provider/provider.dart';
8
+
9
+ import '../../../../data/services/auth_service.dart';
10
+ import '../../../core/brand.dart';
11
+ import '../../../core/widgets/responsive_center.dart';
12
+
13
+ class LoginScreen extends StatefulWidget {
14
+ const LoginScreen({super.key, required this.onDone});
15
+ final VoidCallback onDone;
16
+
17
+ @override
18
+ State<LoginScreen> createState() => _LoginScreenState();
19
+ }
20
+
21
+ class _LoginScreenState extends State<LoginScreen> {
22
+ bool _busy = false;
23
+ String? _error;
24
+
25
+ Future<void> _run(Future<void> Function() action) async {
26
+ setState(() {
27
+ _busy = true;
28
+ _error = null;
29
+ });
30
+ try {
31
+ await action();
32
+ if (mounted) widget.onDone();
33
+ } catch (_) {
34
+ if (mounted) {
35
+ setState(() =>
36
+ _error = "Couldn't sign in. Check your connection and try again.");
37
+ }
38
+ } finally {
39
+ if (mounted) setState(() => _busy = false);
40
+ }
41
+ }
42
+
43
+ @override
44
+ Widget build(BuildContext context) {
45
+ final theme = Theme.of(context);
46
+ final scheme = theme.colorScheme;
47
+ final auth = context.read<AuthService>();
48
+ return Scaffold(
49
+ backgroundColor: scheme.surface,
50
+ body: Container(
51
+ decoration: BoxDecoration(
52
+ gradient: RadialGradient(
53
+ center: const Alignment(0, -0.45),
54
+ radius: 1.3,
55
+ colors: [
56
+ scheme.primary.withValues(alpha: 0.12),
57
+ Colors.transparent,
58
+ ],
59
+ ),
60
+ ),
61
+ child: SafeArea(
62
+ child: ResponsiveCenter(
63
+ child: Padding(
64
+ padding: const EdgeInsets.symmetric(horizontal: 28),
65
+ child: Column(
66
+ crossAxisAlignment: CrossAxisAlignment.start,
67
+ children: [
68
+ const SizedBox(height: 48),
69
+ const CachyGlyph(size: 56),
70
+ const SizedBox(height: 32),
71
+ Text('Keep your\nlibrary safe.',
72
+ style: theme.textTheme.displaySmall),
73
+ const SizedBox(height: 16),
74
+ Text(
75
+ 'Sign in so your cards follow you to any device — and survive a reinstall.',
76
+ style: theme.textTheme.bodyLarge?.copyWith(
77
+ color: scheme.onSurfaceVariant, height: 1.5),
78
+ ),
79
+ if (_error != null) ...[
80
+ const SizedBox(height: 16),
81
+ Text(_error!, style: TextStyle(color: scheme.error)),
82
+ ],
83
+ const Spacer(),
84
+ FilledButton.icon(
85
+ onPressed: _busy
86
+ ? null
87
+ : () => _run(() async {
88
+ await auth.signInWithGoogle();
89
+ }),
90
+ icon: const PhosphorIcon(PhosphorIconsRegular.googleLogo,
91
+ size: 20),
92
+ label: const Text('Continue with Google'),
93
+ style: FilledButton.styleFrom(
94
+ minimumSize: const Size.fromHeight(56),
95
+ shape: RoundedRectangleBorder(
96
+ borderRadius: BorderRadius.circular(16)),
97
+ ),
98
+ ),
99
+ const SizedBox(height: 14),
100
+ Center(
101
+ child: TextButton(
102
+ onPressed: _busy
103
+ ? null
104
+ : () => _run(() async {
105
+ await auth.signInAnonymously();
106
+ }),
107
+ child: Text(
108
+ 'Or use without login…',
109
+ style: theme.textTheme.bodySmall?.copyWith(
110
+ color: scheme.onSurfaceVariant,
111
+ decoration: TextDecoration.underline,
112
+ ),
113
+ ),
114
+ ),
115
+ ),
116
+ const SizedBox(height: 6),
117
+ Center(
118
+ child: Text(
119
+ 'Without an account, your library lives only on this device.',
120
+ textAlign: TextAlign.center,
121
+ style: theme.textTheme.bodySmall
122
+ ?.copyWith(color: scheme.onSurfaceVariant),
123
+ ),
124
+ ),
125
+ const SizedBox(height: 24),
126
+ ],
127
+ ),
128
+ ),
129
+ ),
130
+ ),
131
+ ),
132
+ );
133
+ }
134
+ }
app/test/auth_service_test.dart CHANGED
@@ -1,45 +1,6 @@
1
- import 'dart:async';
2
-
3
  import 'package:flutter_test/flutter_test.dart';
4
- import 'package:cachy/data/services/auth_service.dart';
5
-
6
- /// Deterministic in-memory AuthService for widget/unit tests.
7
- class FakeAuthService implements AuthService {
8
- AuthUser? _user;
9
- final _controller = StreamController<AuthUser?>.broadcast();
10
- String tokenValue = 'fake-token';
11
-
12
- @override
13
- Stream<AuthUser?> get userChanges => _controller.stream;
14
- @override
15
- AuthUser? get currentUser => _user;
16
-
17
- @override
18
- Future<AuthUser> signInAnonymously() async {
19
- _user = const AuthUser(uid: 'anon-1', isAnonymous: true);
20
- _controller.add(_user);
21
- return _user!;
22
- }
23
 
24
- @override
25
- Future<AuthUser> signInWithGoogle() async {
26
- // Linking keeps the uid when the current user is anonymous.
27
- final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
28
- _user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
29
- _controller.add(_user);
30
- return _user!;
31
- }
32
-
33
- @override
34
- Future<String?> idToken({bool forceRefresh = false}) async =>
35
- _user == null ? null : tokenValue;
36
-
37
- @override
38
- Future<void> signOut() async {
39
- _user = null;
40
- _controller.add(null);
41
- }
42
- }
43
 
44
  void main() {
45
  test('google sign-in after anonymous keeps the uid (link semantics)', () async {
 
 
 
1
  import 'package:flutter_test/flutter_test.dart';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ import 'fakes.dart';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  void main() {
6
  test('google sign-in after anonymous keeps the uid (link semantics)', () async {
app/test/fakes.dart ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'dart:async';
2
+
3
+ import 'package:cachy/data/services/auth_service.dart';
4
+
5
+ /// Deterministic in-memory AuthService for widget/unit tests.
6
+ class FakeAuthService implements AuthService {
7
+ AuthUser? _user;
8
+ final _controller = StreamController<AuthUser?>.broadcast();
9
+ String tokenValue = 'fake-token';
10
+
11
+ @override
12
+ Stream<AuthUser?> get userChanges => _controller.stream;
13
+ @override
14
+ AuthUser? get currentUser => _user;
15
+
16
+ @override
17
+ Future<AuthUser> signInAnonymously() async {
18
+ _user = const AuthUser(uid: 'anon-1', isAnonymous: true);
19
+ _controller.add(_user);
20
+ return _user!;
21
+ }
22
+
23
+ @override
24
+ Future<AuthUser> signInWithGoogle() async {
25
+ // Linking keeps the uid when the current user is anonymous.
26
+ final uid = _user?.isAnonymous == true ? _user!.uid : 'google-1';
27
+ _user = AuthUser(uid: uid, isAnonymous: false, email: 'a@b.c', displayName: 'A');
28
+ _controller.add(_user);
29
+ return _user!;
30
+ }
31
+
32
+ @override
33
+ Future<String?> idToken({bool forceRefresh = false}) async =>
34
+ _user == null ? null : tokenValue;
35
+
36
+ @override
37
+ Future<void> signOut() async {
38
+ _user = null;
39
+ _controller.add(null);
40
+ }
41
+ }
app/test/login_screen_test.dart ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import 'package:flutter/material.dart';
2
+ import 'package:flutter_test/flutter_test.dart';
3
+ import 'package:provider/provider.dart';
4
+ import 'package:cachy/data/services/auth_service.dart';
5
+ import 'package:cachy/ui/features/onboarding/views/login_screen.dart';
6
+
7
+ import 'fakes.dart';
8
+
9
+ void main() {
10
+ testWidgets('login screen: Google primary, quiet anonymous path', (tester) async {
11
+ final auth = FakeAuthService();
12
+ var done = 0;
13
+ await tester.pumpWidget(
14
+ Provider<AuthService>.value(
15
+ value: auth,
16
+ child: MaterialApp(home: LoginScreen(onDone: () => done++)),
17
+ ),
18
+ );
19
+ expect(find.text('Continue with Google'), findsOneWidget);
20
+ expect(find.text('Or use without login…'), findsOneWidget);
21
+
22
+ await tester.tap(find.text('Or use without login…'));
23
+ await tester.pumpAndSettle();
24
+ expect(auth.currentUser?.isAnonymous, isTrue);
25
+ expect(done, 1);
26
+ });
27
+ }