1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
|
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';
import '../providers/theme_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'),
),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 24),
// ----------------------------------------------------------------
// Appearance section: light / dark / system theme toggle.
// ----------------------------------------------------------------
Text(
'Appearance',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
_ThemeToggle(),
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 24),
// ----------------------------------------------------------------
// Sharing section: navigate to MyShares screen.
// ----------------------------------------------------------------
Text(
'Sharing',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
// My Shares tile — navigates to /shares.
ListTile(
key: const Key('settings_my_shares'),
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.link_outlined),
title: const Text('My Shares'),
subtitle: const Text('View and revoke your share links'),
trailing: const Icon(Icons.chevron_right),
onTap: () => context.go(AppRoutes.shares),
),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Theme toggle widget
// ---------------------------------------------------------------------------
/// Segmented-button control that lets the user choose between
/// light, dark, and system (follow OS) theme modes.
///
/// Kept as a separate [ConsumerWidget] (SRP) so [_SettingsScreenState] does
/// not need to know about [themeProvider] — it only needs to place the widget.
class _ThemeToggle extends ConsumerWidget {
// ignore: prefer_const_constructors_in_immutables — private widget, not const
_ThemeToggle();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Default to system while the provider is loading so the toggle renders
// immediately rather than showing an empty state.
final current = ref.watch(themeProvider).valueOrNull ?? ThemeMode.system;
return _buildSegmentedButton(context, ref, current);
}
Widget _buildSegmentedButton(
BuildContext context,
WidgetRef ref,
ThemeMode current,
) {
return SegmentedButton<ThemeMode>(
key: const Key('settings_theme_toggle'),
segments: const [
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode_outlined),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.brightness_auto_outlined),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode_outlined),
label: Text('Dark'),
),
],
selected: {current},
// Allow only single selection — the user always has exactly one mode active.
multiSelectionEnabled: false,
onSelectionChanged: (selection) {
if (selection.isNotEmpty) {
ref.read(themeProvider.notifier).setThemeMode(selection.first);
}
},
);
}
}
// ---------------------------------------------------------------------------
// 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();
});
|