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 createState() => _LogListViewState(); } class _LogListViewState extends State { 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( 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')}'; } }