291 lines
11 KiB
Dart
291 lines
11 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../../config/settings.dart';
|
||
import '../../transport/connection_state.dart';
|
||
import '../app_scope.dart';
|
||
|
||
class SettingsDialog extends StatefulWidget {
|
||
const SettingsDialog({super.key});
|
||
|
||
@override
|
||
State<SettingsDialog> createState() => _SettingsDialogState();
|
||
}
|
||
|
||
class _SettingsDialogState extends State<SettingsDialog> {
|
||
late Settings _draft;
|
||
late TextEditingController _wsCtrl;
|
||
late TextEditingController _pktCtrl;
|
||
late TextEditingController _logCtrl;
|
||
late TextEditingController _batchCtrl;
|
||
late TextEditingController _initCtrl;
|
||
late TextEditingController _maxCtrl;
|
||
late TextEditingController _backoffCtrl;
|
||
late TextEditingController _lookbackCtrl;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final live = context.findAncestorWidgetOfExactType<AppScope>()!.settings;
|
||
_draft = live.clone();
|
||
_wsCtrl = TextEditingController(text: _draft.wsUrl);
|
||
_pktCtrl = TextEditingController(text: _draft.packetBufferCapacity.toString());
|
||
_logCtrl = TextEditingController(text: _draft.logBufferCapacity.toString());
|
||
_batchCtrl = TextEditingController(
|
||
text: _draft.decoderBatchInterval.inMilliseconds.toString());
|
||
_initCtrl = TextEditingController(
|
||
text: _draft.reconnectInitialDelay.inMilliseconds.toString());
|
||
_maxCtrl = TextEditingController(
|
||
text: _draft.reconnectMaxDelay.inMilliseconds.toString());
|
||
_backoffCtrl =
|
||
TextEditingController(text: _draft.reconnectBackoffFactor.toString());
|
||
_lookbackCtrl =
|
||
TextEditingController(text: _draft.statusLookback.toString());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
for (final c in [
|
||
_wsCtrl, _pktCtrl, _logCtrl, _batchCtrl,
|
||
_initCtrl, _maxCtrl, _backoffCtrl, _lookbackCtrl,
|
||
]) {
|
||
c.dispose();
|
||
}
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final scope = AppScope.of(context);
|
||
return AlertDialog(
|
||
title: const Text('Settings'),
|
||
content: SizedBox(
|
||
width: 480,
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_section('Connection'),
|
||
TextField(
|
||
controller: _wsCtrl,
|
||
decoration: const InputDecoration(labelText: 'WebSocket URL'),
|
||
style: const TextStyle(fontFamily: 'monospace'),
|
||
),
|
||
const SizedBox(height: 8),
|
||
ValueListenableBuilder<WsConnectionState>(
|
||
valueListenable: scope.session.connectionState,
|
||
builder: (_, state, __) {
|
||
final dotColor = switch (state) {
|
||
WsConnectionState.connected => Colors.green,
|
||
WsConnectionState.connecting ||
|
||
WsConnectionState.reconnecting => Colors.orange,
|
||
WsConnectionState.disconnected => Colors.grey,
|
||
};
|
||
final label = switch (state) {
|
||
WsConnectionState.connected => 'Connected to',
|
||
WsConnectionState.connecting => 'Connecting to',
|
||
WsConnectionState.reconnecting => 'Reconnecting to',
|
||
WsConnectionState.disconnected => 'Disconnected',
|
||
};
|
||
final url = scope.transport_url();
|
||
final urlChanged = _wsCtrl.text != url;
|
||
return Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: Theme.of(context)
|
||
.colorScheme
|
||
.surfaceContainerHighest,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Row(children: [
|
||
Container(
|
||
width: 8, height: 8,
|
||
decoration: BoxDecoration(
|
||
color: dotColor, shape: BoxShape.circle),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text('$label ', style: const TextStyle(fontSize: 11)),
|
||
Text(url,
|
||
style: const TextStyle(
|
||
fontFamily: 'monospace', fontSize: 11)),
|
||
const Spacer(),
|
||
if (urlChanged)
|
||
const Text('URL changed — will reconnect on Apply',
|
||
style: TextStyle(
|
||
fontSize: 10,
|
||
fontStyle: FontStyle.italic,
|
||
color: Colors.grey)),
|
||
]),
|
||
);
|
||
},
|
||
),
|
||
_section('Buffers'),
|
||
_numberField('Packet buffer', _pktCtrl, hint: '≈ 10 min @ 1 kHz'),
|
||
_numberField('Log buffer', _logCtrl, hint: 'entries'),
|
||
_section('Decoder'),
|
||
_numberField('Batch interval', _batchCtrl, hint: 'ms (native)'),
|
||
if (kIsWeb)
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 4),
|
||
child: Text(
|
||
'Web target decodes inline; this setting is ignored.',
|
||
style: TextStyle(fontSize: 11, color: Colors.grey),
|
||
),
|
||
),
|
||
_section('Reconnect'),
|
||
_numberField('Initial delay', _initCtrl, hint: 'ms'),
|
||
_numberField('Max delay', _maxCtrl, hint: 'ms'),
|
||
_numberField('Backoff factor', _backoffCtrl, hint: '×'),
|
||
_section('Status indicators'),
|
||
_numberField('Status lookback', _lookbackCtrl,
|
||
hint: '≈ 1 s @ 1 kHz'),
|
||
_section('Dashboard mini log'),
|
||
..._severityCheckboxes(),
|
||
_section('Appearance'),
|
||
SwitchListTile(
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
title: const Text('Dark theme'),
|
||
value: _draft.darkMode,
|
||
onChanged: (v) => setState(() => _draft.darkMode = v),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () {
|
||
_draft.restoreDefaults(isWeb: kIsWeb);
|
||
setState(() {
|
||
_wsCtrl.text = _draft.wsUrl;
|
||
_pktCtrl.text = _draft.packetBufferCapacity.toString();
|
||
_logCtrl.text = _draft.logBufferCapacity.toString();
|
||
_batchCtrl.text =
|
||
_draft.decoderBatchInterval.inMilliseconds.toString();
|
||
_initCtrl.text =
|
||
_draft.reconnectInitialDelay.inMilliseconds.toString();
|
||
_maxCtrl.text =
|
||
_draft.reconnectMaxDelay.inMilliseconds.toString();
|
||
_backoffCtrl.text = _draft.reconnectBackoffFactor.toString();
|
||
_lookbackCtrl.text = _draft.statusLookback.toString();
|
||
});
|
||
},
|
||
child: const Text('Restore defaults'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => _apply(context),
|
||
child: const Text('Apply'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _apply(BuildContext context) async {
|
||
_draft.wsUrl = _wsCtrl.text;
|
||
_draft.packetBufferCapacity = int.tryParse(_pktCtrl.text) ??
|
||
_draft.packetBufferCapacity;
|
||
_draft.logBufferCapacity =
|
||
int.tryParse(_logCtrl.text) ?? _draft.logBufferCapacity;
|
||
_draft.decoderBatchInterval = Duration(
|
||
milliseconds: int.tryParse(_batchCtrl.text) ??
|
||
_draft.decoderBatchInterval.inMilliseconds,
|
||
);
|
||
_draft.reconnectInitialDelay = Duration(
|
||
milliseconds: int.tryParse(_initCtrl.text) ??
|
||
_draft.reconnectInitialDelay.inMilliseconds,
|
||
);
|
||
_draft.reconnectMaxDelay = Duration(
|
||
milliseconds: int.tryParse(_maxCtrl.text) ??
|
||
_draft.reconnectMaxDelay.inMilliseconds,
|
||
);
|
||
_draft.reconnectBackoffFactor =
|
||
double.tryParse(_backoffCtrl.text) ?? _draft.reconnectBackoffFactor;
|
||
_draft.statusLookback =
|
||
int.tryParse(_lookbackCtrl.text) ?? _draft.statusLookback;
|
||
// darkMode is edited directly on _draft via the switch.
|
||
|
||
final scope = AppScope.of(context);
|
||
final urlChanged = scope.settings.wsUrl != _draft.wsUrl;
|
||
scope.settings.copyFrom(_draft);
|
||
await scope.settings.save();
|
||
// Resize buffers if capacity changed.
|
||
if (scope.session.packets.capacity != _draft.packetBufferCapacity) {
|
||
scope.session.packets.resize(_draft.packetBufferCapacity);
|
||
}
|
||
if (scope.session.logs.capacity != _draft.logBufferCapacity) {
|
||
scope.session.logs.resize(_draft.logBufferCapacity);
|
||
}
|
||
if (urlChanged) {
|
||
await scope.session.reconnect();
|
||
}
|
||
if (context.mounted) Navigator.of(context).pop();
|
||
}
|
||
|
||
Widget _section(String label) => Padding(
|
||
padding: const EdgeInsets.only(top: 16, bottom: 6),
|
||
child: Text(label,
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w500, color: Colors.grey)),
|
||
);
|
||
|
||
Widget _numberField(String label, TextEditingController c,
|
||
{String? hint}) {
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 6),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(width: 130, child: Text(label)),
|
||
Expanded(
|
||
child: TextField(
|
||
controller: c,
|
||
keyboardType: TextInputType.number,
|
||
style: const TextStyle(fontFamily: 'monospace'),
|
||
),
|
||
),
|
||
if (hint != null) ...[
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
width: 100,
|
||
child:
|
||
Text(hint, style: const TextStyle(color: Colors.grey)),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _severityCheckboxes() {
|
||
const labels = {
|
||
1: 'DEBUG', 2: 'INFO', 3: 'WARN', 4: 'ERROR', 5: 'FATAL',
|
||
};
|
||
return labels.entries.map((e) {
|
||
return CheckboxListTile(
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
title: Text(e.value, style: const TextStyle(fontFamily: 'monospace')),
|
||
value: _draft.miniLogSeverities.contains(e.key),
|
||
onChanged: (v) => setState(() {
|
||
if (v == true) {
|
||
_draft.miniLogSeverities.add(e.key);
|
||
} else {
|
||
_draft.miniLogSeverities.remove(e.key);
|
||
}
|
||
}),
|
||
);
|
||
}).toList();
|
||
}
|
||
}
|
||
|
||
extension _ScopeTransport on AppScope {
|
||
// Helper: get the transport's actual current URL. Falls back to the
|
||
// settings value if transport is between connections.
|
||
String transport_url() => session.transport.currentUrl ?? settings.wsUrl;
|
||
} |