offline updates and playhead fix

This commit is contained in:
Forrest 2026-08-16 12:46:30 -04:00
parent 7a199fe4df
commit 6663330260
14 changed files with 1673 additions and 545 deletions

View file

@ -0,0 +1,190 @@
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,
),
),
],
),
),
);
}
}