Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is 36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16 disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on API 35+ the window is edge-to-edge from the first frame and MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation. MainScreen's phone layout hides that. It supplies a bottomNavigationBar and never sets extendBody, so Flutter's Scaffold strips padding.bottom from the body MediaQuery and every tab is already safe. Routes pushed on the profile navigator are full-screen siblings of MainScreen with no bottom bar, so they receive the untouched inset and nothing consumes it - the last settings card and the final log lines render under the back, home, and recents buttons. Three shared hosts own most of those routes, so the inset is consumed there: FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing SliverSystemBottomInset, and the four screens that build their own Scaffold around a CustomScrollView append it directly. The new widget codifies the convention this repository had already written down but open-coded - insets baked into the scroll content rather than a SafeArea around the scroll view - so content still paints under the bar while the scroll extent grows enough to bring the last row above it. It reads padding from its own context and collapses to zero height wherever the inset is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside MainScreen's tab bodies. No platform branching, and it stacks additively with the music detail screens' existing mini-player spacers, which is correct because the mini-player itself floats above the navigation bar on a pushed route. Scroll views that are not sliver lists take the inset in their own padding: the companion remote's ListView, the auth screen's scroll container, and the two SliverFillRemaining sign-in forms, whose children size themselves from the extent remaining before them and so cannot be helped by a trailing sliver. The logs empty state is left alone for the same reason inverted - it already fills the viewport, and a trailing inset would only add scroll slack. Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation: Settings, Logs, and Video Playback all end clear of the bar. close #1766
451 lines
16 KiB
Dart
451 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:device_info_plus/device_info_plus.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/utils/media_server_http_client.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:logger/logger.dart';
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import '../../focus/focusable_action_bar.dart';
|
|
import '../../widgets/dialog_action_button.dart';
|
|
import '../../widgets/app_icon.dart';
|
|
import '../../focus/key_event_utils.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../mixins/mounted_set_state_mixin.dart';
|
|
import '../../utils/dialogs.dart';
|
|
import '../../main.dart' show gitCommit;
|
|
import '../../services/background_work_diagnostics_service.dart';
|
|
import '../../services/device_performance.dart';
|
|
import '../../services/log_upload_service.dart';
|
|
import '../../services/startup_diagnostics.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../../utils/formatters.dart';
|
|
import '../../utils/platform_detector.dart';
|
|
import '../../utils/snackbar_helper.dart';
|
|
import '../../widgets/desktop_app_bar.dart';
|
|
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
|
import '../../widgets/system_bottom_inset.dart';
|
|
|
|
const previousStartupFailureKey = Key('logs-previous-startup-failure');
|
|
|
|
class LogsScreen extends StatefulWidget {
|
|
const LogsScreen({super.key, this.httpClient, this.deviceInfoPlugin});
|
|
|
|
final MediaServerHttpClient? httpClient;
|
|
final DeviceInfoPlugin? deviceInfoPlugin;
|
|
|
|
@override
|
|
State<LogsScreen> createState() => _LogsScreenState();
|
|
}
|
|
|
|
class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
|
|
List<LogEntry> _logs = [];
|
|
String _deviceInfo = '';
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
MediaServerHttpClient get _httpClient => widget.httpClient ?? httpClient;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_logs = MemoryLogOutput.getLogs();
|
|
_loadDeviceInfo();
|
|
}
|
|
|
|
Future<void> _loadDeviceInfo() async {
|
|
final packageInfo = await PackageInfo.fromPlatform();
|
|
final deviceInfo = widget.deviceInfoPlugin ?? DeviceInfoPlugin();
|
|
final buffer = StringBuffer();
|
|
final commitSuffix = gitCommit.isNotEmpty ? ' ${gitCommit.substring(0, 7)}' : '';
|
|
buffer.writeln('${t.app.title} v${packageInfo.version} (${packageInfo.buildNumber})$commitSuffix');
|
|
|
|
if (Platform.isAndroid) {
|
|
final info = await deviceInfo.androidInfo;
|
|
buffer.writeln('Android ${info.version.release} (API ${info.version.sdkInt})');
|
|
buffer.writeln('${info.manufacturer} ${info.model}');
|
|
if (TvDetectionService.isTVSync()) {
|
|
final reasons = TvDetectionService.tvDetectionReasonsSync();
|
|
final suffix = reasons.isEmpty ? '' : ' (${reasons.join(', ')})';
|
|
buffer.writeln('TV mode: yes$suffix');
|
|
}
|
|
// Renderer + effects tier turn "did the reduced tier engage on this
|
|
// device" into something answerable from any uploaded log (#1349).
|
|
String renderer;
|
|
try {
|
|
renderer = await const MethodChannel('com.plezy/theme').invokeMethod<String>('getRenderer') ?? 'unknown';
|
|
} catch (_) {
|
|
renderer = 'unknown';
|
|
}
|
|
buffer.writeln('Renderer: $renderer');
|
|
// "Are downloads allowed to run in the background" is the single most
|
|
// asked support question; answering it from the uploaded log turns a
|
|
// four-message thread into one reply.
|
|
final backgroundWork = BackgroundWorkDiagnosticsService.instance;
|
|
await backgroundWork.refresh();
|
|
buffer.writeln('Background: ${backgroundWork.describeSync()}');
|
|
} else if (Platform.isIOS) {
|
|
final info = await deviceInfo.iosInfo;
|
|
buffer.writeln('iOS ${info.systemVersion}');
|
|
buffer.writeln(info.utsname.machine);
|
|
} else if (Platform.isMacOS) {
|
|
final info = await deviceInfo.macOsInfo;
|
|
buffer.writeln('macOS ${info.osRelease}');
|
|
buffer.writeln(info.model);
|
|
} else if (Platform.isLinux) {
|
|
final info = await deviceInfo.linuxInfo;
|
|
buffer.writeln('Linux ${info.versionId ?? info.id}');
|
|
}
|
|
|
|
buffer.writeln('Effects: ${DevicePerformance.describeSync()}');
|
|
buffer.writeln('Display: ${DevicePerformance.describeDisplay()}');
|
|
|
|
setStateIfMounted(() => _deviceInfo = buffer.toString().trimRight());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _loadLogs() {
|
|
setState(() {
|
|
_logs = MemoryLogOutput.getLogs();
|
|
});
|
|
}
|
|
|
|
String _formatTime(DateTime time) {
|
|
final hour = padNumber(time.hour, 2);
|
|
final minute = padNumber(time.minute, 2);
|
|
final second = padNumber(time.second, 2);
|
|
final millisecond = padNumber(time.millisecond, 3);
|
|
return '$hour:$minute:$second.$millisecond';
|
|
}
|
|
|
|
/// Whether there is anything worth copying, uploading or clearing.
|
|
///
|
|
/// A launch that failed the startup gate leaves an empty in-memory buffer —
|
|
/// that process is gone — but does leave a persisted record. Gating the
|
|
/// actions on the buffer alone would show that record and then refuse to let
|
|
/// the user do anything with it, which is the whole point of #1732.
|
|
bool get _hasDiagnostics => _logs.isNotEmpty || StartupDiagnosticsStore.pending != null;
|
|
|
|
Future<void> _clearLogs() async {
|
|
// The startup record is part of the same diagnostic payload, so clearing
|
|
// logs drops it too, or it would silently reappear in the next upload.
|
|
// Awaited before rebuilding so the banner cannot survive the clear.
|
|
await StartupDiagnosticsStore.clear();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
MemoryLogOutput.clearLogs();
|
|
_logs = [];
|
|
});
|
|
showSuccessSnackBar(context, t.messages.logsCleared);
|
|
}
|
|
|
|
String _formatAllLogs({int? maxBytes}) {
|
|
final sections = <String>[
|
|
if (_deviceInfo.isNotEmpty) _deviceInfo,
|
|
// A launch that failed the startup gate leaves no in-memory log at all —
|
|
// the buffer died with that process. This is the only place its record
|
|
// can reach a maintainer (#1732).
|
|
?StartupDiagnosticsStore.pending?.describe(),
|
|
];
|
|
final header = sections.isEmpty ? '' : '${sections.join('\n\n')}\n---\n';
|
|
final logs = StringBuffer();
|
|
var isFirst = true;
|
|
for (final log in _logs.reversed) {
|
|
if (!isFirst) {
|
|
logs.write('\n');
|
|
}
|
|
isFirst = false;
|
|
|
|
logs.write('[${_formatTime(log.timestamp)}] [${log.level.name.toUpperCase()}] ${log.message}');
|
|
if (log.error != null) {
|
|
logs.write('\nError: ${log.error}');
|
|
}
|
|
if (log.stackTrace != null) {
|
|
logs.write('\nStack trace:\n${log.stackTrace}');
|
|
}
|
|
}
|
|
final logText = logs.toString();
|
|
return maxBytes == null
|
|
? '$header$logText'
|
|
: constrainLogUploadPayload(header: header, logs: logText, maxBytes: maxBytes);
|
|
}
|
|
|
|
void _copyAllLogs() {
|
|
Clipboard.setData(ClipboardData(text: _formatAllLogs()));
|
|
showSuccessSnackBar(context, t.messages.logsCopied);
|
|
}
|
|
|
|
Future<void> _uploadLogs() async {
|
|
final logText = _formatAllLogs(maxBytes: maxLogUploadBytes);
|
|
|
|
showLoadingDialog(context);
|
|
|
|
try {
|
|
final id = await uploadDiagnosticText(logText, client: _httpClient);
|
|
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(); // dismiss loading
|
|
|
|
unawaited(
|
|
showScopedDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(t.messages.logsUploaded),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('${t.messages.logId}:'),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SelectableText(
|
|
id,
|
|
style: const TextStyle(fontWeight: .bold, fontFamily: 'monospace', fontSize: 18),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: const AppIcon(Symbols.content_copy_rounded, size: 20),
|
|
onPressed: () {
|
|
Clipboard.setData(ClipboardData(text: id));
|
|
showSuccessSnackBar(context, t.messages.logsCopied);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
DialogActionButton(autofocus: true, onPressed: () => Navigator.of(ctx).pop(), label: t.common.close),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
Navigator.of(context).pop(); // dismiss loading
|
|
showErrorSnackBar(context, t.messages.logsUploadFailed);
|
|
}
|
|
}
|
|
|
|
Color _getLevelColor(Level level) {
|
|
switch (level) {
|
|
case Level.error:
|
|
case Level.fatal:
|
|
return Colors.red;
|
|
case Level.warning:
|
|
return Colors.orange;
|
|
case Level.info:
|
|
return Colors.blue;
|
|
case Level.debug:
|
|
case Level.trace:
|
|
return Colors.grey;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
void _scroll(double delta) {
|
|
final pos = _scrollController.position;
|
|
_scrollController.animateTo(
|
|
(pos.pixels + delta).clamp(pos.minScrollExtent, pos.maxScrollExtent),
|
|
duration: const Duration(milliseconds: 100),
|
|
curve: Curves.easeOut,
|
|
);
|
|
}
|
|
|
|
List<TextSpan> _buildLogSpans() {
|
|
final spans = <TextSpan>[];
|
|
if (_deviceInfo.isNotEmpty) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: '$_deviceInfo\n',
|
|
style: TextStyle(color: Colors.grey.withValues(alpha: 0.6)),
|
|
),
|
|
);
|
|
spans.add(
|
|
TextSpan(
|
|
text: '---\n',
|
|
style: TextStyle(color: Colors.grey.withValues(alpha: 0.3)),
|
|
),
|
|
);
|
|
}
|
|
for (var i = 0; i < _logs.length; i++) {
|
|
if (i > 0) spans.add(const TextSpan(text: '\n'));
|
|
final log = _logs[i];
|
|
final color = _getLevelColor(log.level);
|
|
spans.add(
|
|
TextSpan(
|
|
text: '[${_formatTime(log.timestamp)}] ',
|
|
style: TextStyle(color: color.withValues(alpha: 0.6)),
|
|
),
|
|
);
|
|
spans.add(
|
|
TextSpan(
|
|
text: '[${log.level.name.toUpperCase()}] ',
|
|
style: TextStyle(color: color, fontWeight: .bold),
|
|
),
|
|
);
|
|
spans.add(TextSpan(text: log.message));
|
|
if (log.error != null) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: '\n Error: ${log.error}',
|
|
style: TextStyle(color: color),
|
|
),
|
|
);
|
|
}
|
|
if (log.stackTrace != null) {
|
|
spans.add(
|
|
TextSpan(
|
|
text: '\n ${log.stackTrace.toString().replaceAll('\n', '\n ')}',
|
|
style: TextStyle(color: Colors.grey.withValues(alpha: 0.7)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
return spans;
|
|
}
|
|
|
|
/// Banner for a startup failure recorded by an earlier launch.
|
|
///
|
|
/// Returns null when there is none, so the caller can splice it in with a
|
|
/// null-aware element.
|
|
Widget? _buildPreviousFailureBanner(ThemeData theme) {
|
|
final failure = StartupDiagnosticsStore.pending;
|
|
if (failure == null) return null;
|
|
|
|
return SliverToBoxAdapter(
|
|
key: previousStartupFailureKey,
|
|
child: Container(
|
|
margin: const EdgeInsets.fromLTRB(12, 12, 12, 0),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(color: theme.colorScheme.errorContainer, borderRadius: BorderRadius.circular(8)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
AppIcon(Symbols.error_rounded, size: 18, color: theme.colorScheme.onErrorContainer),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
t.startup.previousFailureTitle,
|
|
style: theme.textTheme.titleSmall?.copyWith(color: theme.colorScheme.onErrorContainer),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
SelectableText(
|
|
failure.describe(),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
color: theme.colorScheme.onErrorContainer,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
return Focus(
|
|
canRequestFocus: false,
|
|
onKeyEvent: (node, event) {
|
|
final backResult = handleBackKeyNavigation(context, event);
|
|
if (backResult != KeyEventResult.ignored) return backResult;
|
|
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
|
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
|
_scroll(80);
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
|
_scroll(-80);
|
|
return KeyEventResult.handled;
|
|
}
|
|
}
|
|
return KeyEventResult.ignored;
|
|
},
|
|
child: PrimaryScrollController(
|
|
controller: _scrollController,
|
|
child: IosStatusBarTapScrollToTop(
|
|
controller: _scrollController,
|
|
child: Scaffold(
|
|
body: CustomScrollView(
|
|
primary: true,
|
|
slivers: [
|
|
CustomAppBar(
|
|
title: Text(t.screens.logs),
|
|
pinned: true,
|
|
actions: [
|
|
FocusableActionBar(
|
|
actions: [
|
|
FocusableAction(icon: Symbols.refresh_rounded, tooltip: t.common.refresh, onPressed: _loadLogs),
|
|
FocusableAction(
|
|
icon: Symbols.upload_rounded,
|
|
tooltip: t.logs.uploadLogs,
|
|
onPressed: _hasDiagnostics ? _uploadLogs : null,
|
|
),
|
|
FocusableAction(
|
|
icon: Symbols.content_copy_rounded,
|
|
tooltip: t.logs.copyLogs,
|
|
onPressed: _hasDiagnostics ? _copyAllLogs : null,
|
|
),
|
|
FocusableAction(
|
|
icon: Symbols.delete_outline_rounded,
|
|
tooltip: t.logs.clearLogs,
|
|
onPressed: _hasDiagnostics ? _clearLogs : null,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
// A launch that failed the startup gate leaves nothing in the
|
|
// in-memory buffer — that process is gone. Show its record
|
|
// here, where the user can actually act on it (#1732).
|
|
?_buildPreviousFailureBanner(theme),
|
|
if (_logs.isEmpty)
|
|
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
|
|
else ...[
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(12),
|
|
sliver: SliverToBoxAdapter(
|
|
child: SelectableText.rich(
|
|
TextSpan(
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
height: 1.5,
|
|
),
|
|
children: _buildLogSpans(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Only the log body needs it: the empty state already fills
|
|
// the viewport, so a trailing inset would just add slack.
|
|
const SliverSystemBottomInset(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|