From faa3cf36bc5b57e8b133eab8df90110b80eb4cf7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Thu, 21 May 2026 07:55:35 +0300 Subject: Implement SettingsScreen with AuthGuard and settings persistence (sa) Co-Authored-By: Claude Sonnet 4.6 --- player-android/lib/app_routes.dart | 1 + .../lib/providers/settings_provider.dart | 87 ++++++ player-android/lib/router.dart | 15 +- player-android/lib/screens/settings_screen.dart | 234 ++++++++++++++ player-android/pubspec.lock | 56 ++++ player-android/pubspec.yaml | 3 + .../test/screens/settings_screen_test.dart | 341 +++++++++++++++++++++ 7 files changed, 734 insertions(+), 3 deletions(-) create mode 100644 player-android/lib/providers/settings_provider.dart create mode 100644 player-android/lib/screens/settings_screen.dart create mode 100644 player-android/test/screens/settings_screen_test.dart diff --git a/player-android/lib/app_routes.dart b/player-android/lib/app_routes.dart index 2856753..aa76ba7 100644 --- a/player-android/lib/app_routes.dart +++ b/player-android/lib/app_routes.dart @@ -8,6 +8,7 @@ abstract final class AppRoutes { static const home = '/home'; static const mediaDetail = '/media/:id'; static const share = '/share'; + static const settings = '/settings'; /// First-run setup route shown when no admin account exists yet. static const bootstrap = '/bootstrap'; diff --git a/player-android/lib/providers/settings_provider.dart b/player-android/lib/providers/settings_provider.dart new file mode 100644 index 0000000..8ffec0f --- /dev/null +++ b/player-android/lib/providers/settings_provider.dart @@ -0,0 +1,87 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// SharedPreferences key for the server base URL setting. +const _kBaseUrlKey = 'server_base_url'; + +// Default base URL used when the user has not yet configured one. +// Points to the local Android emulator loopback address so the app is +// runnable out-of-the-box without any manual configuration. +// Private: only referenced within this file; callers read the resolved URL +// through [AppSettings.serverBaseUrl] obtained from [settingsProvider]. +const _kDefaultBaseUrl = 'http://10.0.2.2:8080'; + +/// Immutable snapshot of persisted app settings. +/// +/// Keeping settings as a value object means every state change produces a new +/// instance, which plays well with Riverpod's equality-based rebuild suppression +/// and keeps the notifier's contract straightforward. +class AppSettings { + const AppSettings({required this.serverBaseUrl}); + + /// The base URL of the player-server API (e.g. "https://player.example.com"). + final String serverBaseUrl; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AppSettings && + runtimeType == other.runtimeType && + serverBaseUrl == other.serverBaseUrl; + + @override + int get hashCode => serverBaseUrl.hashCode; + + @override + String toString() => 'AppSettings(serverBaseUrl: $serverBaseUrl)'; +} + +/// Manages persisted app settings via [SharedPreferences]. +/// +/// Uses [AsyncNotifier] because the initial state load is async (disk read). +/// After initialisation, [setServerBaseUrl] writes to disk and updates state +/// synchronously so the UI reflects changes immediately. +/// +/// Design notes (SRP / ISP): +/// - This notifier owns only settings persistence; auth is handled separately +/// by [AuthStateNotifier] to maintain single responsibility. +/// - [SharedPreferences] is created internally rather than injected because +/// it is a platform singleton; tests override the entire provider via +/// [ProviderScope] overrides instead. +class SettingsNotifier extends AsyncNotifier { + @override + Future build() async { + // Load persisted settings from disk on first access. The platform + // SharedPreferences instance is a singleton; obtaining it here is cheap + // because subsequent calls return the cached instance. + final prefs = await SharedPreferences.getInstance(); + final url = prefs.getString(_kBaseUrlKey) ?? _kDefaultBaseUrl; + return AppSettings(serverBaseUrl: url); + } + + /// Persists [url] as the new server base URL and updates the in-memory state. + /// + /// The UI calls this when the user edits the URL field and submits. The + /// async write to [SharedPreferences] is awaited so that a subsequent cold + /// start will see the new value; the in-memory state is updated first so the + /// UI is not blocked on the disk write. + Future setServerBaseUrl(String url) async { + // Update in-memory state first for immediate UI feedback. + state = AsyncData(AppSettings(serverBaseUrl: url)); + + // Persist to disk so the value survives app restarts. + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kBaseUrlKey, url); + } +} + +/// The single source of truth for persisted app settings. +/// +/// Currently consumed by [SettingsScreen] for displaying and editing settings. +/// Will also be consumed by [apiClientProvider] (for the server base URL) once +/// that provider is wired to read from settings rather than +/// [String.fromEnvironment] — tracked as a future task. +final settingsProvider = + AsyncNotifierProvider( + SettingsNotifier.new, +); diff --git a/player-android/lib/router.dart b/player-android/lib/router.dart index 4c78259..255ffce 100644 --- a/player-android/lib/router.dart +++ b/player-android/lib/router.dart @@ -10,6 +10,7 @@ import 'screens/bootstrap_screen.dart'; import 'screens/home_screen.dart'; import 'screens/login_screen.dart'; import 'screens/media_detail_screen.dart'; +import 'screens/settings_screen.dart'; import 'screens/share_screen.dart'; // Re-export AppRoutes so existing callers that import router.dart for routes @@ -61,9 +62,13 @@ final routerProvider = Provider((ref) { } if (auth.isUnauthenticated && !isLoginRoute && !isBootstrapRoute) { - // Unauthenticated: determine whether this is first-run (no users) or - // a normal returning-user scenario. firstRunProvider returns true when - // the server reports count == 0 (no accounts exist yet). + // Unauthenticated: any route other than /login and /bootstrap is + // protected. This covers /home, /media/:id, /share, /settings, and + // any future authenticated routes added to the route table. + // + // Determine whether this is first-run (no users exist yet) or a normal + // returning-user scenario. firstRunProvider returns true when the + // server reports count == 0. // // While the check is loading we stay put; the router re-evaluates when // firstRunProvider's AsyncValue settles (via refreshListenable). @@ -105,6 +110,10 @@ final routerProvider = Provider((ref) { path: AppRoutes.share, builder: (context, state) => const ShareScreen(), ), + GoRoute( + path: AppRoutes.settings, + builder: (context, state) => const SettingsScreen(), + ), ], ); }); diff --git a/player-android/lib/screens/settings_screen.dart b/player-android/lib/screens/settings_screen.dart new file mode 100644 index 0000000..143acd2 --- /dev/null +++ b/player-android/lib/screens/settings_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../app_routes.dart'; +import '../providers/api_client_provider.dart'; +import '../providers/auth_state_provider.dart'; +import '../providers/settings_provider.dart'; + +/// Settings screen: editable server base URL, current username, and logout. +/// +/// Design notes: +/// - [ConsumerStatefulWidget] is used so that the text controller can be +/// initialised from the persisted settings and [WidgetRef] is available +/// throughout the async logout path without storing a stale ref. +/// - The base URL is pre-filled from [settingsProvider] and saved on every +/// submit (Enter key or "Save" button). +/// - Logout clears the bearer token via [AuthStateNotifier.logout], which +/// triggers go_router's redirect callback (via [refreshListenable]) and +/// navigates to /login automatically. An explicit [context.go] acts as a +/// safety net in case the redirect has not fired yet. +/// - All async continuations guard on [mounted] to prevent setState/context +/// calls after widget disposal. +class SettingsScreen extends ConsumerStatefulWidget { + const SettingsScreen({super.key}); + + @override + ConsumerState createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState { + // Controller for the server base URL text field. Initialised once from the + // persisted settings value and disposed when the widget leaves the tree. + final _urlController = TextEditingController(); + + // True while the logout round-trip (token deletion + state update) is in + // progress; prevents double-tapping the logout button. + bool _isLoggingOut = false; + + // Tracks whether the URL controller has been seeded from the loaded settings + // so we populate it exactly once (on the first non-loading build). + bool _urlInitialised = false; + + @override + void dispose() { + _urlController.dispose(); + super.dispose(); + } + + // --------------------------------------------------------------------------- + // URL save logic + // --------------------------------------------------------------------------- + + /// Validates the URL field and persists the new value via [SettingsNotifier]. + /// + /// Trims whitespace so that a trailing newline from keyboard submission does + /// not get saved as part of the URL. + Future _saveBaseUrl() async { + final url = _urlController.text.trim(); + if (url.isEmpty) return; + + // Persist the new URL; [SettingsNotifier] updates in-memory state first so + // the UI reflects the change immediately without waiting for the disk write. + await ref.read(settingsProvider.notifier).setServerBaseUrl(url); + + // Dismiss the keyboard now that the value has been committed. + if (mounted) FocusScope.of(context).unfocus(); + } + + // --------------------------------------------------------------------------- + // Logout logic + // --------------------------------------------------------------------------- + + /// Clears the stored bearer token and transitions to the unauthenticated state. + /// + /// [AuthStateNotifier.logout] deletes the token from secure storage and sets + /// state to [AuthStatus.unauthenticated]. The router's [refreshListenable] + /// picks up the change and the redirect callback routes to /login automatically. + /// The explicit [context.go] below acts as a safety net. + Future _logout() async { + setState(() => _isLoggingOut = true); + try { + await ref.read(authStateProvider.notifier).logout(); + // Safety-net navigation in case the router redirect has not fired yet. + if (mounted) context.go(AppRoutes.login); + } finally { + // Only call setState if the widget is still in the tree; navigation may + // have triggered dispose before the finally block executes. + if (mounted) setState(() => _isLoggingOut = false); + } + } + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + // Watch settings to seed the URL field on first load. + final settingsAsync = ref.watch(settingsProvider); + + // Seed the URL text field exactly once, after settings have loaded. + // Doing this in build (rather than initState) ensures we have the loaded + // value; [_urlInitialised] prevents clobbering an in-progress edit. + settingsAsync.whenData((settings) { + if (!_urlInitialised) { + _urlController.text = settings.serverBaseUrl; + _urlInitialised = true; + } + }); + + // Read the stored token as the username display. The token stored by + // AuthStateNotifier is the username string (LoginScreen and BootstrapScreen + // both call `authStateProvider.notifier.login(user.username)`). + final usernameAsync = ref.watch(_currentUsernameProvider); + final username = usernameAsync.valueOrNull ?? '—'; + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ---------------------------------------------------------------- + // Account section: signed-in username + logout. + // ---------------------------------------------------------------- + Text( + 'Account', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // Current username row. + Row( + children: [ + const Icon(Icons.person_outline), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Signed in as', + style: Theme.of(context).textTheme.bodySmall, + ), + Text( + username, + key: const Key('settings_username'), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ], + ), + const SizedBox(height: 24), + + // Logout button: shows a spinner while the token is being deleted. + _isLoggingOut + ? const Center(child: CircularProgressIndicator()) + : OutlinedButton( + key: const Key('settings_logout'), + onPressed: _logout, + style: OutlinedButton.styleFrom( + foregroundColor: + Theme.of(context).colorScheme.error, + side: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + ), + child: const Text('Log Out'), + ), + + const SizedBox(height: 32), + const Divider(), + const SizedBox(height: 24), + + // ---------------------------------------------------------------- + // Server section: editable base URL. + // ---------------------------------------------------------------- + Text( + 'Server', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // Server base URL field pre-filled from persisted settings. + TextField( + key: const Key('settings_base_url'), + controller: _urlController, + decoration: const InputDecoration( + labelText: 'Server base URL', + border: OutlineInputBorder(), + helperText: + 'e.g. https://player.example.com or http://10.0.2.2:8080', + ), + keyboardType: TextInputType.url, + autocorrect: false, + textInputAction: TextInputAction.done, + // Persist when the user presses "Done" on the keyboard. + onSubmitted: (_) => _saveBaseUrl(), + ), + const SizedBox(height: 12), + + ElevatedButton( + key: const Key('settings_save_url'), + onPressed: _saveBaseUrl, + child: const Text('Save URL'), + ), + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// File-level helpers +// --------------------------------------------------------------------------- + +/// Reads the current username from [tokenStorageProvider]. +/// +/// The username is stored as the bearer token value by [AuthStateNotifier.login] +/// (both LoginScreen and BootstrapScreen call `login(user.username)`). +/// This autoDispose FutureProvider is re-evaluated whenever the provider scope +/// changes, ensuring the display is up-to-date after logout/login transitions. +/// +/// Kept private (underscore prefix) because it is an implementation detail of +/// this screen — no other file should depend on it. +final _currentUsernameProvider = FutureProvider.autoDispose((ref) { + final storage = ref.watch(tokenStorageProvider); + return storage.readToken(); +}); diff --git a/player-android/pubspec.lock b/player-android/pubspec.lock index d3ca08a..44e8eff 100644 --- a/player-android/pubspec.lock +++ b/player-android/pubspec.lock @@ -448,6 +448,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.dev" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sky_engine: dependency: transitive description: flutter diff --git a/player-android/pubspec.yaml b/player-android/pubspec.yaml index ddd5d0d..c693cff 100644 --- a/player-android/pubspec.yaml +++ b/player-android/pubspec.yaml @@ -17,6 +17,9 @@ dependencies: flutter_riverpod: ^2.6.1 # go_router: declarative routing for Flutter; replaces imperative Navigator calls. go_router: ^14.8.1 + # shared_preferences: persists simple key-value settings (e.g. server base URL) + # using platform-native storage (SharedPreferences on Android, NSUserDefaults on iOS). + shared_preferences: ^2.3.2 dev_dependencies: flutter_test: diff --git a/player-android/test/screens/settings_screen_test.dart b/player-android/test/screens/settings_screen_test.dart new file mode 100644 index 0000000..71c2d7d --- /dev/null +++ b/player-android/test/screens/settings_screen_test.dart @@ -0,0 +1,341 @@ +// Widget tests for SettingsScreen. +// +// Tests cover: +// 1. Displays username: the token stored in TokenStorage is shown as the +// signed-in username. +// 2. Saves base URL: entering a URL and tapping Save persists it via the +// settings provider. +// 3. Logout flow: tapping Log Out calls AuthStateNotifier.logout and clears +// the stored token. +// +// Riverpod providers are overridden with in-memory fakes so tests run without +// a real server, OS keychain, or SharedPreferences disk I/O. +// +// Run with: flutter test test/screens/settings_screen_test.dart + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:player_android/api/dio_client.dart'; +import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/providers/auth_state_provider.dart'; +import 'package:player_android/providers/settings_provider.dart'; +import 'package:player_android/screens/settings_screen.dart'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +/// In-memory [TokenStorage] that avoids the platform OS keychain in tests. +class _FakeTokenStorage implements TokenStorage { + String? _token; + + @override + Future readToken() async => _token; + + @override + Future writeToken(String token) async => _token = token; + + @override + Future deleteToken() async => _token = null; +} + +/// In-memory [SettingsNotifier] whose state is set directly in tests. +/// +/// Uses [AsyncNotifier] the same way the production notifier does, but +/// bypasses [SharedPreferences] so tests have no disk I/O. +class _FakeSettingsNotifier extends SettingsNotifier { + _FakeSettingsNotifier(this._initialUrl); + + final String _initialUrl; + + // Captures the last URL passed to setServerBaseUrl for test assertions. + String? savedUrl; + + @override + Future build() async => + AppSettings(serverBaseUrl: _initialUrl); + + @override + Future setServerBaseUrl(String url) async { + savedUrl = url; + // Mirror the production implementation: update in-memory state immediately. + state = AsyncData(AppSettings(serverBaseUrl: url)); + } +} + +// --------------------------------------------------------------------------- +// Helper: pump SettingsScreen inside a minimal ProviderScope. +// --------------------------------------------------------------------------- + +/// Pumps [SettingsScreen] inside a [ProviderScope] that overrides: +/// - [tokenStorageProvider] with an in-memory fake (avoids OS keychain) +/// - [settingsProvider] with an in-memory fake (avoids SharedPreferences) +/// +/// Uses [MaterialApp.router] with a minimal [GoRouter] so that [context.go] +/// calls inside [SettingsScreen._logout] do not throw "No GoRouter in context". +/// +/// Returns a record containing: +/// - [storage]: the fake token storage for post-test assertions. +/// - [settings]: the fake settings notifier for post-test assertions. +Future<({_FakeTokenStorage storage, _FakeSettingsNotifier settings})> + _pumpSettingsScreen( + WidgetTester tester, { + String initialToken = 'alice', + String initialUrl = 'http://10.0.2.2:8080', +}) async { + final fakeStorage = _FakeTokenStorage().._token = initialToken; + final fakeSettings = _FakeSettingsNotifier(initialUrl); + + // A minimal GoRouter that renders SettingsScreen at '/'. The /login route + // is included so that the safety-net context.go(AppRoutes.login) in + // _logout() does not trigger a "route not found" error. + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const SettingsScreen(), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + // Avoid OS keychain / SharedPreferences in tests. + tokenStorageProvider.overrideWithValue(fakeStorage), + settingsProvider.overrideWith(() => fakeSettings), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + + // Allow async providers (_currentUsernameProvider, settingsProvider) to + // resolve their futures before we inspect the widget tree. + await tester.pumpAndSettle(); + + return (storage: fakeStorage, settings: fakeSettings); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // -------------------------------------------------------------------------- + // Username display + // -------------------------------------------------------------------------- + + group('username display', () { + testWidgets('shows the token stored in TokenStorage as the username', + (tester) async { + await _pumpSettingsScreen(tester, initialToken: 'alice'); + + // The settings_username widget should show the stored token value. + expect(find.byKey(const Key('settings_username')), findsOneWidget); + expect(find.text('alice'), findsOneWidget); + }); + + testWidgets('shows placeholder when no token is stored', (tester) async { + // Pump with an empty token — simulates a freshly logged-out state + // where the screen is still mounted transiently before redirect. + final fakeStorage = _FakeTokenStorage(); // _token is null + final fakeSettings = _FakeSettingsNotifier('http://10.0.2.2:8080'); + + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => const SettingsScreen(), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(fakeStorage), + settingsProvider.overrideWith(() => fakeSettings), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + // When the token is null, the username row shows the fallback '—'. + expect(find.text('—'), findsOneWidget); + }); + }); + + // -------------------------------------------------------------------------- + // Base URL editing + // -------------------------------------------------------------------------- + + group('base URL', () { + testWidgets('pre-fills the URL field from the settings provider', + (tester) async { + await _pumpSettingsScreen( + tester, + initialUrl: 'https://player.example.com', + ); + + // The URL field should be seeded with the persisted value. + final field = tester.widget( + find.byKey(const Key('settings_base_url')), + ); + expect(field.controller?.text, equals('https://player.example.com')); + }); + + testWidgets('saves URL when Save URL button is tapped', (tester) async { + final result = await _pumpSettingsScreen( + tester, + initialUrl: 'http://10.0.2.2:8080', + ); + + // Edit the URL field. + await tester.tap(find.byKey(const Key('settings_base_url'))); + await tester.pump(); + await tester.enterText( + find.byKey(const Key('settings_base_url')), + 'https://new-server.example.com', + ); + + // Tap Save URL. + await tester.tap(find.byKey(const Key('settings_save_url'))); + await tester.pumpAndSettle(); + + // The fake notifier should have captured the new URL. + expect( + result.settings.savedUrl, + equals('https://new-server.example.com'), + ); + }); + + testWidgets('saves URL when keyboard Done action is triggered', + (tester) async { + final result = await _pumpSettingsScreen(tester); + + await tester.tap(find.byKey(const Key('settings_base_url'))); + await tester.pump(); + await tester.enterText( + find.byKey(const Key('settings_base_url')), + 'http://192.168.1.100:8080', + ); + + // Simulate the "Done" keyboard action. + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect( + result.settings.savedUrl, + equals('http://192.168.1.100:8080'), + ); + }); + + testWidgets('does not save when URL field is empty', (tester) async { + final result = await _pumpSettingsScreen( + tester, + initialUrl: 'http://10.0.2.2:8080', + ); + + // Clear the field and tap Save. + await tester.enterText(find.byKey(const Key('settings_base_url')), ''); + await tester.tap(find.byKey(const Key('settings_save_url'))); + await tester.pumpAndSettle(); + + // Nothing should have been saved since the field was blank. + expect(result.settings.savedUrl, isNull); + }); + }); + + // -------------------------------------------------------------------------- + // Logout flow + // -------------------------------------------------------------------------- + + group('logout flow', () { + testWidgets('logout button is visible and enabled initially', + (tester) async { + await _pumpSettingsScreen(tester); + + expect(find.byKey(const Key('settings_logout')), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('tapping logout clears the token from TokenStorage', + (tester) async { + final result = await _pumpSettingsScreen( + tester, + initialToken: 'alice', + ); + + // Verify the token is set before logout. + expect(result.storage._token, equals('alice')); + + // Tap the logout button. + await tester.tap(find.byKey(const Key('settings_logout'))); + await tester.pumpAndSettle(); + + // AuthStateNotifier.logout() should have deleted the token. + expect(result.storage._token, isNull); + }); + + testWidgets('tapping logout updates auth state to unauthenticated', + (tester) async { + // Capture the auth state notifier to inspect state after logout. + AuthState? capturedState; + final fakeStorage = _FakeTokenStorage().._token = 'bob'; + final fakeSettings = _FakeSettingsNotifier('http://10.0.2.2:8080'); + + // Minimal GoRouter: '/' renders SettingsScreen, '/login' is the redirect + // target so context.go('/login') in the logout handler does not throw. + final router = GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + builder: (_, __) => Consumer( + builder: (context, ref, _) { + // Watch and capture auth state for post-logout assertion. + final authAsync = ref.watch(authStateProvider); + authAsync.whenData((s) => capturedState = s); + return const SettingsScreen(); + }, + ), + ), + GoRoute( + path: '/login', + builder: (_, __) => const Scaffold(body: Text('Login')), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + tokenStorageProvider.overrideWithValue(fakeStorage), + settingsProvider.overrideWith(() => fakeSettings), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('settings_logout'))); + await tester.pumpAndSettle(); + + // After logout the auth state should be unauthenticated. + expect(capturedState?.isUnauthenticated, isTrue); + }); + }); +} -- cgit v1.2.3