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 <noreply@anthropic.com>
feat/64-observer-config
Strycher 4 weeks ago
parent 183e9eb7dd
commit bc8b285ed4

@ -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<MqttBrokersScreen> createState() => _MqttBrokersScreenState();
}
class _MqttBrokersScreenState extends State<MqttBrokersScreen> {
List<BrokerConfig> _brokers = const [];
bool _busy = false;
@override
void initState() {
super.initState();
_brokers =
context.read<ObserverConfigService>().config?.brokers ?? const [];
WidgetsBinding.instance.addPostFrameCallback((_) => _reload());
}
Future<void> _reload() async {
final svc = context.read<ObserverConfigService>();
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<void> _openEditor(BrokerConfig b) async {
final saved = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => BrokerEditorScreen(broker: b)),
);
if (saved == true && mounted) await _reload();
}
Future<void> _quickToggle(BrokerConfig b, bool enable) async {
final svc = context.read<ObserverConfigService>();
await svc.setBrokerField(b.slot, 'enabled', enable ? '1' : '0');
if (mounted) await _reload();
}
Future<void> _clear(BrokerConfig b) async {
final svc = context.read<ObserverConfigService>();
final ok = await showDialog<bool>(
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<void> _menu(BrokerConfig b) async {
final action = await showModalBottomSheet<String>(
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),
),
],
),
);
}
}

@ -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<ObserverSettingsView> {
),
),
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<ObserverSettingsView> {
return 'Status: ${w.status.name}$ip';
}
List<Widget> _brokerTiles(List<BrokerConfig> brokers) {
String _brokerSummary(List<BrokerConfig> 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';
}
}

@ -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<BrokerConfig> _brokers;
final List<String> setCalls = [];
final List<int> clearCalls = [];
@override
ObserverConfig? get config => ObserverConfig(brokers: _brokers);
@override
Future<List<BrokerConfig>?> getBrokers() async => _brokers;
@override
Future<bool> setBrokerField(int slot, String field, String value) async {
setCalls.add('$slot.$field=$value');
return true;
}
@override
Future<bool> clearBroker(int slot) async {
clearCalls.add(slot);
return true;
}
}
Future<void> _pump(WidgetTester tester, _FakeSvc fake) async {
await tester.pumpWidget(
ChangeNotifierProvider<ObserverConfigService>.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));
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.