From bf47a26155007ef8d50e6aafacb66d6b6529ef28 Mon Sep 17 00:00:00 2001 From: diffwireauto <330573775+diffwireauto@users.noreply.github.com> Date: Sat, 24 Oct 2026 00:24:17 -0400 Subject: [PATCH] feat(#978): split Button and buzzer into Node Settings and Message Settings Button actions stay under Node Settings, shown only on caps byte 2 0x02. Buzzer and tones move to Message Settings as their own card under the phone-notifications card, shown only on 0x01. DeviceUiView now renders one part, requests only that part's state, and shows a refusal only on the screen it belongs to. Boards with a buzzer and no button exist (m5stack_unit_c6l, thinknode_m9, wio-tracker-l1-eink), which is why the homes are separate. Placement and entry gates only: the screen already showed each half from its own bit, and every wire write is unchanged. The Buzzer and tones subtitle says 'When this radio beeps' rather than the mockup's 'and which tone', because no tone picker exists until #971 and #970. Tests: all four button/buzzer combinations, and that each entry opens and requests only its own half. Full suite 1506 pass. Agent: MistyLynx (session 6c0d5797) Co-Authored-By: Claude Opus 5.5 --- lib/l10n/app_en.arb | 4 + lib/l10n/app_localizations.dart | 24 +++ lib/l10n/app_localizations_bg.dart | 12 ++ lib/l10n/app_localizations_de.dart | 12 ++ lib/l10n/app_localizations_en.dart | 12 ++ lib/l10n/app_localizations_es.dart | 12 ++ lib/l10n/app_localizations_fr.dart | 12 ++ lib/l10n/app_localizations_hu.dart | 12 ++ lib/l10n/app_localizations_it.dart | 12 ++ lib/l10n/app_localizations_ja.dart | 12 ++ lib/l10n/app_localizations_ko.dart | 12 ++ lib/l10n/app_localizations_nl.dart | 12 ++ lib/l10n/app_localizations_pl.dart | 12 ++ lib/l10n/app_localizations_pt.dart | 12 ++ lib/l10n/app_localizations_ru.dart | 12 ++ lib/l10n/app_localizations_sk.dart | 12 ++ lib/l10n/app_localizations_sl.dart | 12 ++ lib/l10n/app_localizations_sv.dart | 12 ++ lib/l10n/app_localizations_uk.dart | 12 ++ lib/l10n/app_localizations_zh.dart | 12 ++ lib/screens/settings/device_ui_view.dart | 40 ++++- .../settings/message_settings_view.dart | 45 ++++- lib/screens/settings_screen.dart | 28 ++- .../settings_device_ui_split_test.dart | 161 ++++++++++++++++++ untranslated.json | 68 ++++++++ 25 files changed, 562 insertions(+), 24 deletions(-) create mode 100644 test/screens/settings_device_ui_split_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index fed6e4ce..2a9fd297 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -324,6 +324,10 @@ "settings_gpsSleepFieldLabel": "Seconds between attempts", "settings_gpsSleepFieldHint": "0 for the firmware default. The radio raises anything under 300 to 300.", "settings_gpsSleepInvalid": "Enter a whole number of seconds, 0 or more.", + "settings_buttonActions": "Button actions", + "settings_buttonActionsSubtitle": "Assign what the button does", + "settings_buzzerTones": "Buzzer and tones", + "settings_buzzerTonesSubtitle": "When this radio beeps", "settings_contactSettings": "Contact Settings", "settings_contactSettingsSubtitle": "Settings for how contacts are added.", "settings_privacyMode": "Privacy Mode", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ea591ba1..119dee78 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1162,6 +1162,30 @@ abstract class AppLocalizations { /// **'Enter a whole number of seconds, 0 or more.'** String get settings_gpsSleepInvalid; + /// No description provided for @settings_buttonActions. + /// + /// In en, this message translates to: + /// **'Button actions'** + String get settings_buttonActions; + + /// No description provided for @settings_buttonActionsSubtitle. + /// + /// In en, this message translates to: + /// **'Assign what the button does'** + String get settings_buttonActionsSubtitle; + + /// No description provided for @settings_buzzerTones. + /// + /// In en, this message translates to: + /// **'Buzzer and tones'** + String get settings_buzzerTones; + + /// No description provided for @settings_buzzerTonesSubtitle. + /// + /// In en, this message translates to: + /// **'When this radio beeps'** + String get settings_buzzerTonesSubtitle; + /// No description provided for @settings_contactSettings. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 99c39637..8391a1e5 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -618,6 +618,18 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Настройки за контакти'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index c5aa48ff..3f8727bf 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -620,6 +620,18 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Kontakteinstellungen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 642081d8..fbe4226b 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -614,6 +614,18 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Contact Settings'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0f82d33b..9c08b90e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -618,6 +618,18 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Configuración de contacto'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 28722c79..466a85f0 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -621,6 +621,18 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Paramètres de contact'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 1a4454b4..a8c9d72e 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -620,6 +620,18 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Kapcsolat beállítások'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 84642200..776a766f 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -620,6 +620,18 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Impostazioni di contatto'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 4b8f354a..36e8383c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -603,6 +603,18 @@ class AppLocalizationsJa extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => '連絡設定'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 4ba06ec3..adec5408 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -603,6 +603,18 @@ class AppLocalizationsKo extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => '연락처 설정'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 355cc7cc..3624ba23 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -617,6 +617,18 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Contactinstellingen'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index c7a791aa..54678f8c 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -623,6 +623,18 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Ustawienia kontaktów'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index d7b2cd60..dfa50e00 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -620,6 +620,18 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Configurações de Contato'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1efe862d..2b5d4c9f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -619,6 +619,18 @@ class AppLocalizationsRu extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Настройки контактов'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 9d4ea4a3..41bf9d82 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -618,6 +618,18 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Nastavenia kontaktov'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 5e38d5f5..f6288d99 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -616,6 +616,18 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Nastavitve stika'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 86cc5b18..41e3dbb1 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -615,6 +615,18 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Kontaktinställningar'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 3a0b84b7..79efce02 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -617,6 +617,18 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => 'Налаштування контактів'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 4fd4e4b1..6c89bd3b 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -599,6 +599,18 @@ class AppLocalizationsZh extends AppLocalizations { String get settings_gpsSleepInvalid => 'Enter a whole number of seconds, 0 or more.'; + @override + String get settings_buttonActions => 'Button actions'; + + @override + String get settings_buttonActionsSubtitle => 'Assign what the button does'; + + @override + String get settings_buzzerTones => 'Buzzer and tones'; + + @override + String get settings_buzzerTonesSubtitle => 'When this radio beeps'; + @override String get settings_contactSettings => '联系人设置'; diff --git a/lib/screens/settings/device_ui_view.dart b/lib/screens/settings/device_ui_view.dart index 95f56190..e9a6f40c 100644 --- a/lib/screens/settings/device_ui_view.dart +++ b/lib/screens/settings/device_ui_view.dart @@ -13,13 +13,22 @@ import '../../connector/offband_device_ui.dart'; /// /// English-only for now, matching the serial-capture pane (#430); localization /// is a follow-up rather than a blocker on shipping the capability. +/// +/// Shows one [part] at a time (#978): button actions open from Node Settings, +/// the buzzer from Message Settings. They are separate features with separate +/// bits, and boards with a buzzer and no button exist. class DeviceUiView extends StatefulWidget { - const DeviceUiView({super.key}); + const DeviceUiView({super.key, required this.part}); + + final DeviceUiPart part; @override State createState() => _DeviceUiViewState(); } +/// Which half of the device UI a [DeviceUiView] shows. +enum DeviceUiPart { buttons, buzzer } + class _DeviceUiViewState extends State { @override void initState() { @@ -29,17 +38,34 @@ class _DeviceUiViewState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final c = context.read(); - c.requestButtonMatrix(); - c.requestNotifyScope(); + switch (widget.part) { + case DeviceUiPart.buttons: + c.requestButtonMatrix(); + case DeviceUiPart.buzzer: + c.requestNotifyScope(); + } }); } + /// An error is shown on the screen it belongs to. One that could not be + /// attributed to a write is shown on both halves rather than hidden. + bool _ownsError(DeviceUiSurface? surface) => switch (surface) { + null => true, + DeviceUiSurface.buttonMatrix => widget.part == DeviceUiPart.buttons, + DeviceUiSurface.notifyScope || + DeviceUiSurface.channelTune => widget.part == DeviceUiPart.buzzer, + DeviceUiSurface.gpsMode => false, + }; + @override Widget build(BuildContext context) { return Consumer( builder: (context, connector, _) { - final showButtons = connector.supportsButtonMatrix; - final showScope = connector.supportsNotifyScope; + final showButtons = + widget.part == DeviceUiPart.buttons && + connector.supportsButtonMatrix; + final showScope = + widget.part == DeviceUiPart.buzzer && connector.supportsNotifyScope; // A radio advertising neither bit has no button and no buzzer to // configure, so it gets nothing. The tile that leads here is gated the // same way, so this is a belt-and-braces guard rather than a path a @@ -82,7 +108,8 @@ class _DeviceUiViewState extends State { } return ListView( children: [ - if (connector.deviceUiError != null) + if (connector.deviceUiError != null && + _ownsError(connector.deviceUiErrorSurface)) _ErrorBanner( message: connector.deviceUiError!, onDismiss: connector.clearDeviceUiError, @@ -98,7 +125,6 @@ class _DeviceUiViewState extends State { ), ), ..._scopeTiles(connector), - const Divider(height: 24), ], if (showButtons) ...[ const _SectionHeader('Button actions'), diff --git a/lib/screens/settings/message_settings_view.dart b/lib/screens/settings/message_settings_view.dart index 151cc602..a81f615f 100644 --- a/lib/screens/settings/message_settings_view.dart +++ b/lib/screens/settings/message_settings_view.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../../connector/meshcore_connector.dart'; import '../../l10n/l10n.dart'; import '../../services/app_settings_service.dart'; import '../../services/notification_service.dart'; import '../../helpers/settings_persist.dart'; import '../../helpers/snack_bar_builder.dart'; +import 'device_ui_view.dart'; /// Embeddable view (no Scaffold) for the Message Settings shell pane. /// -/// Renders notification toggles and message-handling settings. Both cards read -/// and write only [AppSettingsService]. +/// Renders notification toggles and message-handling settings, which read and +/// write only [AppSettingsService]. Between them, on a radio with a buzzer, +/// sits the entry to the radio's own buzzer settings (#978). class MessageSettingsView extends StatelessWidget { const MessageSettingsView({super.key}); @@ -23,6 +26,7 @@ class MessageSettingsView extends StatelessWidget { children: [ _buildNotificationsCard(context, settingsService), const SizedBox(height: 16), + const _RadioBuzzerEntry(), _buildMessagingCard(context, settingsService), ], ); @@ -404,3 +408,40 @@ class MessageSettingsView extends StatelessWidget { ); } } + +/// Entry to the radio's buzzer settings (#978), shown only where the radio +/// advertises a buzzer (caps byte 2 `0x01`). Separate from the card above, +/// which governs whether this PHONE notifies; this governs the radio. +class _RadioBuzzerEntry extends StatelessWidget { + const _RadioBuzzerEntry(); + + @override + Widget build(BuildContext context) { + final connector = context.watch(); + if (!connector.supportsNotifyScope) return const SizedBox.shrink(); + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Card( + child: ListTile( + leading: const Icon(Icons.volume_up_outlined), + title: Text(l10n.settings_buzzerTones), + subtitle: Text(l10n.settings_buzzerTonesSubtitle), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => Scaffold( + appBar: AppBar( + title: Text(l10n.settings_buzzerTones), + centerTitle: true, + ), + body: const DeviceUiView(part: DeviceUiPart.buzzer), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7628f7da..302d2304 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -430,23 +430,21 @@ class _SettingsScreenState extends State { onTap: () => _editNodeName(context, connector), ), // Location and the GPS toggle moved to their own category (#977). - // Button and buzzer: device UI config (#474/#475). Owner-placed - // under Node Settings, directly above the public key. + // Button actions (#474), directly above the public key. The + // buzzer moved to Message Settings (#978): boards with a buzzer + // and no button exist, so the two are separate entries on + // separate bits. // - // Gated STRICTLY on the capability bits. A radio without the - // hardware has no button to configure, so it gets no tile, no - // screen and no command. This is the negative test in #474: - // "A device that does not advertise the bit shows no screen and - // the client emits no command." - if (connector.supportsButtonMatrix || - connector.supportsNotifyScope) ...[ + // Gated STRICTLY on the capability bit. A radio without a button + // gets no tile, no screen and no command. This is the negative + // test in #474: "A device that does not advertise the bit shows + // no screen and the client emits no command." + if (connector.supportsButtonMatrix) ...[ const Divider(height: 1), ListTile( leading: const Icon(Icons.radio_button_checked_outlined), - title: const Text('Button and buzzer'), - subtitle: const Text( - 'Assign button actions and set when this radio beeps', - ), + title: Text(l10n.settings_buttonActions), + subtitle: Text(l10n.settings_buttonActionsSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { Navigator.push( @@ -454,10 +452,10 @@ class _SettingsScreenState extends State { MaterialPageRoute( builder: (context) => Scaffold( appBar: AppBar( - title: const Text('Button and buzzer'), + title: Text(l10n.settings_buttonActions), centerTitle: true, ), - body: const DeviceUiView(), + body: const DeviceUiView(part: DeviceUiPart.buttons), ), ), ); diff --git a/test/screens/settings_device_ui_split_test.dart b/test/screens/settings_device_ui_split_test.dart new file mode 100644 index 00000000..4b6127a8 --- /dev/null +++ b/test/screens/settings_device_ui_split_test.dart @@ -0,0 +1,161 @@ +// Pins #978: button actions live under Node Settings on caps byte 2 0x02, the +// buzzer under Message Settings on 0x01, and each opens only its own half. +// All four combinations are covered, because boards with a buzzer and no +// button exist (m5stack_unit_c6l, thinknode_m9, wio-tracker-l1-eink). + +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/offband_device_ui.dart'; +import 'package:meshcore_open/l10n/app_localizations.dart'; +import 'package:meshcore_open/screens/settings_screen.dart'; +import 'package:meshcore_open/services/app_settings_service.dart'; +import 'package:meshcore_open/storage/drift/blob_store.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _Radio extends MeshCoreConnector { + _Radio({required this.button, required this.buzzer}); + final bool button; + final bool buzzer; + final List requests = []; + + @override + bool get supportsButtonMatrix => button; + + @override + bool get supportsNotifyScope => buzzer; + + // Values already read, so no screen sits on an animating spinner. + @override + ButtonMatrix? get buttonMatrix => + const ButtonMatrix(assignments: {}, supportedActions: 0); + + @override + DeviceNotifyScope? get deviceNotifyScope => DeviceNotifyScope.all; + + @override + Future requestButtonMatrix() async => requests.add('matrix'); + + @override + Future requestNotifyScope() async => requests.add('scope'); +} + +void main() { + late OffbandDatabase db; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + }); + + tearDown(() async { + BlobStore.clearTestOverride(); + await db.close(); + }); + + Future pumpSettings(WidgetTester tester, _Radio radio) async { + radio.sendFrameOverrideForTest = (_) {}; + tester.view.physicalSize = const Size(400, 1000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider( + create: (_) => AppSettingsService(), + ), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: SettingsScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + Future tapText(WidgetTester tester, String text) async { + await tester.scrollUntilVisible(find.text(text), 200); + await tester.tap(find.text(text)); + await tester.pumpAndSettle(); + } + + Future back(WidgetTester tester) async { + await tester.pageBack(); + await tester.pumpAndSettle(); + } + + /// What Node Settings and Message Settings each offer for this radio. + Future<(bool, bool)> entries(WidgetTester tester, _Radio radio) async { + await pumpSettings(tester, radio); + await tapText(tester, 'Node Settings'); + final hasButton = find.text('Button actions').evaluate().isNotEmpty; + await back(tester); + await tapText(tester, 'Message Settings'); + final hasBuzzer = find.text('Buzzer and tones').evaluate().isNotEmpty; + return (hasButton, hasBuzzer); + } + + testWidgets('button only: an entry in Node Settings, none for a buzzer', ( + tester, + ) async { + final r = await entries(tester, _Radio(button: true, buzzer: false)); + expect(r, equals((true, false))); + }); + + testWidgets('buzzer only: an entry in Message Settings, none for a button', ( + tester, + ) async { + final r = await entries(tester, _Radio(button: false, buzzer: true)); + expect(r, equals((false, true))); + }); + + testWidgets('both: each in its own place', (tester) async { + final r = await entries(tester, _Radio(button: true, buzzer: true)); + expect(r, equals((true, true))); + }); + + testWidgets('neither: no entry anywhere', (tester) async { + final r = await entries(tester, _Radio(button: false, buzzer: false)); + expect(r, equals((false, false))); + }); + + testWidgets('the combined Button and buzzer entry is gone', (tester) async { + await pumpSettings(tester, _Radio(button: true, buzzer: true)); + await tapText(tester, 'Node Settings'); + expect(find.text('Button and buzzer'), findsNothing); + }); + + testWidgets('Buzzer and tones opens only the buzzer half', (tester) async { + final radio = _Radio(button: true, buzzer: true); + await pumpSettings(tester, radio); + await tapText(tester, 'Message Settings'); + await tapText(tester, 'Buzzer and tones'); + + expect(find.text('Device notification scope'), findsOneWidget); + // A button row: present whenever the button half renders. + expect(find.text('Single press'), findsNothing); + expect(radio.requests, equals(['scope'])); + }); + + testWidgets('Button actions opens only the button half', (tester) async { + final radio = _Radio(button: true, buzzer: true); + await pumpSettings(tester, radio); + await tapText(tester, 'Node Settings'); + await tapText(tester, 'Button actions'); + + expect(find.text('Single press'), findsOneWidget); + expect(find.text('Device notification scope'), findsNothing); + expect(radio.requests, equals(['matrix'])); + }); +} diff --git a/untranslated.json b/untranslated.json index 29b98bf3..6a12ec1f 100644 --- a/untranslated.json +++ b/untranslated.json @@ -59,6 +59,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -369,6 +373,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -679,6 +687,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -989,6 +1001,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -1299,6 +1315,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -1609,6 +1629,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -1919,6 +1943,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -2229,6 +2257,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -2539,6 +2571,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -2849,6 +2885,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -3159,6 +3199,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -3469,6 +3513,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -3779,6 +3827,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -4089,6 +4141,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -4399,6 +4455,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -4709,6 +4769,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription", @@ -5019,6 +5083,10 @@ "settings_gpsSleepFieldLabel", "settings_gpsSleepFieldHint", "settings_gpsSleepInvalid", + "settings_buttonActions", + "settings_buttonActionsSubtitle", + "settings_buzzerTones", + "settings_buzzerTonesSubtitle", "settings_experimental", "settings_experimentalSubtitle", "settings_experimentalDescription",