import 'package:flutter/material.dart'; import '../about/internal/application/TextType.dart'; import '../comms/Comms.dart' show LogLine, LogLevel; import '../utils/Colors.dart'; import 'text/Text.dart'; /// Reusable terminal-style log console. It stays dark in both light and dark /// app themes (like a code block), with colored, prefixed lines and auto-scroll. class LogConsole extends StatefulWidget { final List logs; final double? height; final bool live; final VoidCallback? onClear; final VoidCallback? onCopy; /// When the caller is showing a filtered subset, the size of the unfiltered /// backlog — rendered as "shown / total" in the header. final int? totalCount; /// Replaces the "Output will appear here." placeholder. final String? emptyMessage; const LogConsole({ super.key, required this.logs, this.height = 300, this.live = false, this.onClear, this.onCopy, this.totalCount, this.emptyMessage, }); @override State createState() => _LogConsoleState(); } class _LogConsoleState extends State { final _scroll = ScrollController(); int _last = 0; @override void didUpdateWidget(LogConsole old) { super.didUpdateWidget(old); if (widget.logs.length != _last) { _last = widget.logs.length; WidgetsBinding.instance.addPostFrameCallback((_) { if (_scroll.hasClients) { _scroll.animateTo( _scroll.position.maxScrollExtent, duration: const Duration(milliseconds: 120), curve: Curves.easeOut, ); } }); } } @override void dispose() { _scroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // A null height means the caller has already constrained us (e.g. inside // an Expanded), so we fill whatever we're given. return Container( height: widget.height, decoration: BoxDecoration( color: context.jp.consoleBg, borderRadius: BorderRadius.circular(14), border: Border.all(color: ConsoleColors.frame), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: ConsoleColors.frame)), ), child: Row( children: [ const Icon(Icons.terminal, size: 16, color: PeachTheme.seed), const SizedBox(width: 8), text( 'LOGS', 12, TextType.Regular, color: ConsoleColors.header, letterSpacing: 1.5, weight: FontWeight.w700, ), if (widget.live) ...[ const SizedBox(width: 10), const _LivePill(), ], const SizedBox(width: 10), text( widget.totalCount == null ? '${widget.logs.length}' : '${widget.logs.length} / ${widget.totalCount}', 11, TextType.Regular, color: ConsoleColors.muted, weight: FontWeight.w400, ), const Spacer(), if (widget.onCopy != null) IconButton( tooltip: 'Copy', iconSize: 16, color: ConsoleColors.header, onPressed: widget.logs.isEmpty ? null : widget.onCopy, icon: const Icon(Icons.copy_all), ), if (widget.onClear != null) _ClearButton( enabled: widget.logs.isNotEmpty, onTap: widget.onClear!, ), ], ), ), Expanded( child: widget.logs.isEmpty ? Center( child: text( widget.emptyMessage ?? 'Output will appear here.', 14, TextType.Regular, color: ConsoleColors.disabled, weight: FontWeight.w400, ), ) : ListView.builder( controller: _scroll, padding: const EdgeInsets.all(12), itemCount: widget.logs.length, itemBuilder: (_, i) => _row(widget.logs[i]), ), ), ], ), ); } Widget _row(LogLine l) { final (color, prefix) = switch (l.level) { LogLevel.error => (ConsoleColors.error, '✖ '), LogLevel.warn => (ConsoleColors.warn, '! '), LogLevel.success => (ConsoleColors.success, '✓ '), LogLevel.step => (ConsoleColors.step, '➤ '), LogLevel.info => (ConsoleColors.info, '· '), LogLevel.remote => (ConsoleColors.body, ' '), }; return Padding( padding: const EdgeInsets.symmetric(vertical: 1), child: SelectableText( '$prefix${l.text}', style: TextStyle( fontFamily: 'monospace', fontSize: 12.5, height: 1.35, color: color, ), ), ); } } /// Labeled "Clear" control: empties the on-screen backlog. New lines keep /// streaming in afterwards. class _ClearButton extends StatelessWidget { final bool enabled; final VoidCallback onTap; const _ClearButton({required this.enabled, required this.onTap}); @override Widget build(BuildContext context) { return Tooltip( message: 'Clear the log backlog — new lines keep streaming', child: InkWell( borderRadius: BorderRadius.circular(8), onTap: enabled ? onTap : null, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), border: Border.all(color: ConsoleColors.frameRaised), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.delete_sweep, size: 14, color: enabled ? ConsoleColors.header : ConsoleColors.disabled, ), const SizedBox(width: 5), text( 'Clear', 11.5, TextType.Regular, color: enabled ? ConsoleColors.header : ConsoleColors.disabled, weight: FontWeight.w700, ), ], ), ), ), ); } } class _LivePill extends StatelessWidget { const _LivePill(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: ConsoleColors.live.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.fiber_manual_record, size: 9, color: ConsoleColors.live, ), const SizedBox(width: 4), text( 'LIVE', 10, TextType.Regular, color: ConsoleColors.live, weight: FontWeight.w700, ), ], ), ); } }