Add auth session flow and sign-in UX

This commit is contained in:
Rijad Zuzo
2026-02-15 16:38:07 +01:00
parent a33e663d57
commit aaabc51d2d
7 changed files with 417 additions and 9 deletions
+27
View File
@@ -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.
+76
View File
@@ -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,
);
+176
View File
@@ -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,
);
}
}
+51 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}); });
} }