diff options
Diffstat (limited to 'player-android/lib')
| -rw-r--r-- | player-android/lib/app_routes.dart | 1 | ||||
| -rw-r--r-- | player-android/lib/providers/settings_provider.dart | 87 | ||||
| -rw-r--r-- | player-android/lib/router.dart | 15 | ||||
| -rw-r--r-- | player-android/lib/screens/settings_screen.dart | 234 |
4 files changed, 334 insertions, 3 deletions
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<AppSettings> { + @override + Future<AppSettings> 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<void> 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, AppSettings>( + 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<GoRouter>((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<GoRouter>((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<SettingsScreen> createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends ConsumerState<SettingsScreen> { + // 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<void> _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<void> _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<String?>((ref) { + final storage = ref.watch(tokenStorageProvider); + return storage.readToken(); +}); |
