diff --git a/docs/progress.md b/docs/progress.md index d13b72f..ba99d82 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -2,6 +2,33 @@ 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 Implemented the next Phase 2 slice by introducing Ideas and Reminder Rules as first-class local features. diff --git a/lib/features/auth/session_controller.dart b/lib/features/auth/session_controller.dart new file mode 100644 index 0000000..28d22c2 --- /dev/null +++ b/lib/features/auth/session_controller.dart @@ -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 { + @override + Future build() async { + return ref.read(tokenStoreProvider).read(); + } + + Future signIn({ + required SignInMethod method, + String? email, + String? password, + String? idToken, + }) async { + state = const AsyncLoading(); + + 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(session); + } catch (error, stackTrace) { + state = AsyncError(error, stackTrace); + } + } + + Future 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(null); + } + + Future 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(updated); + } catch (_) { + // Keep current state if profile refresh fails. + } + } +} + +final AsyncNotifierProvider +sessionControllerProvider = + AsyncNotifierProvider( + SessionController.new, + ); diff --git a/lib/features/auth/sign_in_view.dart b/lib/features/auth/sign_in_view.dart new file mode 100644 index 0000000..c6d0580 --- /dev/null +++ b/lib/features/auth/sign_in_view.dart @@ -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 createState() => _SignInViewState(); +} + +class _SignInViewState extends ConsumerState { + 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 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: [ + 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( + segments: const >[ + ButtonSegment( + value: SignInMethod.password, + label: Text('Password'), + ), + ButtonSegment( + value: SignInMethod.emailMagicLink, + label: Text('Magic Link'), + ), + ButtonSegment( + value: SignInMethod.oidc, + label: Text('OIDC'), + ), + ], + selected: {_method}, + onSelectionChanged: busy + ? null + : (Set 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: [ + 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 _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, + ); + } +} diff --git a/lib/features/settings/settings_view.dart b/lib/features/settings/settings_view.dart index bc2668a..87babae 100644 --- a/lib/features/settings/settings_view.dart +++ b/lib/features/settings/settings_view.dart @@ -1,13 +1,21 @@ 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 SettingsView extends StatelessWidget { +class SettingsView extends ConsumerWidget { const SettingsView({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final AuthSession? session = ref + .watch(sessionControllerProvider) + .asData + ?.value; + return Padding( padding: const EdgeInsets.fromLTRB(28, 24, 28, 28), child: Column( @@ -26,6 +34,14 @@ class SettingsView extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + _SettingRow( + label: 'Signed in as', + value: + session?.user.email ?? + session?.user.id ?? + 'Not signed in', + ), + const SizedBox(height: 12), _SettingRow( label: 'Gateway mode', value: AppConfig.useFakeBackend @@ -39,6 +55,30 @@ class SettingsView extends StatelessWidget { label: 'Realtime', value: 'Planned later (SSE/WebSocket)', ), + const SizedBox(height: 14), + Row( + children: [ + 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 _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 { diff --git a/lib/main.dart b/lib/main.dart index c6e0e68..1b705ac 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,22 +1,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/integrations/backend/models/backend_models.dart'; void main() { runApp(const ProviderScope(child: RelationshipSaverApp())); } -class RelationshipSaverApp extends StatelessWidget { +class RelationshipSaverApp extends ConsumerWidget { const RelationshipSaverApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue sessionState = ref.watch( + sessionControllerProvider, + ); + return MaterialApp( title: 'Relationship Saver', debugShowCheckedModeBanner: false, 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(), + ), + ), ); } } diff --git a/test/features/auth/session_controller_test.dart b/test/features/auth/session_controller_test.dart new file mode 100644 index 0000000..e9b7551 --- /dev/null +++ b/test/features/auth/session_controller_test.dart @@ -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); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index e47e0ce..3f936ce 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,5 +1,7 @@ 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/integrations/backend/backend_gateway_provider.dart'; import 'package:relationship_saver/main.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -8,11 +10,16 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - testWidgets('renders dashboard flow', (WidgetTester tester) async { - await tester.pumpWidget(const ProviderScope(child: RelationshipSaverApp())); + testWidgets('renders sign-in flow by default', (WidgetTester tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [tokenStoreProvider.overrideWithValue(InMemoryTokenStore())], + child: const RelationshipSaverApp(), + ), + ); await tester.pumpAndSettle(); - expect(find.text('Today'), findsOneWidget); - expect(find.text('Active People'), findsOneWidget); + expect(find.text('Sign in to continue'), findsOneWidget); + expect(find.text('Sign In'), findsOneWidget); }); }