feat(#64): observer settings UI + capability gate
- screens/settings/observer_settings_view.dart: staged-save pane for WiFi (ssid/pwd/enabled), MQTT region (iata/status_interval), display (always_on/rotation 0|180), with Save (changed keys only) + Refresh + stale/error banners; the broker pool renders read-only (enable/clear await the firmware F-task). - settings_screen.dart: register the Observer category, gated on ObserverConfigService.supported (re-evaluated as the connector's offband_caps flips on connect/disconnect). - connector/meshcore_connector.dart: parse the offband_caps byte (device-info byte 82, after path_hash_mode) + expose offbandCaps. - services/observer_config_service.dart: supported computed live from the connector (version gate + cap bit). - main.dart: provide ObserverConfigService. Strings are English-only pending l10n ARB keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>pull/99/head
parent
ce1c5a3cb6
commit
a31b8146d5
@ -0,0 +1,313 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../models/observer_config.dart';
|
||||||
|
import '../../services/observer_config_service.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 model: every control edits LOCAL state; nothing reaches the
|
||||||
|
/// device until "Save", which sends only the changed keys (the firmware has no
|
||||||
|
/// transaction, so each is an individual SET awaiting its ACK). Device NVS is
|
||||||
|
/// the source of truth — Refresh re-reads it.
|
||||||
|
///
|
||||||
|
/// TODO(l10n): strings are English-only pending ARB keys.
|
||||||
|
/// TODO(#64 / firmware F-task): broker enable/disable/clear + the broker editor
|
||||||
|
/// are blocked on the firmware wire path (no `enabled` SET; VIEW allowlist is
|
||||||
|
/// read-only), so the broker pool is read-only here for now.
|
||||||
|
class ObserverSettingsView extends StatefulWidget {
|
||||||
|
const ObserverSettingsView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ObserverSettingsView> createState() => _ObserverSettingsViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ObserverSettingsViewState extends State<ObserverSettingsView> {
|
||||||
|
final _ssid = TextEditingController();
|
||||||
|
final _pwd = TextEditingController();
|
||||||
|
final _iata = TextEditingController();
|
||||||
|
final _statusInterval = TextEditingController();
|
||||||
|
bool _wifiEnabled = true;
|
||||||
|
bool _displayAlwaysOn = false;
|
||||||
|
int _rotation = 0;
|
||||||
|
|
||||||
|
bool _loading = false;
|
||||||
|
bool _saving = false;
|
||||||
|
bool _seeded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _refresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ssid.dispose();
|
||||||
|
_pwd.dispose();
|
||||||
|
_iata.dispose();
|
||||||
|
_statusInterval.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
final svc = context.read<ObserverConfigService>();
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await svc.refresh();
|
||||||
|
_seedFromConfig(svc.config);
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _seedFromConfig(ObserverConfig? c) {
|
||||||
|
if (c == null) return;
|
||||||
|
_ssid.text = c.wifi.ssid;
|
||||||
|
_wifiEnabled = c.wifi.enabled;
|
||||||
|
_iata.text = c.mqtt.iata;
|
||||||
|
_statusInterval.text = '${c.mqtt.statusInterval}';
|
||||||
|
_displayAlwaysOn = c.display.alwaysOn;
|
||||||
|
_rotation = c.display.rotation;
|
||||||
|
_seeded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
final svc = context.read<ObserverConfigService>();
|
||||||
|
final c = svc.config;
|
||||||
|
setState(() => _saving = true);
|
||||||
|
var ok = true;
|
||||||
|
|
||||||
|
Future<void> setIfChanged(bool changed, String key, String value) async {
|
||||||
|
if (changed) ok = await svc.setFlat(key, value) && ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setIfChanged(
|
||||||
|
c == null || _ssid.text != c.wifi.ssid,
|
||||||
|
'wifi.ssid',
|
||||||
|
_ssid.text,
|
||||||
|
);
|
||||||
|
if (_pwd.text.isNotEmpty) {
|
||||||
|
ok = await svc.setFlat('wifi.pwd', _pwd.text) && ok;
|
||||||
|
}
|
||||||
|
await setIfChanged(
|
||||||
|
c == null || _wifiEnabled != c.wifi.enabled,
|
||||||
|
'wifi.enabled',
|
||||||
|
_wifiEnabled ? '1' : '0',
|
||||||
|
);
|
||||||
|
await setIfChanged(
|
||||||
|
c == null || _iata.text != c.mqtt.iata,
|
||||||
|
'mqtt.iata',
|
||||||
|
_iata.text,
|
||||||
|
);
|
||||||
|
await setIfChanged(
|
||||||
|
c == null || _statusInterval.text != '${c.mqtt.statusInterval}',
|
||||||
|
'mqtt.status_interval',
|
||||||
|
_statusInterval.text,
|
||||||
|
);
|
||||||
|
await setIfChanged(
|
||||||
|
c == null || _displayAlwaysOn != c.display.alwaysOn,
|
||||||
|
'display.always_on',
|
||||||
|
_displayAlwaysOn ? '1' : '0',
|
||||||
|
);
|
||||||
|
await setIfChanged(
|
||||||
|
c == null || _rotation != c.display.rotation,
|
||||||
|
'display.rotation',
|
||||||
|
'$_rotation',
|
||||||
|
);
|
||||||
|
|
||||||
|
_pwd.clear();
|
||||||
|
await svc.refresh();
|
||||||
|
_seedFromConfig(svc.config);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _saving = false);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
ok
|
||||||
|
? 'Observer settings saved'
|
||||||
|
: (svc.lastError ??
|
||||||
|
'Some changes failed — re-read from the device'),
|
||||||
|
),
|
||||||
|
backgroundColor: ok ? null : Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final svc = context.watch<ObserverConfigService>();
|
||||||
|
if (!_seeded && _loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
if (svc.stale)
|
||||||
|
_banner(
|
||||||
|
Icons.warning_amber,
|
||||||
|
'Showing the last read — the device did not answer the latest refresh.',
|
||||||
|
),
|
||||||
|
if (svc.lastError != null) _banner(Icons.error_outline, svc.lastError!),
|
||||||
|
_actionRow(),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_sectionTitle('WiFi'),
|
||||||
|
TextField(
|
||||||
|
controller: _ssid,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'SSID',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _pwd,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Password (leave blank to keep)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('WiFi enabled'),
|
||||||
|
subtitle: Text(_wifiStatusLine(svc.config?.wifi)),
|
||||||
|
value: _wifiEnabled,
|
||||||
|
onChanged: (v) => setState(() => _wifiEnabled = v),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
_sectionTitle('MQTT / Region'),
|
||||||
|
TextField(
|
||||||
|
controller: _iata,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Region tag (mqtt.iata)',
|
||||||
|
helperText: 'MQTT topic region, e.g. HAO',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(
|
||||||
|
controller: _statusInterval,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Status interval (sec, 10-3600)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
_sectionTitle('Display'),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Always on'),
|
||||||
|
subtitle: const Text('Keep the screen lit (USB/mains nodes)'),
|
||||||
|
value: _displayAlwaysOn,
|
||||||
|
onChanged: (v) => setState(() => _displayAlwaysOn = v),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Text('Rotation'),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
SegmentedButton<int>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(value: 0, label: Text('0°')),
|
||||||
|
ButtonSegment(value: 180, label: Text('180°')),
|
||||||
|
],
|
||||||
|
selected: {_rotation},
|
||||||
|
onSelectionChanged: (s) => setState(() => _rotation = s.first),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 32),
|
||||||
|
_sectionTitle('MQTT brokers'),
|
||||||
|
..._brokerTiles(svc.config?.brokers ?? const []),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _actionRow() {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: (_loading || _saving) ? null : _refresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('Refresh'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
icon: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save),
|
||||||
|
label: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _sectionTitle(String t) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(t, style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _banner(IconData icon, String msg) {
|
||||||
|
final color = Theme.of(context).colorScheme.error;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(msg, style: TextStyle(color: color)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _wifiStatusLine(WifiConfig? w) {
|
||||||
|
if (w == null) return '';
|
||||||
|
final ip = w.ip != null ? ' · ${w.ip}' : '';
|
||||||
|
return 'Status: ${w.status.name}$ip';
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _brokerTiles(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' : ''}',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in new issue