offline updates and playhead fix
This commit is contained in:
parent
7a199fe4df
commit
6663330260
14 changed files with 1673 additions and 545 deletions
190
lib/screens/debug_screen.dart
Normal file
190
lib/screens/debug_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue