126 lines
3.6 KiB
Dart
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')}';
|
|
}
|
|
} |