Move backend config to a dialog; group settings into cards

Settings screen now splits responsibilities:
- Backend URL + API key move into a Test/Save popup dialog
  (backend_config_dialog.dart), reachable via a "Re-configure" action.
  Per M3 the Test button is left-aligned (neutral) with Cancel/Save
  grouped trailing. On first run the dialog auto-opens non-dismissible.
- The screen shows the connection read-only (URL plain, key masked with
  reveal) in a "Backend connection" card, and groups Theme + Push into an
  "App settings" card. Theme and push apply immediately, no Save button.
- Align all card titles to titleLarge across home/status/settings.

Adds a test seam (dioBuilderForTesting) so reconfigure keeps the mock Dio
in widget tests instead of issuing real requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-08 18:15:10 -04:00
co-authored by Claude Opus 4.8
parent 1d85ec6f83
commit 67b94b89a1
8 changed files with 564 additions and 217 deletions
+3 -3
View File
@@ -9,8 +9,8 @@
- [x] Fix bugs and release 0.1.1
- [x] Install in test server (docker) following documented process
- [x] Install test app on iPhone
- [ ] Test in-house for 1 week
- [ ] Implement push notifications
- [x] Test in-house for 1 week
- [x] Implement push notifications
- [ ] Release 0.2.0
- [ ] Install in test server and test app on iPhone
- [ ] Test in-house for 3 days
@@ -27,7 +27,7 @@
- [x] In the status screen, the "listening for" property of passive scanners should change to days and months (now its always minutes)
- [ ] Check for potential dependency upgrades
- [ ] In settings, move the backend configuration to a popup dialog that asks for the URL and API key and allows the user to test and save it. In the main screen it should present both items as read only and allow the user to change the theme and push notifications and both should trigger the change immediately (ie. without a save button)
- [x] In the frontend, in the settings screen, move the backend configuration to a popup dialog that asks for the URL and API key and allows the user to test and save it. In the main screen it should present both items as read only and allow the user to change the theme and push notifications and both should trigger the change immediately (ie. without a save button). The dialog for the backend config should be accesible via a "Re-configure" link or button (do what aligns best with M3). The first time the user uses the UI (or when the backend is not configured at all, it should navigate to the settings screen with the dialog open, and upon Saving the backend config it should refresh the settings page)
- [x] The permissions dialog for push notifications on android says "Allow frontend to send you notifications"
## Push relay
@@ -0,0 +1,229 @@
import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
import '../theme/dimens.dart';
import '../utils/oott_api.dart';
import '../utils/pref_utils.dart';
import '../utils/ui_snackbars.dart';
/// Shows the backend connection dialog (URL + API key) with a Test + Save flow.
///
/// Prefills from the stored configuration so reconfiguring starts from the
/// current values. Returns `true` when the user saved a new configuration and
/// `false` when dismissed.
///
/// On first run the backend is not configured yet; pass [dismissible] as
/// `false` so the user cannot close the dialog (no Cancel, no barrier or
/// back-gesture dismiss) until a configuration is saved.
Future<bool> showBackendConfigDialog(
BuildContext context, {
bool dismissible = true,
}) async {
final saved = await showDialog<bool>(
context: context,
barrierDismissible: dismissible,
builder: (_) => _BackendConfigDialog(dismissible: dismissible),
);
if (saved == true && context.mounted) {
UISnackbars.showSuccess(context, 'Settings saved successfully');
}
return saved ?? false;
}
class _BackendConfigDialog extends StatefulWidget {
const _BackendConfigDialog({required this.dismissible});
final bool dismissible;
@override
State<_BackendConfigDialog> createState() => _BackendConfigDialogState();
}
class _BackendConfigDialogState extends State<_BackendConfigDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _baseUrlController;
late final TextEditingController _apiKeyController;
bool _apiKeyVisible = false;
bool _testOk = false;
// Whether the connection details changed since the last successful test. The
// prefilled config starts unmodified so it can be re-saved without retesting,
// but any edit re-gates Save behind a fresh Test.
bool _connectionModified = false;
@override
void initState() {
super.initState();
_baseUrlController = TextEditingController(
text: PrefUtil.getValue('base_url', '') as String,
);
_apiKeyController = TextEditingController(
text: XOR().xorDecode(PrefUtil.getValue('api_key', '') as String),
);
}
@override
void dispose() {
_baseUrlController.dispose();
_apiKeyController.dispose();
super.dispose();
}
void _onConnectionChanged(String _) {
setState(() {
_testOk = false;
_connectionModified = true;
});
}
Future<void> _testConnection() async {
if (!_formKey.currentState!.validate()) return;
final result = await BackendAPI.test(
_baseUrlController.text,
_apiKeyController.text,
);
if (!mounted) return;
setState(() {
_testOk = result == null;
if (result == null) _connectionModified = false;
});
if (result == null) {
UISnackbars.showSuccess(context, 'It works!');
} else {
UISnackbars.showError(context, result);
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
bool ok;
try {
final urlOk = await PrefUtil.setValue(
'base_url',
_baseUrlController.text,
);
final keyOk = await PrefUtil.setValue(
'api_key',
XOR().xorEncode(_apiKeyController.text),
);
ok = urlOk && keyOk;
} catch (e) {
debugPrint('Failed to save backend configuration: $e');
ok = false;
}
if (!mounted) return;
if (ok) {
BackendAPI.instance.reconfigureFromPrefs();
Navigator.of(context).pop(true);
} else {
UISnackbars.showError(context, 'Failed to save settings');
}
}
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final saveDisabled = _connectionModified && !_testOk;
return PopScope(
canPop: widget.dismissible,
child: AlertDialog(
title: const Text('Backend configuration'),
content: SizedBox(
width: 420,
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _baseUrlController,
onChanged: _onConnectionChanged,
validator: (value) => value == null || value.isEmpty
? 'The URL cannot be empty'
: null,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
labelText: "Base URL of your OOTT server's API",
hintText: 'For example http://192.168.0.1:3000/api',
),
),
const SizedBox(height: Insets.lg),
TextFormField(
controller: _apiKeyController,
onChanged: _onConnectionChanged,
validator: (value) => value == null || value.isEmpty
? 'The API key cannot be empty'
: null,
obscureText: !_apiKeyVisible,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'API key',
suffixIcon: IconButton(
icon: Icon(
_apiKeyVisible
? Icons.visibility
: Icons.visibility_off,
),
onPressed: () =>
setState(() => _apiKeyVisible = !_apiKeyVisible),
),
),
),
if (saveDisabled) ...[
const SizedBox(height: Insets.sm),
Text(
'Test the connection before saving your changes.',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
],
),
),
),
// Test is a neutral utility action, so per Material 3 it sits on the
// left, separated from the dismiss/confirm pair (Cancel, Save) which
// stays grouped at the trailing edge with the confirming action last.
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
FilledButton.icon(
onPressed: _testConnection,
label: const Text('Test'),
icon: Icon(_testOk ? Icons.check : Icons.play_arrow),
style: FilledButton.styleFrom(
backgroundColor: _testOk
? appColors.success
: colorScheme.secondary,
foregroundColor: _testOk
? appColors.onSuccess
: colorScheme.onSecondary,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.dismissible) ...[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
const SizedBox(width: Insets.sm),
],
FilledButton.icon(
onPressed: saveDisabled ? null : _save,
label: const Text('Save'),
icon: const Icon(Icons.save),
),
],
),
],
),
);
}
}
+158 -167
View File
@@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../main.dart';
import '../theme/app_colors.dart';
import '../theme/dimens.dart';
import '../utils/oott_api.dart';
import '../utils/pref_utils.dart';
import '../utils/push_service.dart';
import '../utils/ui_snackbars.dart';
import 'backend_config_dialog.dart';
class Settings extends StatefulWidget {
const Settings({super.key, this.pushService});
@@ -22,11 +22,9 @@ class Settings extends StatefulWidget {
}
class _SettingsState extends State<Settings> {
final _baseUrlController = TextEditingController();
final _apiKeyController = TextEditingController();
String _baseUrl = '';
String _apiKey = '';
bool _apiKeyVisible = false;
bool _testOk = false;
bool _connectionModified = false;
bool _isFirstRun = false;
late String _selectedTheme;
late final PushService _pushService;
@@ -36,20 +34,27 @@ class _SettingsState extends State<Settings> {
// toggle only makes sense then, so it stays hidden until this is confirmed.
bool _pushMethodActive = false;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_isFirstRun = (PrefUtil.getValue('base_url', '') as String).isEmpty;
_baseUrlController.text = PrefUtil.getValue('base_url', '') as String;
_apiKeyController.text = XOR().xorDecode(
PrefUtil.getValue('api_key', '') as String,
);
_readConnectionFromPrefs();
_isFirstRun = _baseUrl.isEmpty;
_selectedTheme = context.read<AppState>().themeKey;
_pushService = widget.pushService ?? FirebasePushService();
_pushEnabled = PrefUtil.getValue('push_enabled', false) as bool;
_loadConfig();
// First run / unconfigured: open the connection dialog immediately and keep
// it open (non-dismissible) until the user saves a working configuration.
if (_isFirstRun) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _openConfigDialog(dismissible: false);
});
}
}
void _readConnectionFromPrefs() {
_baseUrl = PrefUtil.getValue('base_url', '') as String;
_apiKey = XOR().xorDecode(PrefUtil.getValue('api_key', '') as String);
}
// Learn the backend's notification method so the push toggle is only shown
@@ -65,36 +70,19 @@ class _SettingsState extends State<Settings> {
}
}
@override
void dispose() {
_baseUrlController.dispose();
_apiKeyController.dispose();
super.dispose();
}
void _onConnectionChanged(String _) {
setState(() {
_testOk = false;
_connectionModified = true;
});
}
Future<void> _testConnection() async {
if (!_formKey.currentState!.validate()) return;
final result = await BackendAPI.test(
_baseUrlController.text,
_apiKeyController.text,
// Opens the backend connection dialog and, on save, refreshes the screen with
// the new connection details and re-reads the backend config.
Future<void> _openConfigDialog({bool dismissible = true}) async {
final saved = await showBackendConfigDialog(
context,
dismissible: dismissible,
);
if (!mounted) return;
if (!mounted || !saved) return;
setState(() {
_testOk = result == null;
if (result == null) _connectionModified = false;
_isFirstRun = false;
_readConnectionFromPrefs();
});
if (result == null) {
UISnackbars.showSuccess(context, 'It works!');
} else {
UISnackbars.showError(context, result);
}
_loadConfig();
}
// Enable or disable push on this specific device. The toggle reflects user
@@ -141,107 +129,123 @@ class _SettingsState extends State<Settings> {
}
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
try {
final urlOk = await PrefUtil.setValue(
'base_url',
_baseUrlController.text,
);
if (!mounted) return;
final keyOk = await PrefUtil.setValue(
'api_key',
XOR().xorEncode(_apiKeyController.text),
);
if (!mounted) return;
final themeOk = await context.read<AppState>().setTheme(_selectedTheme);
if (!mounted) return;
if (urlOk && keyOk && themeOk) {
BackendAPI.instance.reconfigureFromPrefs();
UISnackbars.showSuccess(context, 'Settings saved successfully');
} else {
UISnackbars.showError(context, 'Failed to save settings');
}
} catch (e) {
debugPrint('Failed to save settings: $e');
if (!mounted) return;
UISnackbars.showError(context, 'Failed to save settings');
}
}
@override
Widget build(BuildContext context) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final saveDisabled = _connectionModified && !_testOk;
return SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isFirstRun) ...[
Card(
color: colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: Row(
children: [
Icon(
Icons.waving_hand_outlined,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: Insets.md),
Expanded(
child: Text(
'Welcome to OOTT! Point the app at your servers API '
'to get started.',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
),
),
),
],
),
),
),
const SizedBox(height: Insets.lg),
],
_buildConnectionSummary(context),
const SizedBox(height: Insets.lg),
_buildAppSettings(context),
],
),
);
}
// Read-only summary of the backend connection with a link to reconfigure it.
Widget _buildConnectionSummary(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
final configured = _baseUrl.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_isFirstRun) ...[
Card(
color: colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: Row(
children: [
Icon(
Icons.waving_hand_outlined,
color: colorScheme.onSecondaryContainer,
),
const SizedBox(width: Insets.md),
Expanded(
child: Text(
'Welcome to OOTT! Point the app at your servers API '
'below, then Test and Save to get started.',
style: textTheme.bodyMedium?.copyWith(
color: colorScheme.onSecondaryContainer,
),
),
),
],
Row(
children: [
Expanded(
child: Text(
'Backend connection',
style: textTheme.titleLarge,
),
),
),
const SizedBox(height: Insets.lg),
],
TextFormField(
controller: _baseUrlController,
onChanged: _onConnectionChanged,
validator: (value) => value == null || value.isEmpty
? 'The URL cannot be empty'
: null,
decoration: const InputDecoration(
border: UnderlineInputBorder(),
labelText: "Base URL of your OOTT server's API",
hintText: 'For example http://192.168.0.1:3000/api',
),
),
const SizedBox(height: Insets.lg),
TextFormField(
controller: _apiKeyController,
onChanged: _onConnectionChanged,
validator: (value) => value == null || value.isEmpty
? 'The API key cannot be empty'
: null,
obscureText: !_apiKeyVisible,
decoration: InputDecoration(
border: const UnderlineInputBorder(),
labelText: 'API key',
suffixIcon: IconButton(
icon: Icon(
_apiKeyVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () =>
setState(() => _apiKeyVisible = !_apiKeyVisible),
TextButton.icon(
onPressed: () => _openConfigDialog(),
icon: const Icon(Icons.edit_outlined),
label: const Text('Re-configure'),
),
),
],
),
const SizedBox(height: Insets.lg),
const SizedBox(height: Insets.sm),
_buildReadOnlyField(
context,
label: 'Base URL',
value: configured ? _baseUrl : 'Not configured yet',
),
const SizedBox(height: Insets.md),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: _buildReadOnlyField(
context,
label: 'API key',
value: !configured
? 'Not configured yet'
: (_apiKeyVisible ? _apiKey : '••••••••'),
),
),
if (configured)
IconButton(
icon: Icon(
_apiKeyVisible ? Icons.visibility : Icons.visibility_off,
),
onPressed: () =>
setState(() => _apiKeyVisible = !_apiKeyVisible),
),
],
),
],
),
),
);
}
// App-level preferences (theme, per-device push) that apply immediately.
Widget _buildAppSettings(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('App settings', style: textTheme.titleLarge),
const SizedBox(height: Insets.md),
DropdownButtonFormField<String>(
initialValue: _selectedTheme,
decoration: const InputDecoration(
@@ -258,8 +262,11 @@ class _SettingsState extends State<Settings> {
child: Text('Gruvbox Dark'),
),
],
// Applies immediately; the theme persists itself, no Save needed.
onChanged: (value) {
if (value != null) setState(() => _selectedTheme = value);
if (value == null) return;
setState(() => _selectedTheme = value);
context.read<AppState>().setTheme(value);
},
),
if (_pushService.isSupported && _pushMethodActive) ...[
@@ -274,48 +281,32 @@ class _SettingsState extends State<Settings> {
onChanged: _pushBusy ? null : _togglePush,
),
],
const SizedBox(height: Insets.lg),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FilledButton.icon(
onPressed: () {
_testConnection();
},
label: const Text('Test'),
icon: Icon(_testOk ? Icons.check : Icons.play_arrow),
style: FilledButton.styleFrom(
backgroundColor: _testOk
? appColors.success
: colorScheme.secondary,
foregroundColor: _testOk
? appColors.onSuccess
: colorScheme.onSecondary,
),
),
const SizedBox(width: Insets.sm),
FilledButton.icon(
onPressed: saveDisabled ? null : _save,
label: const Text('Save'),
icon: const Icon(Icons.save),
),
],
),
if (saveDisabled) ...[
const SizedBox(height: Insets.sm),
Align(
alignment: Alignment.centerRight,
child: Text(
'Test the connection before saving your changes.',
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
],
],
),
),
);
}
Widget _buildReadOnlyField(
BuildContext context, {
required String label,
required String value,
}) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: Insets.xs),
Text(value, style: textTheme.bodyLarge),
],
);
}
}
+9 -1
View File
@@ -37,12 +37,20 @@ class BackendAPI {
late String _apiKey;
late Dio _dio;
/// Test-only override for how [reconfigureFromPrefs] builds its [Dio], so a
/// reconfigure (e.g. after saving the backend settings) keeps using a client
/// backed by a mock adapter instead of issuing real network requests.
@visibleForTesting
static Dio Function(String baseUrl, String apiKey)? dioBuilderForTesting;
void reconfigureFromPrefs() {
_baseUrl =
PrefUtil.getValue("base_url", "http://localhost:3000/api") as String;
_apiKey = XOR().xorDecode(PrefUtil.getValue("api_key", "") as String);
_dio = buildDio(baseUrl: _baseUrl, apiKey: _apiKey);
_dio =
dioBuilderForTesting?.call(_baseUrl, _apiKey) ??
buildDio(baseUrl: _baseUrl, apiKey: _apiKey);
BackendReachability.instance.setProber(() => _dio.get('/test'));
}
@@ -95,7 +95,7 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>>
children: [
Text(
widget.title,
style: theme.textTheme.titleMedium,
style: theme.textTheme.titleLarge,
),
if (isStale) ...[
const SizedBox(width: 6),
@@ -63,5 +63,8 @@ Future<DioAdapter> setUpBackendForTest({
final dio = Dio(BaseOptions(baseUrl: 'http://test.local/api'));
final adapter = DioAdapter(dio: dio);
BackendAPI.instance.dioForTesting = dio;
// Keep the mock client even after a reconfigure (e.g. saving the backend
// settings) so no test ever issues a real network request.
BackendAPI.dioBuilderForTesting = (_, _) => dio;
return adapter;
}
@@ -0,0 +1,129 @@
import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/settings/settings.dart';
import 'package:frontend/utils/pref_utils.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
void main() {
setUp(() async {
final adapter = await setUpBackendForTest(
prefs: {
'base_url': 'http://my.server/api',
'api_key': XOR().xorEncode('topsecret'),
'theme': 'catppuccin_mocha',
},
);
adapter.onGet(
'/config',
(server) => server.reply(200, {
'notifications': {'method': 'none'},
}),
);
await PrefUtil.setValue('base_url', 'http://my.server/api');
await PrefUtil.setValue('api_key', XOR().xorEncode('topsecret'));
});
// Opens the dialog from the settings screen via the Re-configure button.
Future<void> openDialog(WidgetTester tester) async {
await pumpScreen(tester, const Settings());
await tester.pump(const Duration(milliseconds: 10));
await tester.tap(find.widgetWithText(TextButton, 'Re-configure'));
await tester.pumpAndSettle();
}
testWidgets('prefills the dialog from stored preferences', (tester) async {
await openDialog(tester);
expect(find.text('Backend configuration'), findsOneWidget);
expect(
find.widgetWithText(TextFormField, 'http://my.server/api'),
findsOneWidget,
);
});
testWidgets('validates an empty base URL when testing the connection', (
tester,
) async {
await openDialog(tester);
await tester.enterText(find.byType(TextFormField).first, '');
await tester.tap(find.widgetWithText(FilledButton, 'Test'));
await tester.pump();
expect(find.text('The URL cannot be empty'), findsOneWidget);
});
testWidgets('editing the connection re-gates Save behind a Test', (
tester,
) async {
await openDialog(tester);
final saveButton = find.widgetWithText(FilledButton, 'Save');
expect(
tester.widget<FilledButton>(saveButton).onPressed,
isNotNull,
reason: 'enabled for the unmodified, prefilled form',
);
await tester.enterText(
find.byType(TextFormField).first,
'http://changed/api',
);
await tester.pump();
expect(
tester.widget<FilledButton>(saveButton).onPressed,
isNull,
reason: 'disabled until the new connection is tested',
);
expect(
find.textContaining('Test the connection before saving'),
findsOneWidget,
);
});
testWidgets('saving the prefilled configuration persists and closes', (
tester,
) async {
await openDialog(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
// Settle the dialog's exit transition so it has fully left the tree.
await tester.pumpAndSettle();
expect(find.text('Settings saved successfully'), findsOneWidget);
expect(find.text('Backend configuration'), findsNothing);
expect(PrefUtil.getValue('base_url', ''), 'http://my.server/api');
// Unmount so the SnackBar's auto-dismiss timer doesn't leak.
await tearDownTree(tester);
});
testWidgets('cancelling discards edits without persisting', (tester) async {
await openDialog(tester);
await tester.enterText(
find.byType(TextFormField).first,
'http://changed/api',
);
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
await tester.pumpAndSettle();
expect(find.text('Backend configuration'), findsNothing);
expect(PrefUtil.getValue('base_url', ''), 'http://my.server/api');
});
testWidgets('first run auto-opens a non-dismissible dialog', (tester) async {
await PrefUtil.setValue('base_url', '');
await pumpScreen(tester, const Settings());
// Let the post-frame callback open the dialog.
await tester.pumpAndSettle();
expect(find.text('Backend configuration'), findsOneWidget);
// No Cancel on first run; the user must save a working configuration.
expect(find.widgetWithText(TextButton, 'Cancel'), findsNothing);
expect(find.widgetWithText(FilledButton, 'Save'), findsOneWidget);
});
}
+32 -45
View File
@@ -24,63 +24,68 @@ void main() {
'notifications': {'method': 'none'},
}),
);
// The mock SharedPreferences persist across tests in this isolate; restore
// the configured connection a previous test may have changed.
await PrefUtil.setValue('base_url', 'http://my.server/api');
await PrefUtil.setValue('api_key', XOR().xorEncode('topsecret'));
await PrefUtil.setValue('theme', 'catppuccin_mocha');
});
testWidgets('prefills the form from stored preferences', (tester) async {
testWidgets('shows the configured connection read-only', (tester) async {
await pumpScreen(tester, const Settings());
// Let the on-init config request resolve so its timer doesn't leak.
await tester.pump(const Duration(milliseconds: 10));
expect(find.text('http://my.server/api'), findsOneWidget);
// The key stays masked until revealed.
expect(find.text('topsecret'), findsNothing);
expect(find.text('••••••••'), findsOneWidget);
expect(find.text('Catppuccin Mocha'), findsOneWidget);
// No inline Save button: theme and push apply immediately.
expect(find.widgetWithText(FilledButton, 'Save'), findsNothing);
});
testWidgets('validates an empty base URL when testing the connection', (
testWidgets('reveals the API key when the visibility toggle is tapped', (
tester,
) async {
await pumpScreen(tester, const Settings());
await tester.pump(const Duration(milliseconds: 10));
await tester.enterText(find.byType(TextFormField).first, '');
await tester.tap(find.widgetWithText(FilledButton, 'Test'));
await tester.tap(find.byIcon(Icons.visibility_off));
await tester.pump();
expect(find.text('The URL cannot be empty'), findsOneWidget);
expect(find.text('topsecret'), findsOneWidget);
});
testWidgets('disables Save once the connection details are edited', (
testWidgets('changing the theme applies immediately, no save needed', (
tester,
) async {
await pumpScreen(tester, const Settings());
final saveButton = find.widgetWithText(FilledButton, 'Save');
await tester.pump(const Duration(milliseconds: 10));
expect(
tester.widget<FilledButton>(saveButton).onPressed,
isNotNull,
reason: 'enabled for the unmodified, prefilled form',
);
await tester.tap(find.text('Catppuccin Mocha'));
await tester.pumpAndSettle();
await tester.tap(find.text('Gruvbox Dark').last);
await tester.pumpAndSettle();
await tester.enterText(
find.byType(TextFormField).first,
'http://changed/api',
);
await tester.pump();
expect(
tester.widget<FilledButton>(saveButton).onPressed,
isNull,
reason: 'disabled until the new connection is tested',
);
expect(PrefUtil.getValue('theme', ''), 'gruvbox_dark');
});
testWidgets('saving the unmodified form shows a success message', (
testWidgets('opens the backend configuration dialog from Re-configure', (
tester,
) async {
await pumpScreen(tester, const Settings());
await tester.pump(const Duration(milliseconds: 10));
await tester.tap(find.widgetWithText(FilledButton, 'Save'));
await pumpUntilFound(tester, find.text('Settings saved successfully'));
await tester.tap(find.widgetWithText(TextButton, 'Re-configure'));
await tester.pumpAndSettle();
expect(find.text('Settings saved successfully'), findsOneWidget);
expect(find.text('Backend configuration'), findsOneWidget);
// Prefilled from the stored connection.
expect(
find.widgetWithText(TextFormField, 'http://my.server/api'),
findsOneWidget,
);
});
testWidgets('shows the welcome intro when no server is configured', (
@@ -102,22 +107,4 @@ void main() {
expect(find.textContaining('Welcome to OOTT'), findsNothing);
});
testWidgets('explains why Save is disabled after editing the connection', (
tester,
) async {
await pumpScreen(tester, const Settings());
// Editing the connection requires re-testing before saving.
await tester.enterText(
find.byType(TextFormField).first,
'http://changed/api',
);
await tester.pump();
expect(
find.textContaining('Test the connection before saving'),
findsOneWidget,
);
});
}