Non-English users saw English text in a dozen places and blank labels in sixteen more. The English came from sites that produce their copy away from the widget that renders it, which is what the structural hardcoded-string check cannot see: picture-in-picture refused with a raw literal instead of the pipErrors.notSupported key that already existed; the two Jellyfin/Emby auth throws missing display: rendered their developer message on the add-server form; ServerParsingException.toString() fed its English into the localized "Failed to load servers" wrapper; Watch Together interpolated the whole PeerError, so a failed create read "Failed to create session: PeerError(PeerErrorType.timeout): Timed out creating session" and join printed its prefix twice; the hub and playlist continuation footers rendered exception.toString(); shader rows showed an English title over an already translated subtitle; the player queue fell back to the raw Dart enum name; a Plex home user with no title showed "Unknown"; a failed player start showed "Exception: Failed to initialize player"; and the tvOS top-shelf header was hardcoded in an extension that has no Flutter engine. The blanks came from three recent features that added English keys without translations. clean_translations.py filled all 21 siblings with empty strings, so the Android TV resolution switch, every Jellyfin/Emby recording-rule field, the demuxer row, the Companion Remote address caption and the Seerr blocklist pill rendered nothing at all. Two fixes are structural rather than key swaps. ContinuationStatusSliver now takes an errorContext and calls a new non-logging localizedLoadErrorText, so no future throw can leak through it. lib/mpv stays free of user-facing copy: it raises a PlayerInitializationException sentinel and a PlayerError.playerInitFailed cause tag that the player screen resolves to localized text. The tvOS section title travels in the shelf payload, additively, so an older cache still renders.
205 lines
5.9 KiB
Dart
205 lines
5.9 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import '../../focus/focusable_button.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../utils/error_message_utils.dart';
|
|
import 'state_messages.dart';
|
|
|
|
/// Sliver wrapper around [ErrorStateWidget] for use in `CustomScrollView.slivers`.
|
|
class SliverErrorState extends StatelessWidget {
|
|
final String message;
|
|
final VoidCallback? onRetry;
|
|
final String? retryLabel;
|
|
final FocusNode? actionFocusNode;
|
|
final VoidCallback? onActionNavigateUp;
|
|
final VoidCallback? onActionNavigateLeft;
|
|
final VoidCallback? onActionBack;
|
|
final bool actionAutofocus;
|
|
final bool actionUseBackgroundFocus;
|
|
|
|
const SliverErrorState({
|
|
super.key,
|
|
required this.message,
|
|
this.onRetry,
|
|
this.retryLabel,
|
|
this.actionFocusNode,
|
|
this.onActionNavigateUp,
|
|
this.onActionNavigateLeft,
|
|
this.onActionBack,
|
|
this.actionAutofocus = false,
|
|
this.actionUseBackgroundFocus = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SliverFillRemaining(
|
|
child: ErrorStateWidget(
|
|
message: message,
|
|
icon: Symbols.error_outline_rounded,
|
|
onRetry: onRetry,
|
|
retryLabel: retryLabel,
|
|
actionFocusNode: actionFocusNode,
|
|
onActionNavigateUp: onActionNavigateUp,
|
|
onActionNavigateLeft: onActionNavigateLeft,
|
|
onActionBack: onActionBack,
|
|
actionAutofocus: actionAutofocus,
|
|
actionUseBackgroundFocus: actionUseBackgroundFocus,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Sliver wrapper around [EmptyStateWidget] for use in `CustomScrollView.slivers`.
|
|
class SliverEmptyState extends StatelessWidget {
|
|
final String message;
|
|
final IconData? icon;
|
|
final String? subtitle;
|
|
final VoidCallback? onAction;
|
|
final String? actionLabel;
|
|
final IconData? actionIcon;
|
|
final FocusNode? actionFocusNode;
|
|
final VoidCallback? onActionNavigateUp;
|
|
final VoidCallback? onActionNavigateLeft;
|
|
final VoidCallback? onActionBack;
|
|
|
|
const SliverEmptyState({
|
|
super.key,
|
|
required this.message,
|
|
required this.icon,
|
|
this.subtitle,
|
|
this.onAction,
|
|
this.actionLabel,
|
|
this.actionIcon,
|
|
this.actionFocusNode,
|
|
this.onActionNavigateUp,
|
|
this.onActionNavigateLeft,
|
|
this.onActionBack,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SliverFillRemaining(
|
|
child: EmptyStateWidget(
|
|
message: message,
|
|
icon: icon,
|
|
subtitle: subtitle,
|
|
onAction: onAction,
|
|
actionLabel: actionLabel,
|
|
actionIcon: actionIcon,
|
|
actionFocusNode: actionFocusNode,
|
|
onActionNavigateUp: onActionNavigateUp,
|
|
onActionNavigateLeft: onActionNavigateLeft,
|
|
onActionBack: onActionBack,
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Footer sliver for continuation (append-to-list) pagination: a spinner while
|
|
/// the next page loads, or the error message with a focusable retry button.
|
|
class ContinuationStatusSliver extends StatelessWidget {
|
|
/// Failure from the last page load; null while the page is still loading.
|
|
final Object? error;
|
|
final VoidCallback onRetry;
|
|
final FocusNode retryFocusNode;
|
|
final VoidCallback? onNavigateUp;
|
|
final VoidCallback? onBack;
|
|
|
|
/// Name of the thing being loaded; interpolated into the localized error
|
|
/// message (`t.errors.unableToLoad`).
|
|
final String errorContext;
|
|
|
|
const ContinuationStatusSliver({
|
|
super.key,
|
|
required this.error,
|
|
required this.onRetry,
|
|
required this.retryFocusNode,
|
|
required this.errorContext,
|
|
this.onNavigateUp,
|
|
this.onBack,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final exception = error;
|
|
final message = exception == null ? null : localizedLoadErrorText(exception, context: errorContext);
|
|
return SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: message == null
|
|
? const CircularProgressIndicator()
|
|
: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
Text(message, textAlign: TextAlign.center),
|
|
const SizedBox(height: 8),
|
|
FocusableButton(
|
|
focusNode: retryFocusNode,
|
|
onPressed: onRetry,
|
|
onNavigateUp: onNavigateUp,
|
|
onBack: onBack,
|
|
child: TextButton(onPressed: onRetry, child: Text(t.common.retry)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A widget that handles loading, error, empty, and content states
|
|
/// Provides a consistent UI pattern across the app for data-driven screens
|
|
class ContentStateBuilder<T> extends StatelessWidget {
|
|
/// Whether data is currently loading
|
|
final bool isLoading;
|
|
|
|
/// Error message to display (null if no error)
|
|
final String? errorMessage;
|
|
|
|
/// The list of items to display
|
|
final List<T> items;
|
|
|
|
/// Icon to display when the list is empty
|
|
final IconData emptyIcon;
|
|
|
|
/// Message to display when the list is empty
|
|
final String emptyMessage;
|
|
|
|
/// Callback when user taps retry button
|
|
final VoidCallback onRetry;
|
|
|
|
/// Builder for the content when items are available
|
|
final Widget Function(List<T> items) builder;
|
|
|
|
const ContentStateBuilder({
|
|
super.key,
|
|
required this.isLoading,
|
|
required this.errorMessage,
|
|
required this.items,
|
|
required this.emptyIcon,
|
|
required this.emptyMessage,
|
|
required this.onRetry,
|
|
required this.builder,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (isLoading && items.isEmpty) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (errorMessage != null && items.isEmpty) {
|
|
return ErrorStateWidget(
|
|
message: errorMessage!,
|
|
icon: Symbols.error_outline_rounded,
|
|
onRetry: onRetry,
|
|
retryLabel: t.common.retry,
|
|
);
|
|
}
|
|
|
|
if (items.isEmpty) {
|
|
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
|
|
}
|
|
|
|
return builder(items);
|
|
}
|
|
}
|