190 lines
5.5 KiB
Dart
190 lines
5.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../debug/log_store.dart';
|
|
import '../theme/tokens.dart';
|
|
|
|
/// Beta-testing console: shows the app's captured `print`/`debugPrint` output
|
|
/// and uncaught errors (see [LogStore], wired up in `main`). Read-only view
|
|
/// with copy-all / clear / follow controls — no debugger needed on-device.
|
|
class DebugScreen extends StatefulWidget {
|
|
const DebugScreen({super.key});
|
|
|
|
@override
|
|
State<DebugScreen> createState() => _DebugScreenState();
|
|
}
|
|
|
|
class _DebugScreenState extends State<DebugScreen> {
|
|
static const _errorColor = Color(0xFFE06C75);
|
|
|
|
final _controller = ScrollController();
|
|
|
|
/// When true, new lines keep the view pinned to the bottom (tail -f style).
|
|
/// Flipped off automatically when the user scrolls up to read history.
|
|
bool _follow = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.addListener(_onScroll);
|
|
LogStore.instance.addListener(_onLog);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
LogStore.instance.removeListener(_onLog);
|
|
_controller.removeListener(_onScroll);
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (!_controller.hasClients) return;
|
|
final atBottom =
|
|
_controller.offset >= _controller.position.maxScrollExtent - 24;
|
|
if (atBottom != _follow) setState(() => _follow = atBottom);
|
|
}
|
|
|
|
void _onLog() {
|
|
if (!mounted) return;
|
|
setState(() {});
|
|
if (_follow) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _jumpToBottom());
|
|
}
|
|
}
|
|
|
|
void _jumpToBottom() {
|
|
if (!_controller.hasClients) return;
|
|
_controller.jumpTo(_controller.position.maxScrollExtent);
|
|
}
|
|
|
|
Future<void> _copyAll() async {
|
|
await Clipboard.setData(ClipboardData(text: LogStore.instance.asText()));
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Log copied to clipboard')),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
color: TimbreColors.background,
|
|
child: Column(
|
|
children: [
|
|
_header(),
|
|
Expanded(
|
|
child: ListenableBuilder(
|
|
listenable: LogStore.instance,
|
|
builder: (context, _) {
|
|
final entries = LogStore.instance.entries;
|
|
if (entries.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
'No output captured yet.',
|
|
style: TextStyle(color: TimbreColors.dimmed),
|
|
),
|
|
);
|
|
}
|
|
return Scrollbar(
|
|
controller: _controller,
|
|
child: ListView.builder(
|
|
controller: _controller,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: TimbreSpacing.lg,
|
|
vertical: TimbreSpacing.sm,
|
|
),
|
|
itemCount: entries.length,
|
|
itemBuilder: (context, i) => _line(entries[i]),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _header() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: TimbreColors.surface,
|
|
border: Border(bottom: BorderSide(color: TimbreColors.border)),
|
|
),
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: TimbreSpacing.lg,
|
|
vertical: TimbreSpacing.sm,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: ListenableBuilder(
|
|
listenable: LogStore.instance,
|
|
builder: (context, _) => Text(
|
|
'console · ${LogStore.instance.length} lines',
|
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
_action(
|
|
_follow ? Icons.vertical_align_bottom : Icons.pause,
|
|
_follow ? 'follow' : 'paused',
|
|
() {
|
|
setState(() => _follow = !_follow);
|
|
if (_follow) _jumpToBottom();
|
|
},
|
|
active: _follow,
|
|
),
|
|
_action(Icons.copy, 'copy', _copyAll),
|
|
_action(Icons.delete_outline, 'clear', LogStore.instance.clear),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _action(IconData icon, String label, VoidCallback onTap,
|
|
{bool active = false}) {
|
|
final accent = Theme.of(context).colorScheme.primary;
|
|
final color = active ? accent : TimbreColors.dimmed;
|
|
return InkWell(
|
|
onTap: onTap,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 14, color: color),
|
|
const SizedBox(width: TimbreSpacing.xs),
|
|
Text(label, style: TextStyle(color: color, fontSize: 12)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _line(LogEntry e) {
|
|
final isError = e.level == LogLevel.error;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 2),
|
|
child: Text.rich(
|
|
TextSpan(
|
|
children: [
|
|
TextSpan(
|
|
text: '${e.timeLabel} ',
|
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
|
|
),
|
|
TextSpan(
|
|
text: e.text,
|
|
style: TextStyle(
|
|
color: isError ? _errorColor : TimbreColors.foreground,
|
|
fontSize: 12,
|
|
height: 1.35,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|