From 67db1085205212d5789f06b8ea7aa3f644e6d135 Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 22 Jun 2026 22:56:33 -0400 Subject: [PATCH] feat(#80): brokers screen + Observer-pane entry The broker pool gets its own screen, reached from a "MQTT brokers" row in the Observer pane (replacing the read-only inline list and its stale "pending firmware support" note). - MqttBrokersScreen: a tile per broker (tap -> editor, long-press -> Enable / Disable / Edit / Clear), and a + FAB that opens the next empty slot (disabled when all 10 are full) -- matching channels_screen's FAB-add pattern. Each action re-reads only the affected slot, never the full pool dump. - Observer pane: the broker section is now a navigational row showing "N configured - M enabled"; the editor is wired in end-to-end. 3/3 screen tests green; full suite green. Co-Authored-By: Claude Opus 4.8 --- lib/screens/settings/mqtt_brokers_screen.dart | 183 ++++++++++++++++++ .../settings/observer_settings_view.dart | 54 ++---- test/screens/mqtt_brokers_screen_test.dart | 100 ++++++++++ 3 files changed, 301 insertions(+), 36 deletions(-) create mode 100644 lib/screens/settings/mqtt_brokers_screen.dart create mode 100644 test/screens/mqtt_brokers_screen_test.dart diff --git a/lib/screens/settings/mqtt_brokers_screen.dart b/lib/screens/settings/mqtt_brokers_screen.dart new file mode 100644 index 0000000..8be2803 --- /dev/null +++ b/lib/screens/settings/mqtt_brokers_screen.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../connector/observer_config_client.dart'; +import '../../models/observer_config.dart'; +import '../../services/observer_config_service.dart'; +import 'broker_editor_screen.dart'; + +/// The MQTT broker pool as its own screen (#80), reached from the Observer +/// settings pane. Tap a broker to edit it; long-press for Enable / Disable / +/// Edit / Clear; the `+` FAB adds the next empty slot — matching the FAB-add +/// pattern of channels_screen. Single-slot re-GET keeps it off the 84-frame +/// pool dump after each action. +class MqttBrokersScreen extends StatefulWidget { + const MqttBrokersScreen({super.key}); + + @override + State createState() => _MqttBrokersScreenState(); +} + +class _MqttBrokersScreenState extends State { + List _brokers = const []; + bool _busy = false; + + @override + void initState() { + super.initState(); + _brokers = + context.read().config?.brokers ?? const []; + WidgetsBinding.instance.addPostFrameCallback((_) => _reload()); + } + + Future _reload() async { + final svc = context.read(); + setState(() => _busy = true); + final list = await svc.getBrokers(); + if (!mounted) return; + setState(() { + _busy = false; + if (list != null) _brokers = list; + }); + } + + int? get _nextEmptySlot { + final used = _brokers.map((b) => b.slot).toSet(); + for (var i = 0; i < ObserverConfigClient.brokerSlotCount; i++) { + if (!used.contains(i)) return i; + } + return null; + } + + Future _openEditor(BrokerConfig b) async { + final saved = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => BrokerEditorScreen(broker: b)), + ); + if (saved == true && mounted) await _reload(); + } + + Future _quickToggle(BrokerConfig b, bool enable) async { + final svc = context.read(); + await svc.setBrokerField(b.slot, 'enabled', enable ? '1' : '0'); + if (mounted) await _reload(); + } + + Future _clear(BrokerConfig b) async { + final svc = context.read(); + final ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('Clear broker ${b.slot}?'), + content: const Text( + 'This wipes the slot on the device. You can set it up again later.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(c, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(c, true), + child: const Text('Clear'), + ), + ], + ), + ); + if (ok != true || !mounted) return; + await svc.clearBroker(b.slot); + if (mounted) await _reload(); + } + + Future _menu(BrokerConfig b) async { + final action = await showModalBottomSheet( + context: context, + builder: (c) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (b.enabled) + ListTile( + leading: const Icon(Icons.cloud_off), + title: const Text('Disable'), + onTap: () => Navigator.pop(c, 'disable'), + ) + else + ListTile( + leading: const Icon(Icons.cloud_done), + title: const Text('Enable'), + onTap: () => Navigator.pop(c, 'enable'), + ), + ListTile( + leading: const Icon(Icons.edit), + title: const Text('Edit'), + onTap: () => Navigator.pop(c, 'edit'), + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Clear'), + onTap: () => Navigator.pop(c, 'clear'), + ), + ], + ), + ), + ); + if (!mounted) return; + switch (action) { + case 'enable': + await _quickToggle(b, true); + case 'disable': + await _quickToggle(b, false); + case 'edit': + await _openEditor(b); + case 'clear': + await _clear(b); + } + } + + @override + Widget build(BuildContext context) { + final slot = _nextEmptySlot; + return Scaffold( + appBar: AppBar( + centerTitle: true, + title: const Text('MQTT brokers'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + onPressed: _busy ? null : _reload, + ), + ], + ), + floatingActionButton: FloatingActionButton( + tooltip: slot == null ? 'All broker slots are full' : 'Add broker', + onPressed: slot == null + ? null + : () => _openEditor(BrokerConfig.empty(slot)), + child: const Icon(Icons.add), + ), + body: _brokers.isEmpty + ? const Center( + child: Text('No brokers configured — tap + to add one'), + ) + : ListView( + children: [ + for (final b in _brokers) + ListTile( + leading: Icon( + b.enabled ? Icons.cloud_done : Icons.cloud_off, + color: b.enabled ? Colors.green : null, + ), + title: Text('[${b.slot}] ${b.url}'), + subtitle: Text( + '${b.port} · ${b.transport.wire}' + '${b.enabled ? '' : ' · disabled'}', + ), + onTap: () => _openEditor(b), + onLongPress: () => _menu(b), + ), + ], + ), + ); + } +} diff --git a/lib/screens/settings/observer_settings_view.dart b/lib/screens/settings/observer_settings_view.dart index 8a62cfc..aa12baa 100644 --- a/lib/screens/settings/observer_settings_view.dart +++ b/lib/screens/settings/observer_settings_view.dart @@ -5,9 +5,10 @@ import 'package:provider/provider.dart'; import '../../connector/meshcore_connector.dart'; import '../../models/observer_config.dart'; import '../../services/observer_config_service.dart'; +import 'mqtt_brokers_screen.dart'; /// Observer settings pane: WiFi / MQTT (region) / display flat settings with -/// staged-save, plus a read-only view of the MQTT broker pool. +/// staged-save, plus a row into the MQTT broker editor (#80). /// /// Staged-save model: every control edits LOCAL state; nothing reaches the /// device until "Save", which sends only the changed keys (the firmware has no @@ -15,8 +16,8 @@ import '../../services/observer_config_service.dart'; /// the source of truth — Refresh re-reads it. /// /// TODO(l10n): strings are English-only pending ARB keys. -/// The broker pool is read-only HERE; the editor (tap -> edit, long-press -> -/// Enable/Disable/Edit/Clear) is tracked as epic #80 (firmware supports it). +/// The broker pool opens as its own screen (MqttBrokersScreen, #80) — tap to +/// edit, long-press for Enable/Disable/Edit/Clear, + FAB to add. class ObserverSettingsView extends StatefulWidget { const ObserverSettingsView({super.key}); @@ -278,14 +279,22 @@ class _ObserverSettingsViewState extends State { ), ), const Divider(height: 32), - _sectionTitle('MQTT brokers'), if (svc.brokersUnavailable) _banner( Icons.cloud_off, 'Broker pool unavailable — the device did not finish sending it.', ) else - ..._brokerTiles(svc.config?.brokers ?? const []), + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.dns_outlined), + title: const Text('MQTT brokers'), + subtitle: Text(_brokerSummary(svc.config?.brokers ?? const [])), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const MqttBrokersScreen()), + ), + ), ], ); } @@ -342,37 +351,10 @@ class _ObserverSettingsViewState extends State { return 'Status: ${w.status.name}$ip'; } - List _brokerTiles(List brokers) { + String _brokerSummary(List brokers) { final populated = brokers.where((b) => b.isPopulated).toList(); - if (populated.isEmpty) { - return [ - const ListTile( - contentPadding: EdgeInsets.zero, - title: Text('No brokers configured'), - ), - ]; - } - return [ - const Padding( - padding: EdgeInsets.only(bottom: 4), - child: Text( - 'Read-only — enable/disable/clear pending firmware support.', - style: TextStyle(fontStyle: FontStyle.italic), - ), - ), - for (final b in populated) - ListTile( - contentPadding: EdgeInsets.zero, - leading: Icon( - b.enabled ? Icons.cloud_done : Icons.cloud_off, - color: b.enabled ? Colors.green : null, - ), - title: Text('[${b.slot}] ${b.url}:${b.port}'), - subtitle: Text( - '${b.transport.wire} · ${b.authType.wire}' - '${b.passwordSet ? ' · pwd set' : ''}', - ), - ), - ]; + if (populated.isEmpty) return 'None configured — tap to add'; + final enabled = populated.where((b) => b.enabled).length; + return '${populated.length} configured · $enabled enabled'; } } diff --git a/test/screens/mqtt_brokers_screen_test.dart b/test/screens/mqtt_brokers_screen_test.dart new file mode 100644 index 0000000..8a637bf --- /dev/null +++ b/test/screens/mqtt_brokers_screen_test.dart @@ -0,0 +1,100 @@ +// Widget tests for the MQTT brokers screen (#80): a tile per broker, the + FAB +// opens the editor on the next empty slot, and long-press exposes the +// Enable/Disable/Edit/Clear actions (Clear behind a confirm). + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/models/observer_config.dart'; +import 'package:meshcore_open/screens/settings/mqtt_brokers_screen.dart'; +import 'package:meshcore_open/services/observer_config_service.dart'; + +class _DummyConn extends MeshCoreConnector {} + +class _FakeSvc extends ObserverConfigService { + _FakeSvc(this._brokers) : super(_DummyConn()); + + final List _brokers; + final List setCalls = []; + final List clearCalls = []; + + @override + ObserverConfig? get config => ObserverConfig(brokers: _brokers); + @override + Future?> getBrokers() async => _brokers; + @override + Future setBrokerField(int slot, String field, String value) async { + setCalls.add('$slot.$field=$value'); + return true; + } + + @override + Future clearBroker(int slot) async { + clearCalls.add(slot); + return true; + } +} + +Future _pump(WidgetTester tester, _FakeSvc fake) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: fake, + child: const MaterialApp(home: MqttBrokersScreen()), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('renders a tile per broker', (tester) async { + await _pump( + tester, + _FakeSvc(const [ + BrokerConfig(slot: 0, url: 'a.example', port: 1883, enabled: true), + BrokerConfig(slot: 1, url: 'b.example', port: 8883), + ]), + ); + + expect(find.text('[0] a.example'), findsOneWidget); + expect(find.text('[1] b.example'), findsOneWidget); + }); + + testWidgets('the + FAB opens the editor on the next empty slot', ( + tester, + ) async { + await _pump( + tester, + _FakeSvc(const [BrokerConfig(slot: 0, url: 'a', port: 1883)]), + ); + + await tester.tap(find.byType(FloatingActionButton)); + await tester.pumpAndSettle(); + + // Editor app bar for the next empty slot (1). + expect(find.text('Broker 1'), findsOneWidget); + }); + + testWidgets('long-press opens the action menu; Clear confirms then clears', ( + tester, + ) async { + final fake = _FakeSvc(const [ + BrokerConfig(slot: 2, url: 'a', port: 1883, enabled: true), + ]); + await _pump(tester, fake); + + await tester.longPress(find.text('[2] a')); + await tester.pumpAndSettle(); + expect(find.text('Disable'), findsOneWidget); // enabled slot -> Disable + expect(find.text('Edit'), findsOneWidget); + expect(find.text('Clear'), findsOneWidget); + + await tester.tap(find.text('Clear')); + await tester.pumpAndSettle(); + expect(find.text('Clear broker 2?'), findsOneWidget); + + await tester.tap(find.widgetWithText(TextButton, 'Clear')); + await tester.pumpAndSettle(); + expect(fake.clearCalls, contains(2)); + }); +}