feat: post-onboarding login screen (Google primary, anonymous quiet path)
Browse filesRootGate 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 +1 -1
- app/lib/ui/core/app_controller.dart +32 -1
- app/lib/ui/core/root_gate.dart +14 -2
- app/lib/ui/features/onboarding/views/login_screen.dart +134 -0
- app/test/auth_service_test.dart +1 -40
- app/test/fakes.dart +41 -0
- app/test/login_screen_test.dart +27 -0
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
+
}
|