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
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue