Add app source and tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
alvocool
2026-10-11 10:15:30 +03:00
parent 7e246255c2
commit 38881d1fe1
135 changed files with 21057 additions and 0 deletions

View 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,
),
],
),
);
}
}