Add auth session flow and sign-in UX
This commit is contained in:
@@ -2,6 +2,33 @@
|
|||||||
|
|
||||||
Updated: 2026-02-15
|
Updated: 2026-02-15
|
||||||
|
|
||||||
|
## Latest Milestone (2026-02-15): Auth Session UX
|
||||||
|
|
||||||
|
Implemented app-level authentication/session flow so navigation is now session-aware:
|
||||||
|
|
||||||
|
- Added session lifecycle controller:
|
||||||
|
- `lib/features/auth/session_controller.dart`
|
||||||
|
- bootstraps from token store on app startup
|
||||||
|
- supports sign-in, sign-out, and profile refresh
|
||||||
|
- enforces offline-safe sign-out (always clears local session)
|
||||||
|
- Added sign-in experience:
|
||||||
|
- `lib/features/auth/sign_in_view.dart`
|
||||||
|
- supports Password / Magic Link / OIDC request shapes
|
||||||
|
- shows mode status (Fake/REST)
|
||||||
|
- Updated app bootstrap:
|
||||||
|
- `lib/main.dart` now gates between `SignInView` and `AppShell` based on session state
|
||||||
|
- Updated settings with session actions:
|
||||||
|
- `lib/features/settings/settings_view.dart`
|
||||||
|
- shows current identity and provides `Sign Out` + `Refresh Profile`
|
||||||
|
- Added auth tests:
|
||||||
|
- `test/features/auth/session_controller_test.dart`
|
||||||
|
- updated `test/widget_test.dart` for sign-in default route
|
||||||
|
|
||||||
|
Validation for this milestone:
|
||||||
|
|
||||||
|
- `flutter analyze` -> pass
|
||||||
|
- `flutter test` -> pass
|
||||||
|
|
||||||
## Latest Milestone (2026-02-15): Ideas + Reminders Phase
|
## Latest Milestone (2026-02-15): Ideas + Reminders Phase
|
||||||
|
|
||||||
Implemented the next Phase 2 slice by introducing Ideas and Reminder Rules as first-class local features.
|
Implemented the next Phase 2 slice by introducing Ideas and Reminder Rules as first-class local features.
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/auth/token_store.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
|
/// Manages authenticated session lifecycle for app-level navigation.
|
||||||
|
class SessionController extends AsyncNotifier<AuthSession?> {
|
||||||
|
@override
|
||||||
|
Future<AuthSession?> build() async {
|
||||||
|
return ref.read(tokenStoreProvider).read();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signIn({
|
||||||
|
required SignInMethod method,
|
||||||
|
String? email,
|
||||||
|
String? password,
|
||||||
|
String? idToken,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading<AuthSession?>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final BackendGateway gateway = ref.read(backendGatewayProvider);
|
||||||
|
final AuthSession session = await gateway.signIn(
|
||||||
|
SignInRequest(
|
||||||
|
method: method,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
idToken: idToken,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ensure fake gateway sessions are also persisted.
|
||||||
|
await ref.read(tokenStoreProvider).write(session);
|
||||||
|
state = AsyncData<AuthSession?>(session);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
state = AsyncError<AuthSession?>(error, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> signOut() async {
|
||||||
|
final BackendGateway gateway = ref.read(backendGatewayProvider);
|
||||||
|
final TokenStore tokenStore = ref.read(tokenStoreProvider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gateway.signOut();
|
||||||
|
} catch (_) {
|
||||||
|
// Offline-first sign-out should always clear local session.
|
||||||
|
}
|
||||||
|
|
||||||
|
await tokenStore.clear();
|
||||||
|
state = const AsyncData<AuthSession?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshProfile() async {
|
||||||
|
final AuthSession? current = state.asData?.value;
|
||||||
|
if (current == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final UserProfile profile = await ref.read(backendGatewayProvider).me();
|
||||||
|
final AuthSession updated = current.copyWith(user: profile);
|
||||||
|
await ref.read(tokenStoreProvider).write(updated);
|
||||||
|
state = AsyncData<AuthSession?>(updated);
|
||||||
|
} catch (_) {
|
||||||
|
// Keep current state if profile refresh fails.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final AsyncNotifierProvider<SessionController, AuthSession?>
|
||||||
|
sessionControllerProvider =
|
||||||
|
AsyncNotifierProvider<SessionController, AuthSession?>(
|
||||||
|
SessionController.new,
|
||||||
|
);
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_config.dart';
|
||||||
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||||
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
|
class SignInView extends ConsumerStatefulWidget {
|
||||||
|
const SignInView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<SignInView> createState() => _SignInViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignInViewState extends ConsumerState<SignInView> {
|
||||||
|
SignInMethod _method = SignInMethod.password;
|
||||||
|
final TextEditingController _emailController = TextEditingController(
|
||||||
|
text: 'demo@example.com',
|
||||||
|
);
|
||||||
|
final TextEditingController _passwordController = TextEditingController(
|
||||||
|
text: 'password123',
|
||||||
|
);
|
||||||
|
final TextEditingController _idTokenController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_idTokenController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final AsyncValue<AuthSession?> session = ref.watch(
|
||||||
|
sessionControllerProvider,
|
||||||
|
);
|
||||||
|
final bool busy = session.isLoading;
|
||||||
|
final Object? error = session.asError?.error;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 620),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: FrostedCard(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'Sign in to continue',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Your local data remains available offline. Backend unlocks sync and signals.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
SegmentedButton<SignInMethod>(
|
||||||
|
segments: const <ButtonSegment<SignInMethod>>[
|
||||||
|
ButtonSegment<SignInMethod>(
|
||||||
|
value: SignInMethod.password,
|
||||||
|
label: Text('Password'),
|
||||||
|
),
|
||||||
|
ButtonSegment<SignInMethod>(
|
||||||
|
value: SignInMethod.emailMagicLink,
|
||||||
|
label: Text('Magic Link'),
|
||||||
|
),
|
||||||
|
ButtonSegment<SignInMethod>(
|
||||||
|
value: SignInMethod.oidc,
|
||||||
|
label: Text('OIDC'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: <SignInMethod>{_method},
|
||||||
|
onSelectionChanged: busy
|
||||||
|
? null
|
||||||
|
: (Set<SignInMethod> value) {
|
||||||
|
setState(() {
|
||||||
|
_method = value.first;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
if (_method == SignInMethod.password ||
|
||||||
|
_method == SignInMethod.emailMagicLink)
|
||||||
|
TextField(
|
||||||
|
controller: _emailController,
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
enabled: !busy,
|
||||||
|
decoration: const InputDecoration(labelText: 'Email'),
|
||||||
|
),
|
||||||
|
if (_method == SignInMethod.password)
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
enabled: !busy,
|
||||||
|
decoration: const InputDecoration(labelText: 'Password'),
|
||||||
|
),
|
||||||
|
if (_method == SignInMethod.oidc)
|
||||||
|
TextField(
|
||||||
|
controller: _idTokenController,
|
||||||
|
enabled: !busy,
|
||||||
|
decoration: const InputDecoration(labelText: 'ID Token'),
|
||||||
|
),
|
||||||
|
if (error != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Text(
|
||||||
|
'Sign-in failed: $error',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: const Color(0xFFC83030),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: busy ? null : _submit,
|
||||||
|
icon: busy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.login_rounded),
|
||||||
|
label: const Text('Sign In'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
AppConfig.useFakeBackend
|
||||||
|
? 'Fake mode enabled'
|
||||||
|
: 'REST mode (${AppConfig.backendBaseUrl})',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final String? email = _emailController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _emailController.text.trim();
|
||||||
|
final String? password = _passwordController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _passwordController.text.trim();
|
||||||
|
final String? idToken = _idTokenController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _idTokenController.text.trim();
|
||||||
|
|
||||||
|
await ref
|
||||||
|
.read(sessionControllerProvider.notifier)
|
||||||
|
.signIn(
|
||||||
|
method: _method,
|
||||||
|
email: email,
|
||||||
|
password: password,
|
||||||
|
idToken: idToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,21 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_config.dart';
|
import 'package:relationship_saver/core/config/app_config.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||||
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
import 'package:relationship_saver/features/shared/frosted_card.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
class SettingsView extends StatelessWidget {
|
class SettingsView extends ConsumerWidget {
|
||||||
const SettingsView({super.key});
|
const SettingsView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final AuthSession? session = ref
|
||||||
|
.watch(sessionControllerProvider)
|
||||||
|
.asData
|
||||||
|
?.value;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
padding: const EdgeInsets.fromLTRB(28, 24, 28, 28),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -26,6 +34,14 @@ class SettingsView extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
|
_SettingRow(
|
||||||
|
label: 'Signed in as',
|
||||||
|
value:
|
||||||
|
session?.user.email ??
|
||||||
|
session?.user.id ??
|
||||||
|
'Not signed in',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
_SettingRow(
|
_SettingRow(
|
||||||
label: 'Gateway mode',
|
label: 'Gateway mode',
|
||||||
value: AppConfig.useFakeBackend
|
value: AppConfig.useFakeBackend
|
||||||
@@ -39,6 +55,30 @@ class SettingsView extends StatelessWidget {
|
|||||||
label: 'Realtime',
|
label: 'Realtime',
|
||||||
value: 'Planned later (SSE/WebSocket)',
|
value: 'Planned later (SSE/WebSocket)',
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: session == null
|
||||||
|
? null
|
||||||
|
: () => _signOut(context, ref),
|
||||||
|
icon: const Icon(Icons.logout_rounded),
|
||||||
|
label: const Text('Sign Out'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: session == null
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
ref
|
||||||
|
.read(sessionControllerProvider.notifier)
|
||||||
|
.refreshProfile();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.refresh_rounded),
|
||||||
|
label: const Text('Refresh Profile'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -53,6 +93,15 @@ class SettingsView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _signOut(BuildContext context, WidgetRef ref) async {
|
||||||
|
await ref.read(sessionControllerProvider.notifier).signOut();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Signed out.')));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingRow extends StatelessWidget {
|
class _SettingRow extends StatelessWidget {
|
||||||
|
|||||||
+17
-3
@@ -1,22 +1,36 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:relationship_saver/core/config/app_theme.dart';
|
import 'package:relationship_saver/core/config/app_theme.dart';
|
||||||
|
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||||
|
import 'package:relationship_saver/features/auth/sign_in_view.dart';
|
||||||
import 'package:relationship_saver/features/home/app_shell.dart';
|
import 'package:relationship_saver/features/home/app_shell.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(const ProviderScope(child: RelationshipSaverApp()));
|
runApp(const ProviderScope(child: RelationshipSaverApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
class RelationshipSaverApp extends StatelessWidget {
|
class RelationshipSaverApp extends ConsumerWidget {
|
||||||
const RelationshipSaverApp({super.key});
|
const RelationshipSaverApp({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final AsyncValue<AuthSession?> sessionState = ref.watch(
|
||||||
|
sessionControllerProvider,
|
||||||
|
);
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'Relationship Saver',
|
title: 'Relationship Saver',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.light(),
|
theme: AppTheme.light(),
|
||||||
home: const AppShell(),
|
home: Scaffold(
|
||||||
|
body: sessionState.when(
|
||||||
|
data: (session) =>
|
||||||
|
session == null ? const SignInView() : const AppShell(),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (Object error, StackTrace stackTrace) => const SignInView(),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:relationship_saver/core/auth/in_memory_token_store.dart';
|
||||||
|
import 'package:relationship_saver/features/auth/session_controller.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_fake.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/models/backend_models.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('initial state is signed out when token store is empty', () async {
|
||||||
|
final ProviderContainer container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
tokenStoreProvider.overrideWithValue(InMemoryTokenStore()),
|
||||||
|
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
final AuthSession? session = await container.read(
|
||||||
|
sessionControllerProvider.future,
|
||||||
|
);
|
||||||
|
expect(session, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sign-in creates session and sign-out clears it', () async {
|
||||||
|
final InMemoryTokenStore tokenStore = InMemoryTokenStore();
|
||||||
|
final ProviderContainer container = ProviderContainer(
|
||||||
|
overrides: [
|
||||||
|
tokenStoreProvider.overrideWithValue(tokenStore),
|
||||||
|
backendGatewayProvider.overrideWithValue(BackendGatewayFake()),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
await container
|
||||||
|
.read(sessionControllerProvider.notifier)
|
||||||
|
.signIn(
|
||||||
|
method: SignInMethod.password,
|
||||||
|
email: 'demo@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
);
|
||||||
|
|
||||||
|
final AuthSession? signedIn = container
|
||||||
|
.read(sessionControllerProvider)
|
||||||
|
.asData
|
||||||
|
?.value;
|
||||||
|
expect(signedIn, isNotNull);
|
||||||
|
expect(signedIn?.user.email, 'demo@example.com');
|
||||||
|
|
||||||
|
await container.read(sessionControllerProvider.notifier).signOut();
|
||||||
|
|
||||||
|
final AuthSession? signedOut = container
|
||||||
|
.read(sessionControllerProvider)
|
||||||
|
.asData
|
||||||
|
?.value;
|
||||||
|
expect(signedOut, isNull);
|
||||||
|
expect(await tokenStore.read(), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
+11
-4
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:relationship_saver/core/auth/in_memory_token_store.dart';
|
||||||
|
import 'package:relationship_saver/integrations/backend/backend_gateway_provider.dart';
|
||||||
import 'package:relationship_saver/main.dart';
|
import 'package:relationship_saver/main.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -8,11 +10,16 @@ void main() {
|
|||||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('renders dashboard flow', (WidgetTester tester) async {
|
testWidgets('renders sign-in flow by default', (WidgetTester tester) async {
|
||||||
await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp()));
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: [tokenStoreProvider.overrideWithValue(InMemoryTokenStore())],
|
||||||
|
child: const RelationshipSaverApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Today'), findsOneWidget);
|
expect(find.text('Sign in to continue'), findsOneWidget);
|
||||||
expect(find.text('Active People'), findsOneWidget);
|
expect(find.text('Sign In'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user