fix(#28): guard settings onChanged persisters against silent failures (panes)

Settings onChanged/onTap callbacks fire-and-forgot AppSettingsService Future<void> setters, so a persist failure rejected unhandled and was swallowed. Add a shared persistSetting(context, action) helper that awaits the setter in a try/catch and shows a dismissible error SnackBar, capturing the messenger before the await so it survives a picker-dialog pop. Wrap the 27 onChanged/onTap setter sites in app_settings_view + message_settings_view; setAppDebugLogEnabled and setNotificationsEnabled (which await + show their own confirmation) get an inline try/catch instead.

Covers the two onChanged panes; the settings_screen action flows (GPX export, location dialogs, version-info mounted guard) follow in a second pass, so #28 stays open. Error strings are English for now (separate settings l10n sweep).

Adds a persistSetting widget test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/60/head
Strycher 3 months ago
parent 5227507810
commit d2cca1da82

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
/// Runs a settings-persist [action] and surfaces a dismissible error SnackBar if
/// it throws, instead of dropping the returned Future and swallowing the error
/// (no silent failures — #28).
///
/// Call sites intentionally don't await the returned Future: this function never
/// rethrows (it catches internally), so dropping it is safe. The
/// [ScaffoldMessenger] is captured before the await, so the error still shows
/// even when the triggering widget (e.g. a picker dialog) is popped immediately
/// after.
///
/// [message] is English-only for now; localizing it is part of the separate
/// settings l10n sweep noted on #28.
Future<void> persistSetting(
BuildContext context,
Future<void> Function() action, {
String message = 'Could not save that setting. Please try again.',
}) async {
final messenger = ScaffoldMessenger.of(context);
try {
await action();
} catch (_) {
messenger.showSnackBar(
SnackBar(
content: GestureDetector(
onTap: () => messenger.hideCurrentSnackBar(),
child: Text(message),
),
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 4),
dismissDirection: DismissDirection.down,
),
);
}
}

