Add app source and tests
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
249
lib/Peach/designs/LogConsole.dart
Normal file
249
lib/Peach/designs/LogConsole.dart
Normal file
@@ -0,0 +1,249 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user