250 lines
7.5 KiB
Dart
250 lines
7.5 KiB
Dart
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<LogLine> 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<LogConsole> createState() => _LogConsoleState();
|
|
}
|
|
|
|
class _LogConsoleState extends State<LogConsole> {
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|