From eb2888162eee66b36e1da652b03fbbebcc0f8730 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sun, 14 Jun 2026 18:33:32 -0400 Subject: [PATCH] feat(#18): inline Privacy settings as apply-on-change section Replace the Privacy save/cancel dialog with an inline _PrivacySection in the pane: advert-location + multi-ACKs toggles and the three telemetry-mode dropdowns now apply on change. All five commit through one setTelemetryModeBase call, so each control sends the full current state (coalesced via an apply guard) and reverts to the device's values on failure with an error snackbar -- folding in the #28 error handling for this pane. Removes the category->tile->dialog hop; drops _privacySettings. Co-Authored-By: Claude Opus 4.8 --- lib/screens/settings_screen.dart | 306 ++++++++++++++++++------------- 1 file changed, 182 insertions(+), 124 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 476a361..58849ea 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -223,12 +223,28 @@ class _SettingsScreenState extends State { padding: const EdgeInsets.all(16), children: [ Card( - child: ListTile( - leading: const Icon(Icons.visibility_off_outlined), - title: Text(l10n.settings_privacy), - subtitle: Text(l10n.settings_privacySubtitle), - trailing: const Icon(Icons.chevron_right), - onTap: () => _privacySettings(context, connector), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: Text( + l10n.settings_privacy, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + _PrivacySection( + key: ValueKey( + '${connector.telemetryModeBase}-${connector.telemetryModeLoc}-' + '${connector.telemetryModeEnv}-${connector.advertLocationPolicy}-' + '${connector.multiAcks}', + ), + connector: connector, + ), + ], ), ), ], @@ -975,129 +991,171 @@ class _SettingsScreenState extends State { } } -void _privacySettings(BuildContext context, MeshCoreConnector connector) { - final l10n = context.l10n; - - int telemetryMode = connector.telemetryModeBase; - int telemetryLocMode = connector.telemetryModeLoc; - int telemetryEnvMode = connector.telemetryModeEnv; - bool advertLocPolicy = connector.advertLocationPolicy == 0 ? false : true; - int multiAcks = connector.multiAcks; - - final telemModeBase = [ - DropdownMenuItem(value: teleModeDeny, child: Text(l10n.settings_denyAll)), - DropdownMenuItem( - value: teleModeAllowFlags, - child: Text(l10n.settings_allowByContact), - ), - DropdownMenuItem( - value: teleModeAllowAll, - child: Text(l10n.settings_allowAll), - ), - ]; - - showDialog( - context: context, - builder: (dialogContext) => StatefulBuilder( - builder: (context, setDialogState) => AlertDialog( - title: Text(l10n.settings_privacy), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.settings_privacySettingsDescription), - const SizedBox(height: 16), - FeatureToggleRow( - title: l10n.settings_advertLocation, - subtitle: l10n.settings_advertLocationSubtitle, - value: advertLocPolicy, - onChanged: (value) { - setDialogState(() => advertLocPolicy = value); - }, - ), - const SizedBox(height: 8), - SwitchListTile( - title: Text(l10n.settings_multiAck), - value: multiAcks == 1, - onChanged: (value) { - setDialogState(() => multiAcks = value ? 1 : 0); - }, - contentPadding: EdgeInsets.zero, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: telemetryMode, - decoration: InputDecoration( - labelText: l10n.settings_telemetryBaseMode, - border: const OutlineInputBorder(), - ), - items: telemModeBase, - onChanged: (value) { - if (value != null) { - setDialogState(() => telemetryMode = value); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: telemetryLocMode, - decoration: InputDecoration( - labelText: l10n.settings_telemetryLocationMode, - border: const OutlineInputBorder(), - ), - items: telemModeBase, - onChanged: (value) { - if (value != null) { - setDialogState(() => telemetryLocMode = value); - } - }, - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: telemetryEnvMode, - decoration: InputDecoration( - labelText: l10n.settings_telemetryEnvironmentMode, - border: const OutlineInputBorder(), - ), - items: telemModeBase, - onChanged: (value) { - if (value != null) { - setDialogState(() => telemetryEnvMode = value); - } - }, - ), - ], - ), +/// Inline privacy controls for the Privacy pane. +/// +/// Advert-location, multi-ACKs, and the three telemetry-access modes all commit +/// through a single [MeshCoreConnector.setTelemetryModeBase] call, so each +/// control sends the full current state on change (coalesced) and reverts to the +/// device's values on failure. The host re-keys this widget on the connector's +/// current values, so a device-reported change rebuilds it with fresh values. +class _PrivacySection extends StatefulWidget { + final MeshCoreConnector connector; + + const _PrivacySection({super.key, required this.connector}); + + @override + State<_PrivacySection> createState() => _PrivacySectionState(); +} + +class _PrivacySectionState extends State<_PrivacySection> { + late int _telemetryMode; + late int _telemetryLocMode; + late int _telemetryEnvMode; + late bool _advertLocPolicy; + late int _multiAcks; + + bool _applying = false; + bool _applyAgain = false; + + @override + void initState() { + super.initState(); + _seedFromConnector(); + } + + void _seedFromConnector() { + final c = widget.connector; + _telemetryMode = c.telemetryModeBase; + _telemetryLocMode = c.telemetryModeLoc; + _telemetryEnvMode = c.telemetryModeEnv; + _advertLocPolicy = c.advertLocationPolicy != 0; + _multiAcks = c.multiAcks; + } + + Future _apply() async { + if (_applying) { + _applyAgain = true; + return; + } + _applying = true; + try { + do { + _applyAgain = false; + await widget.connector.setTelemetryModeBase( + _telemetryMode, + _telemetryLocMode, + _telemetryEnvMode, + _advertLocPolicy ? 1 : 0, + _multiAcks, + ); + await widget.connector.refreshDeviceInfo(); + } while (_applyAgain); + } catch (e) { + if (!mounted) return; + setState(_seedFromConnector); + showDismissibleSnackBar( + context, + content: Text(context.l10n.settings_error(e.toString())), + ); + } finally { + _applying = false; + } + } + + Widget _telemetryDropdown({ + required String label, + required int value, + required ValueChanged onChanged, + }) { + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: DropdownButtonFormField( + key: ValueKey('$label-$value'), + initialValue: value, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(l10n.common_cancel), + items: [ + DropdownMenuItem( + value: teleModeDeny, + child: Text(l10n.settings_denyAll), ), - TextButton( - onPressed: () async { - Navigator.pop(context); - await connector.setTelemetryModeBase( - telemetryMode, - telemetryLocMode, - telemetryEnvMode, - advertLocPolicy ? 1 : 0, - multiAcks, - ); - await connector.refreshDeviceInfo(); - if (!context.mounted) return; - showDismissibleSnackBar( - context, - content: Text(l10n.settings_telemetryModeUpdated), - ); - }, - child: Text(l10n.common_save), + DropdownMenuItem( + value: teleModeAllowFlags, + child: Text(l10n.settings_allowByContact), + ), + DropdownMenuItem( + value: teleModeAllowAll, + child: Text(l10n.settings_allowAll), ), ], + onChanged: (v) { + if (v == null) return; + onChanged(v); + }, ), - ), - ); + ); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: Text(l10n.settings_privacySettingsDescription), + ), + SwitchListTile( + secondary: const Icon(Icons.share_location_outlined), + title: Text(l10n.settings_advertLocation), + subtitle: Text(l10n.settings_advertLocationSubtitle), + value: _advertLocPolicy, + onChanged: (value) { + setState(() => _advertLocPolicy = value); + _apply(); + }, + ), + SwitchListTile( + secondary: const Icon(Icons.done_all), + title: Text(l10n.settings_multiAck), + value: _multiAcks == 1, + onChanged: (value) { + setState(() => _multiAcks = value ? 1 : 0); + _apply(); + }, + ), + _telemetryDropdown( + label: l10n.settings_telemetryBaseMode, + value: _telemetryMode, + onChanged: (v) { + setState(() => _telemetryMode = v); + _apply(); + }, + ), + _telemetryDropdown( + label: l10n.settings_telemetryLocationMode, + value: _telemetryLocMode, + onChanged: (v) { + setState(() => _telemetryLocMode = v); + _apply(); + }, + ), + _telemetryDropdown( + label: l10n.settings_telemetryEnvironmentMode, + value: _telemetryEnvMode, + onChanged: (v) { + setState(() => _telemetryEnvMode = v); + _apply(); + }, + ), + const SizedBox(height: 8), + ], + ); + } } /// Inline path-hash size selector for the connected device.