628 lines
25 KiB
Dart
628 lines
25 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../focus/focusable_action_bar.dart';
|
|
import '../focus/dpad_navigator.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../focus/key_event_utils.dart';
|
|
import '../focus/locked_hub_controller.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../media/media_hub.dart';
|
|
import '../media/media_item.dart';
|
|
import '../models/catalog/catalog_cast_member.dart';
|
|
import '../models/catalog/catalog_item.dart';
|
|
import '../providers/catalog_sources_provider.dart';
|
|
import '../services/catalog/catalog_library_matcher.dart';
|
|
import '../services/catalog/catalog_source.dart';
|
|
import '../services/catalog/seerr_catalog_source.dart';
|
|
import '../utils/app_logger.dart';
|
|
import '../utils/desktop_window_padding.dart';
|
|
import '../utils/formatters.dart';
|
|
import '../utils/media_navigation_helper.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import '../utils/snackbar_helper.dart';
|
|
import '../widgets/app_bar_back_button.dart';
|
|
import '../widgets/app_icon.dart';
|
|
import '../widgets/backend_badge.dart';
|
|
import '../widgets/cast_member_strip.dart';
|
|
import '../widgets/focusable_list_tile.dart';
|
|
import '../widgets/hub_section.dart';
|
|
import '../widgets/optimized_media_image.dart';
|
|
import '../widgets/overlay_sheet.dart';
|
|
import '../widgets/seerr_request_sheet.dart';
|
|
import '../widgets/settings_section.dart';
|
|
import '../widgets/stat_chip.dart';
|
|
|
|
/// Detail screen for a catalog item (Explore tab). Renders from provider
|
|
/// data — no media server required — and resolves library availability in
|
|
/// place: an "In these libraries" list when the item is owned, tappable
|
|
/// through to the normal media detail screen.
|
|
class CatalogItemDetailScreen extends StatefulWidget {
|
|
final CatalogItem item;
|
|
|
|
const CatalogItemDetailScreen({super.key, required this.item});
|
|
|
|
@override
|
|
State<CatalogItemDetailScreen> createState() => _CatalogItemDetailScreenState();
|
|
}
|
|
|
|
class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
|
final _backButtonFocusNode = FocusNode(debugLabel: 'catalog_detail_back');
|
|
final _castSectionKey = GlobalKey();
|
|
final _castStripKey = GlobalKey<CastMemberStripState>();
|
|
final _relatedSectionKey = GlobalKey<HubSectionState>();
|
|
final _hubFocusMemory = HubFocusMemory();
|
|
final ScrollController _scrollController = ScrollController();
|
|
List<FocusNode> _libraryMatchFocusNodes = const [];
|
|
CatalogSource? _watchlistSource;
|
|
SeerrCatalogSource? _requestSource;
|
|
bool _mutatingWatchlist = false;
|
|
|
|
/// Library items matching this catalog item; null while resolving.
|
|
List<MediaItem>? _matches;
|
|
|
|
/// Cast/characters from the item's own source; null while loading (the
|
|
/// section only renders once loaded non-empty).
|
|
List<CatalogCastMember>? _cast;
|
|
|
|
/// "More like this" from the item's own source; null while loading (the
|
|
/// row only renders once loaded non-empty).
|
|
List<CatalogItem>? _related;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_resolveMatches());
|
|
unawaited(_loadCast());
|
|
unawaited(_loadRelated());
|
|
final sources = context.read<CatalogSourcesProvider>();
|
|
_watchlistSource = sources.watchlistSourceFor(widget.item);
|
|
// Request needs a connected Seerr, the permission for this kind, and a
|
|
// tmdb id (Trakt items carry one natively; MAL items get theirs from the
|
|
// Fribb mapping at row time).
|
|
final seerr = sources.seerrSource;
|
|
if (seerr != null && widget.item.ids.tmdb != null && seerr.canRequest(widget.item.kind)) {
|
|
_requestSource = seerr;
|
|
}
|
|
final source = _watchlistSource;
|
|
if (source != null) {
|
|
source.watchlistChanges.addListener(_onWatchlistChanged);
|
|
if (source.isOnWatchlist(widget.item.kind, widget.item.ids) == null) {
|
|
unawaited(source.ensureWatchlistLoaded());
|
|
}
|
|
}
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _actionBarKey.currentState?.requestFocusOnFirst();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_backButtonFocusNode.dispose();
|
|
_watchlistSource?.watchlistChanges.removeListener(_onWatchlistChanged);
|
|
for (final node in _libraryMatchFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_scrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onWatchlistChanged() {
|
|
// ignore: no-empty-block - membership state lives in the source
|
|
setState(() {});
|
|
}
|
|
|
|
Future<void> _resolveMatches() async {
|
|
try {
|
|
_setMatches(await context.read<CatalogLibraryMatcher>().match(widget.item));
|
|
} catch (e) {
|
|
appLogger.w('Catalog library match failed for ${widget.item.identityKey}', error: e);
|
|
_setMatches(const []);
|
|
}
|
|
}
|
|
|
|
void _setMatches(List<MediaItem> matches) {
|
|
if (!mounted) return;
|
|
for (final node in _libraryMatchFocusNodes) {
|
|
node.dispose();
|
|
}
|
|
_libraryMatchFocusNodes = [
|
|
for (var index = 0; index < matches.length; index++)
|
|
FocusNode(
|
|
debugLabel: 'catalog_library_match_$index',
|
|
onKeyEvent: (node, event) => _handleLibraryMatchKey(index, event),
|
|
),
|
|
];
|
|
setState(() => _matches = matches);
|
|
}
|
|
|
|
CatalogSource? get _ownSource =>
|
|
context.read<CatalogSourcesProvider>().connectedSources.firstWhereOrNull((s) => s.id == widget.item.source);
|
|
|
|
/// One lazy request against the item's own source; failures just leave the
|
|
/// section hidden.
|
|
Future<void> _loadCast() async {
|
|
final source = _ownSource;
|
|
if (source == null) return;
|
|
try {
|
|
final cast = await source.fetchCast(widget.item);
|
|
if (mounted) setState(() => _cast = cast);
|
|
} catch (e) {
|
|
appLogger.d('Catalog cast load failed for ${widget.item.identityKey}', error: e);
|
|
}
|
|
}
|
|
|
|
/// One lazy request against the item's own source; failures just leave the
|
|
/// row hidden.
|
|
Future<void> _loadRelated() async {
|
|
final source = _ownSource;
|
|
if (source == null) return;
|
|
try {
|
|
final related = await source.fetchRelated(widget.item);
|
|
if (mounted) setState(() => _related = related);
|
|
} catch (e) {
|
|
appLogger.d('Catalog related load failed for ${widget.item.identityKey}', error: e);
|
|
}
|
|
}
|
|
|
|
bool get _hasActions => _watchlistSource != null || _requestSource != null;
|
|
|
|
bool get _hasLibraryMatches => _libraryMatchFocusNodes.isNotEmpty;
|
|
|
|
void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) {
|
|
final focusContext = node?.context;
|
|
if (focusContext == null) return;
|
|
unawaited(
|
|
Scrollable.ensureVisible(
|
|
focusContext,
|
|
alignment: alignment,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _requestLibraryMatchFocus(int index) {
|
|
if (index < 0 || index >= _libraryMatchFocusNodes.length) return;
|
|
final node = _libraryMatchFocusNodes[index];
|
|
node.requestFocus();
|
|
_revealFocusNode(node);
|
|
}
|
|
|
|
bool _focusSectionBelowLibraryMatches() {
|
|
if (_cast?.isNotEmpty ?? false) {
|
|
_requestCastFocus();
|
|
return true;
|
|
}
|
|
if (_related?.isNotEmpty ?? false) {
|
|
_requestRelatedFocus();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
KeyEventResult _handleLibraryMatchKey(int index, KeyEvent event) {
|
|
if (!event.isActionable) return KeyEventResult.ignored;
|
|
final key = event.logicalKey;
|
|
if (key.isUpKey) {
|
|
if (index > 0) {
|
|
_requestLibraryMatchFocus(index - 1);
|
|
} else if (_hasActions) {
|
|
_requestActionBarFocus();
|
|
} else {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
if (key.isDownKey) {
|
|
if (index + 1 < _libraryMatchFocusNodes.length) {
|
|
_requestLibraryMatchFocus(index + 1);
|
|
} else if (!_focusSectionBelowLibraryMatches()) {
|
|
return KeyEventResult.ignored;
|
|
}
|
|
return KeyEventResult.handled;
|
|
}
|
|
return KeyEventResult.ignored;
|
|
}
|
|
|
|
void _requestCastFocus() {
|
|
if (!(_cast?.isNotEmpty ?? false)) return;
|
|
_castStripKey.currentState?.requestFocus();
|
|
final sectionContext = _castSectionKey.currentContext;
|
|
if (sectionContext == null) return;
|
|
unawaited(
|
|
Scrollable.ensureVisible(
|
|
sectionContext,
|
|
alignment: 0.3,
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOut,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _requestActionBarFocus() {
|
|
_actionBarKey.currentState?.requestFocusOnFirst();
|
|
if (!_scrollController.hasClients) return;
|
|
unawaited(_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut));
|
|
}
|
|
|
|
void _requestRelatedFocus() {
|
|
_relatedSectionKey.currentState?.requestFocusFromMemory();
|
|
}
|
|
|
|
void _focusSectionBelowActions() {
|
|
if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(0);
|
|
} else if (_cast?.isNotEmpty ?? false) {
|
|
_requestCastFocus();
|
|
} else {
|
|
_requestRelatedFocus();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveCast() {
|
|
if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
void _focusSectionAboveRelated() {
|
|
if (_cast?.isNotEmpty ?? false) {
|
|
_requestCastFocus();
|
|
} else if (_hasLibraryMatches) {
|
|
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
|
|
} else {
|
|
_requestActionBarFocus();
|
|
}
|
|
}
|
|
|
|
bool? get _isOnWatchlist => _watchlistSource?.isOnWatchlist(widget.item.kind, widget.item.ids);
|
|
|
|
Future<void> _toggleWatchlist() async {
|
|
final source = _watchlistSource;
|
|
final current = _isOnWatchlist;
|
|
if (source == null || current == null || _mutatingWatchlist) return;
|
|
_mutatingWatchlist = true;
|
|
try {
|
|
if (current) {
|
|
await source.removeFromWatchlist(widget.item.kind, widget.item.ids);
|
|
} else {
|
|
await source.addToWatchlist(widget.item.kind, widget.item.ids);
|
|
}
|
|
} catch (_) {
|
|
if (mounted) showErrorSnackBar(context, t.explore.watchlistUpdateFailed);
|
|
} finally {
|
|
_mutatingWatchlist = false;
|
|
}
|
|
}
|
|
|
|
Widget _buildLibraryMatchTile(MediaItem match, int index) {
|
|
return FocusableListTile(
|
|
focusNode: _libraryMatchFocusNodes[index],
|
|
dense: false,
|
|
visualDensity: VisualDensity.standard,
|
|
leading: BackendBadge(backend: match.backend, size: 24),
|
|
// Plex matches carry their library title; Jellyfin's search-based
|
|
// lookup doesn't, so fall back to the server name alone.
|
|
title: Text(match.libraryTitle ?? match.serverName ?? match.backend.name),
|
|
subtitle: match.libraryTitle != null && match.serverName != null ? Text(match.serverName!) : null,
|
|
trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1),
|
|
onTap: () => unawaited(navigateToMediaItemDetails(context, match)),
|
|
);
|
|
}
|
|
|
|
/// Library availability, resolved in place: a progress row while the
|
|
/// matcher runs, "Not in your library" when nothing matched, otherwise an
|
|
/// "In these libraries" list whose rows open the normal media detail
|
|
/// screen. Rows are focusable tiles (dpad-safe, background focus effect).
|
|
Widget _buildLibrarySection(ThemeData theme) {
|
|
final mutedStyle = theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.5));
|
|
final matches = _matches;
|
|
|
|
if (matches == null) {
|
|
return Row(
|
|
children: [
|
|
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
|
|
const SizedBox(width: 10),
|
|
Text(t.explore.checkingLibrary, style: mutedStyle),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (matches.isEmpty) {
|
|
return Row(
|
|
children: [
|
|
AppIcon(Symbols.info_rounded, fill: 1, size: 18, color: theme.colorScheme.onSurface.withValues(alpha: 0.5)),
|
|
const SizedBox(width: 8),
|
|
Text(t.explore.notInLibrary, style: mutedStyle),
|
|
],
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(t.explore.inTheseLibraries, style: theme.textTheme.titleMedium),
|
|
const SizedBox(height: 12),
|
|
// M3E grouped cards, same row anatomy as the settings/trackers hub:
|
|
// server-type logo leading, name, chevron trailing.
|
|
SettingsGroup(
|
|
margin: EdgeInsets.zero,
|
|
children: [
|
|
for (var index = 0; index < matches.length; index++) _buildLibraryMatchTile(matches[index], index),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String get _metaLine {
|
|
final item = widget.item;
|
|
final parts = <String>[
|
|
if (item.year != null) '${item.year}',
|
|
if (item.runtimeMinutes != null) formatDurationTextual(Duration(minutes: item.runtimeMinutes!).inMilliseconds),
|
|
if (item.certification != null && item.certification!.isNotEmpty) item.certification!,
|
|
];
|
|
return parts.join(' • ');
|
|
}
|
|
|
|
static String _statusLabel(CatalogAirStatus status) => switch (status) {
|
|
CatalogAirStatus.airing => t.explore.status.airing,
|
|
CatalogAirStatus.ended => t.explore.status.ended,
|
|
CatalogAirStatus.canceled => t.explore.status.canceled,
|
|
CatalogAirStatus.upcoming => t.explore.status.upcoming,
|
|
};
|
|
|
|
/// Score (with a compact vote count), airing status, episode count, and
|
|
/// network/studio — all data that rode along on the row fetch.
|
|
Widget? _buildStatsChips(ThemeData theme) {
|
|
final item = widget.item;
|
|
String? score;
|
|
if (item.rating != null) {
|
|
score = item.rating!.toStringAsFixed(1);
|
|
if (item.votes != null && item.votes! > 0) {
|
|
final compactVotes = NumberFormat.compact(locale: LocaleSettings.currentLocale.languageCode);
|
|
score = '$score (${compactVotes.format(item.votes)})';
|
|
}
|
|
}
|
|
final chips = <Widget>[
|
|
if (score != null) StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: score),
|
|
if (item.airStatus != null) StatChip(label: _statusLabel(item.airStatus!)),
|
|
if (item.episodeCount != null) StatChip(label: t.explore.episodeCount(n: item.episodeCount!)),
|
|
if (item.network != null) StatChip(label: item.network!),
|
|
];
|
|
if (chips.isEmpty) return null;
|
|
return Wrap(spacing: 8, runSpacing: 8, children: chips);
|
|
}
|
|
|
|
/// Horizontal cast strip — the same [CastMemberStrip] cards as the media
|
|
/// detail screen. Trakt serves actors with their character; MAL serves
|
|
/// characters with their role, so the section is titled accordingly.
|
|
Widget _buildCastSection(ThemeData theme, List<CatalogCastMember> cast) {
|
|
return Column(
|
|
key: _castSectionKey,
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
const {CatalogSourceId.mal, CatalogSourceId.anilist}.contains(widget.item.source)
|
|
? t.explore.characters
|
|
: t.explore.cast,
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 4),
|
|
CastMemberStrip(
|
|
key: _castStripKey,
|
|
members: [
|
|
for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl),
|
|
],
|
|
onNavigateUp: _hasLibraryMatches || _hasActions ? _focusSectionAboveCast : null,
|
|
onNavigateDown: (_related?.isNotEmpty ?? false) ? _requestRelatedFocus : null,
|
|
debugLabel: 'catalog_cast_row',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// "More like this" from the item's own source, rendered through the
|
|
/// standard shelf so cards, long-press menus, and taps behave exactly like
|
|
/// the Explore rows (tap opens another catalog detail screen).
|
|
Widget _buildRelatedSection(List<CatalogItem> related) {
|
|
return HubSection(
|
|
key: _relatedSectionKey,
|
|
hub: MediaHub(
|
|
id: 'catalog-related:${widget.item.source.name}:${widget.item.identityKey}',
|
|
identifier: 'explore.related',
|
|
title: t.discover.moreLikeThis,
|
|
type: 'mixed',
|
|
items: [for (final item in related) item.toMediaItem()],
|
|
size: related.length,
|
|
),
|
|
focusMemory: _hubFocusMemory,
|
|
icon: Symbols.recommend_rounded,
|
|
inset: true,
|
|
onNavigateUp: _focusSectionAboveRelated,
|
|
cardSizing: HubCardSizing.grid,
|
|
);
|
|
}
|
|
|
|
void _handleSystemBack() {
|
|
if (BackKeyCoordinator.consumeIfHandled()) return;
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final item = widget.item;
|
|
final theme = Theme.of(context);
|
|
final onWatchlist = _isOnWatchlist;
|
|
|
|
final viewInsets = MediaQuery.paddingOf(context);
|
|
final blockSystemBack = PlatformDetector.isTV() || InputModeTracker.shouldBlockSystemBack(context);
|
|
// Match the established detail-screen back policy: TV/keyboard back is
|
|
// owned by the focus tree, while native mobile back and iOS swipe-back
|
|
// remain route-driven. The overlay host always gets first refusal.
|
|
return OverlaySheetHost(
|
|
canPop: !blockSystemBack,
|
|
onSystemBack: _handleSystemBack,
|
|
child: Builder(
|
|
builder: (hostContext) => Focus(
|
|
onKeyEvent: (_, event) => handleBackKeyNavigation(hostContext, event),
|
|
child: Scaffold(
|
|
body: Stack(
|
|
children: [
|
|
SingleChildScrollView(
|
|
key: const Key('catalog_detail_scroll'),
|
|
controller: _scrollController,
|
|
// The backdrop lives inside the scrollable so it moves with
|
|
// the content (it extends under the status bar, so the safe
|
|
// areas are baked into the content padding instead of a
|
|
// SafeArea around the scroll view).
|
|
child: Stack(
|
|
children: [
|
|
if (item.backdropUrl != null)
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
height: 320,
|
|
child: ShaderMask(
|
|
shaderCallback: (rect) => LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.black, Colors.black.withValues(alpha: 0.0)],
|
|
stops: const [0.3, 1.0],
|
|
).createShader(rect),
|
|
blendMode: BlendMode.dstIn,
|
|
child: OptimizedMediaImage.thumb(
|
|
imagePath: item.backdropUrl,
|
|
width: double.infinity,
|
|
height: 320,
|
|
fit: BoxFit.cover,
|
|
fallbackIcon: null,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: EdgeInsets.fromLTRB(24, viewInsets.top + 120, 24, viewInsets.bottom + 32),
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 140, height: 210),
|
|
),
|
|
const SizedBox(width: 20),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
item.title,
|
|
style: theme.textTheme.headlineMedium,
|
|
maxLines: 3,
|
|
overflow: .ellipsis,
|
|
),
|
|
if (_metaLine.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
_metaLine,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
],
|
|
if (item.genres?.isNotEmpty ?? false) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
item.genres!.join(' • '),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
if (_watchlistSource != null || _requestSource != null)
|
|
FocusableActionBar(
|
|
key: _actionBarKey,
|
|
onNavigateDown: _focusSectionBelowActions,
|
|
actions: [
|
|
if (_watchlistSource != null)
|
|
FocusableAction(
|
|
icon: onWatchlist ?? false
|
|
? Symbols.bookmark_added_rounded
|
|
: Symbols.bookmark_add_rounded,
|
|
tooltip: onWatchlist ?? false
|
|
? t.explore.removeFromWatchlist
|
|
: t.explore.addToWatchlist,
|
|
onPressed: onWatchlist == null
|
|
? null
|
|
: () => unawaited(_toggleWatchlist()),
|
|
),
|
|
if (_requestSource case final SeerrCatalogSource seerr)
|
|
FocusableAction(
|
|
icon: Symbols.download_rounded,
|
|
tooltip: t.seerr.request,
|
|
onPressed: () => unawaited(
|
|
showSeerrRequestSheet(
|
|
hostContext,
|
|
source: seerr,
|
|
kind: item.kind,
|
|
tmdbId: item.ids.tmdb!,
|
|
title: item.title,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_buildStatsChips(theme) case final Widget chips) ...[const SizedBox(height: 20), chips],
|
|
const SizedBox(height: 24),
|
|
if (item.overview != null) Text(item.overview!, style: theme.textTheme.bodyLarge),
|
|
const SizedBox(height: 24),
|
|
_buildLibrarySection(theme),
|
|
if (_cast case final List<CatalogCastMember> cast when cast.isNotEmpty) ...[
|
|
const SizedBox(height: 28),
|
|
_buildCastSection(theme, cast),
|
|
],
|
|
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
|
const SizedBox(height: 20),
|
|
_buildRelatedSection(related),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Positioned(
|
|
top: 0,
|
|
left: 0,
|
|
child: DesktopAppBarHelper.buildAdjustedLeading(
|
|
AppBarBackButton(style: BackButtonStyle.circular, focusNode: _backButtonFocusNode),
|
|
context: hostContext,
|
|
)!,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|