You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
meshcore-client/lib/screens/settings/observer_settings_view.dart

379 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../connector/meshcore_connector.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.
/// 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).
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;
bool _waitingForSync = false;
MeshCoreConnector? _syncWaitConn;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshWhenIdle());
}
/// Defer the initial read until the device's channel/contact sync settles —
/// observer config traffic must not compete with (and slow) that sync (#81).
void _refreshWhenIdle() {
final conn = context.read<MeshCoreConnector>();
if (!_syncing(conn)) {
_refresh();
return;
}
setState(() => _waitingForSync = true);
_syncWaitConn = conn;
conn.addListener(_onSyncProgress);
}
void _onSyncProgress() {
final conn = _syncWaitConn;
if (conn == null || _syncing(conn)) return;
conn.removeListener(_onSyncProgress);
_syncWaitConn = null;
if (!mounted) return;
setState(() => _waitingForSync = false);
_refresh();
}
bool _syncing(MeshCoreConnector c) =>
c.isLoadingContacts ||
c.isSyncingChannels ||
c.isShowingQueuedMessageSyncProgress;
@override
void dispose() {
_syncWaitConn?.removeListener(_onSyncProgress);
_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;
// Device may report any int; the SegmentedButton only offers {0, 180}, so
// normalize to a valid selection rather than seed a dangling value.
_rotation = c.display.rotation == 180 ? 180 : 0;
_seeded = true;
}
Future<void> _save() async {
final svc = context.read<ObserverConfigService>();
final c = svc.config;
setState(() => _saving = true);
var ok = true;
var invalidInterval = false;
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,
);
// Validate before sending: the firmware range is 10-3600. Don't put a
// predictably invalid value on the wire (#78 MINOR-B).
if (c == null || _statusInterval.text != '${c.mqtt.statusInterval}') {
final n = int.tryParse(_statusInterval.text);
if (n != null && n >= 10 && n <= 3600) {
ok = await svc.setFlat('mqtt.status_interval', '$n') && ok;
} else {
invalidInterval = true;
ok = false;
}
}
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();
// Flat save: don't re-pull the 84-frame broker pool, just the flats (#81).
await svc.refresh(includeBrokers: false);
_seedFromConfig(svc.config);
if (!mounted) return;
setState(() => _saving = false);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
invalidInterval
? 'Status interval must be 103600 seconds'
: 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 (_waitingForSync && !_seeded) {
return const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Waiting for the device sync to finish before reading Observer settings…',
textAlign: TextAlign.center,
),
),
);
}
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(
key: const Key('observer_ssid'),
controller: _ssid,
decoration: const InputDecoration(
labelText: 'SSID',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
key: const Key('observer_pwd'),
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(
key: const Key('observer_interval'),
controller: _statusInterval,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
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('')),
ButtonSegment(value: 180, label: Text('180°')),
],
selected: {_rotation},
onSelectionChanged: (s) => setState(() => _rotation = s.first),
),
],
),
),
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 []),
],
);
}
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: (_loading || _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' : ''}',
),
),
];
}
}

Powered by TurnKey Linux.