Files
TelemetryMonitor/lib/ui/log_list_view.dart
2026-04-21 14:40:09 -03:00

126 lines
3.6 KiB
Dart

import 'package:flutter/material.dart';
import '../proto/messages.pb.dart';
import '../session/session_controller.dart';
typedef LogFilter = bool Function(LogPacket entry);
/// Reusable log viewer with auto-scroll-to-newest and disengage-on-manual-scroll.
class LogListView extends StatefulWidget {
const LogListView({
super.key,
required this.session,
required this.filter,
});
final SessionController session;
final LogFilter filter;
@override
State<LogListView> createState() => _LogListViewState();
}
class _LogListViewState extends State<LogListView> {
final ScrollController _ctrl = ScrollController();
bool _autoScroll = true;
@override
void initState() {
super.initState();
_ctrl.addListener(_onScroll);
widget.session.logTick.addListener(_onNewLog);
}
@override
void dispose() {
widget.session.logTick.removeListener(_onNewLog);
_ctrl.dispose();
super.dispose();
}
void _onScroll() {
if (!_ctrl.hasClients) return;
final atBottom = _ctrl.offset >=
_ctrl.position.maxScrollExtent - 4;
if (_autoScroll != atBottom) {
setState(() => _autoScroll = atBottom);
}
}
void _onNewLog() {
if (!_autoScroll || !_ctrl.hasClients) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_ctrl.hasClients) return;
_ctrl.jumpTo(_ctrl.position.maxScrollExtent);
});
}
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: widget.session.logTick,
builder: (_, __, ___) {
final entries = widget.session.logs
.iterate()
.where(widget.filter)
.toList(growable: false);
return ListView.builder(
controller: _ctrl,
itemCount: entries.length,
itemBuilder: (_, i) => _LogRow(entry: entries[i]),
);
},
);
}
}
class _LogRow extends StatelessWidget {
const _LogRow({required this.entry});
final LogPacket entry;
@override
Widget build(BuildContext context) {
final ts = entry.hasTimestampUs()
? _fmtTime(entry.timestampUs.toInt())
: '';
final sev = entry.hasSeverity() ? entry.severity.name : '';
final err = entry.hasErrorNumber()
? '[${entry.errorNumber.toString().padLeft(4, '0')}]'
: '';
final desc = entry.hasDescription() ? entry.description : '';
final sevColor = switch (sev) {
'WARN' => Colors.amber.shade800,
'ERROR' || 'FATAL' => Colors.red.shade800,
_ => Colors.grey.shade700,
};
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 1),
child: DefaultTextStyle(
style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ts, style: const TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
SizedBox(
width: 50,
child: Text(sev, style: TextStyle(color: sevColor)),
),
const SizedBox(width: 6),
Text(err, style: const TextStyle(color: Colors.grey)),
const SizedBox(width: 8),
Expanded(child: Text(desc, overflow: TextOverflow.ellipsis)),
],
),
),
);
}
String _fmtTime(int us) {
final d = DateTime.fromMicrosecondsSinceEpoch(us);
return '${d.hour.toString().padLeft(2, '0')}:'
'${d.minute.toString().padLeft(2, '0')}:'
'${d.second.toString().padLeft(2, '0')}.'
'${d.millisecond.toString().padLeft(3, '0')}';
}
}