@ -9,6 +9,7 @@ import '../../models/app_settings.dart';
import '../../models/translation_support.dart'; import '../../models/translation_support.dart';
import '../../services/app_settings_service.dart'; import '../../services/app_settings_service.dart';
import '../../services/translation_service.dart'; import '../../services/translation_service.dart';
import '../../helpers/settings_persist.dart';
import '../../helpers/snack_bar_builder.dart'; import '../../helpers/snack_bar_builder.dart';
import '../map_cache_screen.dart'; import '../map_cache_screen.dart';
@ -106,7 +107,7 @@ class AppSettingsView extends StatelessWidget {
), ),
value: settingsService.settings.enableMessageTracing, value: settingsService.settings.enableMessageTracing,
onChanged: (value) { onChanged: (value) {
settingsService.setEnableMessageTracing(value); persistSetting(context, () => settingsService.setEnableMessageTracing(value));
}, },
), ),
], ],
@ -135,7 +136,7 @@ class AppSettingsView extends StatelessWidget {
subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle), subtitle: Text(context.l10n.appSettings_showRepeatersSubtitle),
value: settingsService.settings.mapShowRepeaters, value: settingsService.settings.mapShowRepeaters,
onChanged: (value) { onChanged: (value) {
settingsService.setMapShowRepeaters(value); persistSetting(context, () => settingsService.setMapShowRepeaters(value));
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
@ -145,7 +146,7 @@ class AppSettingsView extends StatelessWidget {
subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle), subtitle: Text(context.l10n.appSettings_showChatNodesSubtitle),
value: settingsService.settings.mapShowChatNodes, value: settingsService.settings.mapShowChatNodes,
onChanged: (value) { onChanged: (value) {
settingsService.setMapShowChatNodes(value); persistSetting(context, () => settingsService.setMapShowChatNodes(value));
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
@ -155,7 +156,7 @@ class AppSettingsView extends StatelessWidget {
subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle), subtitle: Text(context.l10n.appSettings_showOtherNodesSubtitle),
value: settingsService.settings.mapShowOtherNodes, value: settingsService.settings.mapShowOtherNodes,
onChanged: (value) { onChanged: (value) {
settingsService.setMapShowOtherNodes(value); persistSetting(context, () => settingsService.setMapShowOtherNodes(value));
}, },
), ),
const Divider(height: 1), const Divider(height: 1),
@ -232,7 +233,7 @@ class AppSettingsView extends StatelessWidget {
title: Text(context.l10n.translation_enableTitle), title: Text(context.l10n.translation_enableTitle),
subtitle: Text(context.l10n.translation_enableSubtitle), subtitle: Text(context.l10n.translation_enableSubtitle),
value: settings.translationEnabled, value: settings.translationEnabled,
onChanged: settingsService.setTranslationEnabled, onChanged: (value) => persistSetting(context, () => settingsService.setTranslationEnabled(value)),
), ),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
@ -250,7 +251,7 @@ class AppSettingsView extends StatelessWidget {
), ),
value: settings.autoTranslateIncomingMessages, value: settings.autoTranslateIncomingMessages,
onChanged: translationEnabled onChanged: translationEnabled
? settingsService.setAutoTranslateIncomingMessages ? (value) => persistSetting(context, () => settingsService.setAutoTranslateIncomingMessages(value))
: null, : null,
), ),
const Divider(height: 1), const Divider(height: 1),
@ -269,7 +270,7 @@ class AppSettingsView extends StatelessWidget {
), ),
value: settings.composerTranslationEnabled, value: settings.composerTranslationEnabled,
onChanged: translationEnabled onChanged: translationEnabled
? settingsService.setComposerTranslationEnabled ? (value) => persistSetting(context, () => settingsService.setComposerTranslationEnabled(value))
: null, : null,
), ),
const Divider(height: 1), const Divider(height: 1),
@ -306,7 +307,7 @@ class AppSettingsView extends StatelessWidget {
onChanged: settings.translationDownloadedModels.isEmpty onChanged: settings.translationDownloadedModels.isEmpty
? null ? null
: (value) { : (value) {
settingsService.setTranslationSelectedModelId(value); persistSetting(context, () => settingsService.setTranslationSelectedModelId(value));
}, },
), ),
), ),
@ -350,7 +351,7 @@ class AppSettingsView extends StatelessWidget {
children: [ children: [
_TranslationUrlField( _TranslationUrlField(
initialValue: settings.translationModelSourceUrl ?? '', initialValue: settings.translationModelSourceUrl ?? '',
onChanged: settingsService.setTranslationModelSourceUrl, onChanged: (value) => persistSetting(context, () => settingsService.setTranslationModelSourceUrl(value)),
onDownload: translationService.isBusy onDownload: translationService.isBusy
? null ? null
: (url) => _downloadTranslationModel( : (url) => _downloadTranslationModel(
@ -423,8 +424,12 @@ class AppSettingsView extends StatelessWidget {
), ),
icon: const Icon(Icons.delete_outline), icon: const Icon(Icons.delete_outline),
), ),
onTap: () => settingsService onTap: () => persistSetting(
.setTranslationSelectedModelId(model.id), context,
() => settingsService.setTranslationSelectedModelId(
model.id,
),
),
), ),
), ),
], ],
@ -497,9 +502,12 @@ class AppSettingsView extends StatelessWidget {
onChanged: isConnected onChanged: isConnected
? (value) { ? (value) {
if (value != null) { if (value != null) {
settingsService.setBatteryChemistryForDevice( persistSetting(
deviceId, context,
value, () => settingsService.setBatteryChemistryForDevice(
deviceId,
value,
),
); );
} }
} }
@ -537,7 +545,7 @@ class AppSettingsView extends StatelessWidget {
groupValue: settingsService.settings.themeMode, groupValue: settingsService.settings.themeMode,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsService.setThemeMode(value); persistSetting(context, () => settingsService.setThemeMode(value));
Navigator.pop(context); Navigator.pop(context);
} }
}, },
@ -592,7 +600,7 @@ class AppSettingsView extends StatelessWidget {
groupValue: settingsService.settings.clockFormat, groupValue: settingsService.settings.clockFormat,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsService.setClockFormat(value); persistSetting(context, () => settingsService.setClockFormat(value));
Navigator.pop(context); Navigator.pop(context);
} }
}, },
@ -690,7 +698,7 @@ class AppSettingsView extends StatelessWidget {
child: RadioGroup<String?>( child: RadioGroup<String?>(
groupValue: settingsService.settings.languageOverride, groupValue: settingsService.settings.languageOverride,
onChanged: (value) { onChanged: (value) {
settingsService.setLanguageOverride(value); persistSetting(context, () => settingsService.setLanguageOverride(value));
Navigator.pop(context); Navigator.pop(context);
}, },
child: Column( child: Column(
@ -798,7 +806,7 @@ class AppSettingsView extends StatelessWidget {
groupValue: settingsService.settings.mapTimeFilterHours, groupValue: settingsService.settings.mapTimeFilterHours,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsService.setMapTimeFilterHours(value); persistSetting(context, () => settingsService.setMapTimeFilterHours(value));
Navigator.pop(context); Navigator.pop(context);
} }
}, },
@ -852,7 +860,7 @@ class AppSettingsView extends StatelessWidget {
groupValue: settingsService.settings.unitSystem, groupValue: settingsService.settings.unitSystem,
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsService.setUnitSystem(value); persistSetting(context, () => settingsService.setUnitSystem(value));
Navigator.pop(context); Navigator.pop(context);
} }
}, },
@ -890,7 +898,7 @@ class AppSettingsView extends StatelessWidget {
currentLanguageCode: currentLanguageCode:
settingsService.settings.translationTargetLanguageCode, settingsService.settings.translationTargetLanguageCode,
onLanguageSelected: (value) { onLanguageSelected: (value) {
settingsService.setTranslationTargetLanguageCode(value); persistSetting(context, () => settingsService.setTranslationTargetLanguageCode(value));
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
@ -1037,7 +1045,7 @@ class AppSettingsView extends StatelessWidget {
}).toList(), }).toList(),
onChanged: (value) { onChanged: (value) {
if (value != null) { if (value != null) {
settingsService.setSelectedCyr2LatProfile(value); persistSetting(context, () => settingsService.setSelectedCyr2LatProfile(value));
} }
}, },
), ),
@ -1318,7 +1326,19 @@ class AppSettingsView extends StatelessWidget {
subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle), subtitle: Text(context.l10n.appSettings_appDebugLoggingSubtitle),
value: settingsService.settings.appDebugLogEnabled, value: settingsService.settings.appDebugLogEnabled,
onChanged: (value) async { onChanged: (value) async {
await settingsService.setAppDebugLogEnabled(value); try {
await settingsService.setAppDebugLogEnabled(value);
} catch (_) {
if (context.mounted) {
showDismissibleSnackBar(
context,
content: const Text(
'Could not change debug logging. Please try again.',
),
);
}
return;
}
if (!context.mounted) return; if (!context.mounted) return;
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,

@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../../l10n/l10n.dart'; import '../../l10n/l10n.dart';
import '../../services/app_settings_service.dart'; import '../../services/app_settings_service.dart';
import '../../services/notification_service.dart'; import '../../services/notification_service.dart';
import '../../helpers/settings_persist.dart';
import '../../helpers/snack_bar_builder.dart'; import '../../helpers/snack_bar_builder.dart';
/// Embeddable view (no Scaffold) for the Message Settings shell pane. /// Embeddable view (no Scaffold) for the Message Settings shell pane.
@ -70,7 +71,19 @@ class MessageSettingsView extends StatelessWidget {
} }
} }
await settingsService.setNotificationsEnabled(value); try {
await settingsService.setNotificationsEnabled(value);
} catch (_) {
if (context.mounted) {
showDismissibleSnackBar(
context,
content: const Text(
'Could not change notifications. Please try again.',
),
);
}
return;
}
if (context.mounted) { if (context.mounted) {
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
@ -111,7 +124,7 @@ class MessageSettingsView extends StatelessWidget {
value: settingsService.settings.notifyOnNewMessage, value: settingsService.settings.notifyOnNewMessage,
onChanged: settingsService.settings.notificationsEnabled onChanged: settingsService.settings.notificationsEnabled
? (value) { ? (value) {
settingsService.setNotifyOnNewMessage(value); persistSetting(context, () => settingsService.setNotifyOnNewMessage(value));
} }
: null, : null,
), ),
@ -142,7 +155,7 @@ class MessageSettingsView extends StatelessWidget {
value: settingsService.settings.notifyOnNewChannelMessage, value: settingsService.settings.notifyOnNewChannelMessage,
onChanged: settingsService.settings.notificationsEnabled onChanged: settingsService.settings.notificationsEnabled
? (value) { ? (value) {
settingsService.setNotifyOnNewChannelMessage(value); persistSetting(context, () => settingsService.setNotifyOnNewChannelMessage(value));
} }
: null, : null,
), ),
@ -173,7 +186,7 @@ class MessageSettingsView extends StatelessWidget {
value: settingsService.settings.notifyOnNewAdvert, value: settingsService.settings.notifyOnNewAdvert,
onChanged: settingsService.settings.notificationsEnabled onChanged: settingsService.settings.notificationsEnabled
? (value) { ? (value) {
settingsService.setNotifyOnNewAdvert(value); persistSetting(context, () => settingsService.setNotifyOnNewAdvert(value));
} }
: null, : null,
), ),
@ -205,7 +218,7 @@ class MessageSettingsView extends StatelessWidget {
), ),
value: settingsService.settings.clearPathOnMaxRetry, value: settingsService.settings.clearPathOnMaxRetry,
onChanged: (value) { onChanged: (value) {
settingsService.setClearPathOnMaxRetry(value); persistSetting(context, () => settingsService.setClearPathOnMaxRetry(value));
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text( content: Text(
@ -223,7 +236,7 @@ class MessageSettingsView extends StatelessWidget {
title: Text(context.l10n.appSettings_jumpToOldestUnread), title: Text(context.l10n.appSettings_jumpToOldestUnread),
subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle), subtitle: Text(context.l10n.appSettings_jumpToOldestUnreadSubtitle),
value: settingsService.settings.jumpToOldestUnread, value: settingsService.settings.jumpToOldestUnread,
onChanged: settingsService.setJumpToOldestUnread, onChanged: (value) => persistSetting(context, () => settingsService.setJumpToOldestUnread(value)),
), ),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
@ -232,7 +245,7 @@ class MessageSettingsView extends StatelessWidget {
subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle), subtitle: Text(context.l10n.appSettings_autoRouteRotationSubtitle),
value: settingsService.settings.autoRouteRotationEnabled, value: settingsService.settings.autoRouteRotationEnabled,
onChanged: (value) { onChanged: (value) {
settingsService.setAutoRouteRotationEnabled(value); persistSetting(context, () => settingsService.setAutoRouteRotationEnabled(value));
showDismissibleSnackBar( showDismissibleSnackBar(
context, context,
content: Text( content: Text(
@ -260,8 +273,10 @@ class MessageSettingsView extends StatelessWidget {
label: settingsService.settings.maxRouteWeight label: settingsService.settings.maxRouteWeight
.round() .round()
.toString(), .toString(),
onChanged: (value) => onChanged: (value) => persistSetting(
settingsService.setMaxRouteWeight(value), context,
() => settingsService.setMaxRouteWeight(value),
),
), ),
], ],
), ),
@ -280,8 +295,10 @@ class MessageSettingsView extends StatelessWidget {
divisions: 9, divisions: 9,
label: settingsService.settings.initialRouteWeight label: settingsService.settings.initialRouteWeight
.toStringAsFixed(1), .toStringAsFixed(1),
onChanged: (value) => onChanged: (value) => persistSetting(
settingsService.setInitialRouteWeight(value), context,
() => settingsService.setInitialRouteWeight(value),
),
), ),
], ],
), ),
@ -304,8 +321,11 @@ class MessageSettingsView extends StatelessWidget {
divisions: 19, divisions: 19,
label: settingsService.settings.routeWeightSuccessIncrement label: settingsService.settings.routeWeightSuccessIncrement
.toStringAsFixed(1), .toStringAsFixed(1),
onChanged: (value) => onChanged: (value) => persistSetting(
settingsService.setRouteWeightSuccessIncrement(value), context,
() =>
settingsService.setRouteWeightSuccessIncrement(value),
),
), ),
], ],
), ),
@ -328,8 +348,11 @@ class MessageSettingsView extends StatelessWidget {
divisions: 19, divisions: 19,
label: settingsService.settings.routeWeightFailureDecrement label: settingsService.settings.routeWeightFailureDecrement
.toStringAsFixed(1), .toStringAsFixed(1),
onChanged: (value) => onChanged: (value) => persistSetting(
settingsService.setRouteWeightFailureDecrement(value), context,
() =>
settingsService.setRouteWeightFailureDecrement(value),
),
), ),
], ],
), ),
@ -349,8 +372,10 @@ class MessageSettingsView extends StatelessWidget {
divisions: 8, divisions: 8,
label: settingsService.settings.maxMessageRetries label: settingsService.settings.maxMessageRetries
.toString(), .toString(),
onChanged: (value) => onChanged: (value) => persistSetting(
settingsService.setMaxMessageRetries(value.toInt()), context,
() => settingsService.setMaxMessageRetries(value.toInt()),
),
), ),
], ],
), ),

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/helpers/settings_persist.dart';
void main() {
Widget harness(Future<void> Function() action) {
return MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () =>
persistSetting(context, action, message: 'Save failed'),
child: const Text('go'),
),
),
),
);
}
testWidgets('shows an error SnackBar when the persist action throws', (
tester,
) async {
await tester.pumpWidget(
harness(() async {
throw Exception('boom');
}),
);
await tester.tap(find.text('go'));
await tester.pump(); // run persistSetting (catch + schedule SnackBar)
await tester.pump(); // build the SnackBar
expect(find.text('Save failed'), findsOneWidget);
});
testWidgets('shows no SnackBar when the persist action succeeds', (
tester,
) async {
await tester.pumpWidget(harness(() async {}));
await tester.tap(find.text('go'));
await tester.pump();
await tester.pump();
expect(find.byType(SnackBar), findsNothing);
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.