fix(explore): size catalog detail relations, ratings and facts to their content

Four sections of the catalog detail screen spent more room than their data
justified.

Franchise relations drew one hub shelf per label. Real payloads make that
absurd: MAL returns twelve relations for Attack on Titan across six labels,
and "Side story" and "Sequel" each hold exactly one title, so each spent a
header, a scroll row and one card. Flatten the labelled groups into one
"Related titles" section of compact rows — poster thumb, label, title and year
— that flow into columns on wide viewports. D-pad moves through the grid by
index and still hands off to the cast strip above and the recommendations
shelf below, which keeps its shelf because taste-based recommendations are
meant to be browsed.

Drop the MAL picture gallery. It was a horizontal strip of unfocusable poster
variants of the title you are already looking at, and it cost a page-height of
scroll; the `pictures` field comes back out of the detail request with it.

Draw attributed scores behind their own brand mark where the source has one,
the way the media detail screen already does: Rotten Tomatoes fresh/rotten and
upright/spilled, IMDb and TMDB, each on the scale that source publishes.
Sources with no mark (critic, audience, tracker scores) keep their written
label. Plex's own badge state is derived from the 60% tomatometer threshold it
encodes in `image.rating.ripe`.

Flow the definition rows — original title, studios, country, budget, box
office, crew — into two or three columns once the window is wide enough.
A 1440-wide window drew a 140-pixel label, a short value and 1,000 pixels of
nothing per fact.

Verified against live MAL and Plex Discover payloads on macOS: the Attack on
Titan page drops from 4,082 to 2,115 logical pixels, Dune: Part Two from 1,282
to 1,154.
This commit is contained in:
edde746
2026-07-30 01:01:38 +02:00
parent 644c782edd
commit 53288116fe
32 changed files with 479 additions and 244 deletions
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "Favorited by ${who}",
"unairedEpisodes": "${n} not aired yet",
"recommendedByPercent": "Recommended by ${percent} of viewers",
"gallery": "Gallery",
"relatedTitles": "Related titles",
"background": "Background"
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1143,7 +1143,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1143,7 +1143,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1155,7 +1155,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1155,7 +1155,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+3 -3
View File
@@ -5445,8 +5445,8 @@ class Translations$explore$detail$en {
/// en: 'Recommended by ${percent} of viewers'
String recommendedByPercent({required Object percent}) => 'Recommended by ${percent} of viewers';
/// en: 'Gallery'
String get gallery => 'Gallery';
/// en: 'Related titles'
String get relatedTitles => 'Related titles';
/// en: 'Background'
String get background => 'Background';
@@ -6966,7 +6966,7 @@ extension on Translations {
'explore.detail.favoritedBy' => ({required Object who}) => 'Favorited by ${who}',
'explore.detail.unairedEpisodes' => ({required Object n}) => '${n} not aired yet',
'explore.detail.recommendedByPercent' => ({required Object percent}) => 'Recommended by ${percent} of viewers',
'explore.detail.gallery' => 'Gallery',
'explore.detail.relatedTitles' => 'Related titles',
'explore.detail.background' => 'Background',
'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ),
'explore.relation.prequel' => 'Prequel',
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1147,7 +1147,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1143,7 +1143,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
+1 -1
View File
@@ -1143,7 +1143,7 @@
"favoritedBy": "",
"unairedEpisodes": "",
"recommendedByPercent": "",
"gallery": "",
"relatedTitles": "",
"background": ""
},
"totalResults": {
-9
View File
@@ -318,11 +318,6 @@ class CatalogItem {
/// Provider search relevance. Ordering input only — never rendered.
final double? relevance;
/// Extra artwork the provider ships alongside the poster (MAL `pictures`).
/// Plain URLs: these providers publish no pixel widths, so they cannot go
/// through [posterVariants], whose keys must be real widths.
final List<String>? gallery;
/// Production/background prose (MAL `background`) — trivia about how the
/// title came to exist, distinct from the plot [overview].
final String? background;
@@ -387,7 +382,6 @@ class CatalogItem {
this.recommendationPercent,
this.playState,
this.relevance,
this.gallery,
this.background,
this.cast,
this.unairedEpisodeCount,
@@ -461,7 +455,6 @@ class CatalogItem {
recommendationCount: recommendationCount ?? detail.recommendationCount,
recommendationPercent: recommendationPercent ?? detail.recommendationPercent,
relevance: relevance ?? detail.relevance,
gallery: detail.gallery ?? gallery,
background: detail.background ?? background,
cast: detail.cast ?? cast,
recommenders: recommenders ?? detail.recommenders,
@@ -563,7 +556,6 @@ class CatalogItem {
if (recommendationPercent != null) 'recommendationPercent': recommendationPercent,
if (playState != null) 'playState': playState!.toJson(),
if (relevance != null) 'relevance': relevance,
if (gallery != null) 'gallery': gallery,
if (background != null) 'background': background,
if (cast != null) 'cast': [for (final c in cast!) c.toJson()],
};
@@ -634,7 +626,6 @@ class CatalogItem {
unairedEpisodeCount: json['unairedEpisodeCount'] as int?,
recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson),
relevance: (json['relevance'] as num?)?.toDouble(),
gallery: (json['gallery'] as List?)?.cast<String>(),
background: json['background'] as String?,
cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson),
);
-8
View File
@@ -283,7 +283,6 @@ class MalAnimeDetail {
final List<MalAnimeRecommendation> recommendations;
final List<MalAnimeRelation> relations;
final MalStatistics? statistics;
final List<MalPicture> pictures;
final String? background;
const MalAnimeDetail({
@@ -291,7 +290,6 @@ class MalAnimeDetail {
this.recommendations = const [],
this.relations = const [],
this.statistics,
this.pictures = const [],
this.background,
});
@@ -299,7 +297,6 @@ class MalAnimeDetail {
final recommendations = json['recommendations'];
final relations = json['related_anime'];
final statistics = json['statistics'];
final pictures = json['pictures'];
final rawBackground = json['background'];
final background = rawBackground is String ? rawBackground.trim() : null;
return MalAnimeDetail(
@@ -313,11 +310,6 @@ class MalAnimeDetail {
for (final raw in relations.take(relatedLimit)) ?MalAnimeRelation.fromEntry(raw),
],
statistics: statistics is Map ? MalStatistics.fromJson(statistics) : null,
pictures: [
if (pictures is List)
for (final raw in pictures)
if (raw is Map) MalPicture.fromJson(raw.cast<String, dynamic>()),
],
background: background == null || background.isEmpty ? null : background,
);
}
+205 -118
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:intl/intl.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
@@ -9,6 +10,7 @@ import 'package:url_launcher/url_launcher.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_wrapper.dart';
import '../focus/dpad_navigator.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
@@ -25,18 +27,19 @@ 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/catalog_navigation_helper.dart';
import '../utils/desktop_window_padding.dart';
import '../utils/formatters.dart';
import '../utils/country_codes.dart';
import '../utils/language_codes.dart';
import '../utils/media_navigation_helper.dart';
import '../utils/platform_detector.dart';
import '../utils/rating_utils.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/horizontal_scroll_with_arrows.dart';
import '../widgets/focusable_list_tile.dart';
import '../widgets/hub_section.dart';
import '../widgets/optimized_media_image.dart';
@@ -68,7 +71,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
final ScrollController _scrollController = ScrollController();
final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags');
List<FocusNode> _linkFocusNodes = const [];
List<GlobalKey<HubSectionState>> _relationSectionKeys = const [];
List<FocusNode> _relationFocusNodes = const [];
List<CatalogTag> _orderedTags = const [];
List<CatalogLink> _streamingLinks = const [];
List<CatalogLink> _otherLinks = const [];
@@ -91,8 +94,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
/// row only renders once loaded non-empty).
List<CatalogItem>? _related;
/// Labelled franchise edges, kept separate from taste-based recommendations.
List<CatalogRelation> _relations = const [];
/// Labelled franchise edges, flattened to one entry per title. Providers
/// routinely return a single sequel or spin-off per label, and a shelf per
/// label spent a whole hub row — header, scroll row, one card — on it.
List<({CatalogRelationType type, CatalogItem item})> _relationEntries = const [];
@override
void initState() {
@@ -132,6 +137,9 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
for (final node in _libraryMatchFocusNodes) {
node.dispose();
}
for (final node in _relationFocusNodes) {
node.dispose();
}
_scrollController.dispose();
super.dispose();
}
@@ -201,23 +209,32 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
try {
final detail = await source.fetchDetail(widget.item);
if (!mounted) return;
final relations = [
final relationEntries = [
for (final relation in detail.relations)
if (relation.items.isNotEmpty) relation,
for (final item in relation.items) (type: relation.type, item: item),
];
_syncDetailCollections(detail.item);
_replaceRelationFocusNodes(relationEntries.length);
setState(() {
_detailItem = detail.item;
_cast = detail.cast;
_related = detail.related;
_relations = relations;
_relationSectionKeys = [for (var index = 0; index < relations.length; index++) GlobalKey<HubSectionState>()];
_relationEntries = relationEntries;
});
} catch (e) {
appLogger.d('Catalog detail load failed for ${widget.item.identityKey}', error: e);
}
}
void _replaceRelationFocusNodes(int count) {
for (final node in _relationFocusNodes) {
node.dispose();
}
_relationFocusNodes = [
for (var index = 0; index < count; index++) FocusNode(debugLabel: 'catalog_relation_$index'),
];
}
bool get _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false;
bool get _hasActions => _watchlistSource != null || _requestSource != null || _hasTrailer;
@@ -230,7 +247,13 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
bool get _hasDetailActions => _hasSpoilerReveal || _linkFocusNodes.isNotEmpty;
bool get _hasHubRows => _relations.isNotEmpty || (_related?.isNotEmpty ?? false);
bool get _hasCast => _cast?.isNotEmpty ?? false;
bool get _hasRelations => _relationEntries.isNotEmpty;
bool get _hasRelated => _related?.isNotEmpty ?? false;
bool get _hasSectionsBelowCast => _hasRelations || _hasRelated;
void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) {
final focusContext = node?.context;
@@ -299,16 +322,18 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
void _requestRelationFocus(int index) {
if (index < 0 || index >= _relationSectionKeys.length) return;
_relationSectionKeys[index].currentState?.requestFocusFromMemory();
if (index < 0 || index >= _relationFocusNodes.length) return;
final node = _relationFocusNodes[index];
node.requestFocus();
_revealFocusNode(node);
}
void _requestRelatedFocus() {
_relatedSectionKey.currentState?.requestFocusFromMemory();
}
void _requestFirstHubFocus() {
if (_relations.isNotEmpty) {
void _focusSectionBelowCast() {
if (_hasRelations) {
_requestRelationFocus(0);
} else {
_requestRelatedFocus();
@@ -316,12 +341,12 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
bool _focusSectionBelowLibraryMatches() {
if (_cast?.isNotEmpty ?? false) {
if (_hasCast) {
_requestCastFocus();
return true;
}
if (_hasHubRows) {
_requestFirstHubFocus();
if (_hasSectionsBelowCast) {
_focusSectionBelowCast();
return true;
}
return false;
@@ -330,10 +355,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
void _focusSectionBelowDetailActions() {
if (_hasLibraryMatches) {
_requestLibraryMatchFocus(0);
} else if (_cast?.isNotEmpty ?? false) {
} else if (_hasCast) {
_requestCastFocus();
} else {
_requestFirstHubFocus();
} else if (_hasSectionsBelowCast) {
_focusSectionBelowCast();
}
}
@@ -363,23 +388,19 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
}
}
void _focusSectionAboveFirstHub() {
if (_cast?.isNotEmpty ?? false) {
void _focusSectionAboveRelations() {
if (_hasCast) {
_requestCastFocus();
} else if (_hasLibraryMatches) {
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
} else if (_hasDetailActions) {
_requestLastDetailActionFocus();
} else {
_requestActionBarFocus();
_focusSectionAboveCast();
}
}
void _focusSectionAboveRelated() {
if (_relations.isNotEmpty) {
_requestRelationFocus(_relations.length - 1);
if (_hasRelations) {
_requestRelationFocus(_relationEntries.length - 1);
} else {
_focusSectionAboveFirstHub();
_focusSectionAboveRelations();
}
}
@@ -750,17 +771,29 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
return Wrap(spacing: 8, runSpacing: 8, children: chips);
}
/// Attributed scores, each behind its own brand badge where the source has
/// one — the same Rotten Tomatoes/IMDb/TMDB marks the media detail screen
/// draws. Sources without a mark (`critic`, `audience`, tracker scores)
/// keep their written label.
Widget? _buildRatingsSection(ThemeData theme) {
final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
final chips = <Widget>[];
for (final rating in _item.ratings ?? const <CatalogRatingSource>[]) {
final source = _ratingSourceLabel(rating.source);
if (source == null) continue;
var label = '$source ${rating.value.toStringAsFixed(1)}';
final badge = catalogRatingInfo(rating.source, rating.value);
if (source == null && badge == null) continue;
var label = badge == null ? '$source ${rating.value.toStringAsFixed(1)}' : badge.formattedValue;
if (rating.votes case final votes?) {
label = '$label (${t.explore.stats.votes(n: compact.format(votes))})';
}
chips.add(StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: label));
chips.add(
badge == null
? StatChip(icon: Symbols.star_rounded, iconColor: Colors.amber, label: label)
: StatChip(
leading: SvgPicture.asset(badge.assetPath, width: 14, height: 14, semanticsLabel: source),
label: label,
),
);
}
if (chips.isEmpty) return null;
return Column(
@@ -833,6 +866,45 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
);
}
/// Gap between definition-grid columns, and between relation tiles.
static const double _factColumnSpacing = 24;
static const double _relationTileSpacing = 12;
/// Definition rows flow into columns once there is room for them: budget,
/// box office and the rest are short values, and one pair per line leaves
/// most of a desktop window empty.
Widget _buildFactGrid(ThemeData theme, List<({String label, String value})> facts) {
return LayoutBuilder(
builder: (context, constraints) {
final columns = _gridColumns(constraints.maxWidth, facts.length);
if (columns < 2) {
return Column(
crossAxisAlignment: .start,
children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)],
);
}
final columnWidth = (constraints.maxWidth - _factColumnSpacing * (columns - 1)) / columns;
return Wrap(
spacing: _factColumnSpacing,
children: [
for (final fact in facts) SizedBox(width: columnWidth, child: _buildFactRow(theme, fact.label, fact.value)),
],
);
},
);
}
/// Columns that fit [width], never more than there are entries: two facts
/// on a wide window stay two columns instead of stretching to three.
static int _gridColumns(double width, int count) {
final fits = width >= 1100
? 3
: width >= 700
? 2
: 1;
return fits < count ? fits : count;
}
Widget? _buildFactsSection(ThemeData theme) {
final item = _item;
final locale = LocaleSettings.currentLocale.intlLocaleName;
@@ -858,10 +930,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
if (item.revenue case final revenue?) add(t.explore.detail.revenue, currency.format(revenue));
add(t.explore.detail.contentAdvisory, item.contentAdvisory);
if (facts.isEmpty) return null;
return Column(
crossAxisAlignment: .start,
children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)],
);
return _buildFactGrid(theme, facts);
}
Widget? _buildRecommendersSection(ThemeData theme) {
@@ -890,10 +959,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
Widget? _buildCrewSection(ThemeData theme) {
final credits = _item.credits;
if (credits == null || credits.isEmpty) return null;
final rows = <Widget>[];
final rows = <({String label, String value})>[];
for (final role in CatalogCreditRole.values) {
final names = _joinValues(credits.where((credit) => credit.role == role).map((credit) => credit.name));
if (names != null) rows.add(_buildFactRow(theme, _creditRoleLabel(role), names));
if (names != null) rows.add((label: _creditRoleLabel(role), value: names));
}
if (rows.isEmpty) return null;
return Column(
@@ -901,7 +970,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
children: [
Text(t.explore.detail.crew, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
...rows,
_buildFactGrid(theme, rows),
],
);
}
@@ -1001,42 +1070,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
);
}
Widget _buildGallerySection(ThemeData theme, List<String> gallery) {
final cardWidth = CastMemberStrip.responsiveCardWidth(context);
final imageHeight = cardWidth * 1.5;
return Column(
crossAxisAlignment: .start,
children: [
Text(t.explore.detail.gallery, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
ExcludeFocus(
child: SizedBox(
height: imageHeight + 10,
child: HorizontalScrollWithArrows(
builder: (scrollController) => ListView.builder(
key: const Key('catalog_detail_gallery'),
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: const EdgeInsets.symmetric(vertical: 5),
itemCount: gallery.length,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(right: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: OptimizedMediaImage.poster(imagePath: gallery[index], width: cardWidth, height: imageHeight),
),
),
),
),
),
),
],
);
}
/// 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.
@@ -1058,48 +1091,109 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl),
],
onNavigateUp: _hasLibraryMatches || _hasDetailActions || _hasActions ? _focusSectionAboveCast : null,
onNavigateDown: _hasHubRows ? _requestFirstHubFocus : null,
onNavigateDown: _hasSectionsBelowCast ? _focusSectionBelowCast : null,
debugLabel: 'catalog_cast_row',
),
],
);
}
Widget _buildRelationSection(CatalogRelation relation, int index) {
return HubSection(
key: _relationSectionKeys[index],
hub: MediaHub(
id: 'catalog-relation:${_item.source.name}:${_item.identityKey}:${relation.type.name}:$index',
identifier: 'explore.relation.${relation.type.name}',
title: _relationLabel(relation.type),
type: 'mixed',
items: [for (final item in relation.items) item.toMediaItem()],
size: relation.items.length,
),
focusMemory: _hubFocusMemory,
icon: Symbols.link_rounded,
inset: true,
onNavigateUp: index == 0 ? _focusSectionAboveFirstHub : () => _requestRelationFocus(index - 1),
onVerticalNavigation: (isUp) {
if (isUp) {
if (index == 0) {
_focusSectionAboveFirstHub();
} else {
_requestRelationFocus(index - 1);
}
return true;
}
if (index + 1 < _relations.length) {
_requestRelationFocus(index + 1);
return true;
}
if (_related?.isNotEmpty ?? false) {
_requestRelatedFocus();
return true;
}
return false;
/// Labelled franchise edges as compact rows rather than one hub per label:
/// a provider that returns a single sequel used to spend an entire shelf on
/// it. Rows flow into columns on wide viewports, like the facts above.
Widget _buildRelationsSection(ThemeData theme) {
return LayoutBuilder(
builder: (context, constraints) {
final count = _relationEntries.length;
final columns = _gridColumns(constraints.maxWidth, count);
final tileWidth = (constraints.maxWidth - _relationTileSpacing * (columns - 1)) / columns;
return Column(
crossAxisAlignment: .start,
children: [
Text(t.explore.detail.relatedTitles, style: theme.textTheme.titleMedium),
const SizedBox(height: 8),
Wrap(
spacing: _relationTileSpacing,
runSpacing: _relationTileSpacing,
children: [
for (var index = 0; index < count; index++)
SizedBox(
width: tileWidth,
child: _buildRelationTile(theme, index, columns: columns, count: count),
),
],
),
],
);
},
cardSizing: HubCardSizing.grid,
);
}
Widget _buildRelationTile(ThemeData theme, int index, {required int columns, required int count}) {
final entry = _relationEntries[index];
final item = entry.item;
final label = _relationLabel(entry.type);
final year = item.year;
void open() => unawaited(navigateToCatalogItem(context, item));
return FocusableWrapper(
focusNode: _relationFocusNodes[index],
borderRadius: 12,
semanticLabel: '$label: ${item.title}',
onSelect: open,
onNavigateUp: index >= columns ? () => _requestRelationFocus(index - columns) : _focusSectionAboveRelations,
onNavigateDown: index + columns < count
? () => _requestRelationFocus(index + columns)
: _hasRelated
? _requestRelatedFocus
: null,
onNavigateLeft: index % columns == 0 ? null : () => _requestRelationFocus(index - 1),
onNavigateRight: (index + 1) % columns == 0 || index + 1 >= count ? null : () => _requestRelationFocus(index + 1),
child: GestureDetector(
onTap: open,
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: OptimizedMediaImage.poster(imagePath: item.posterUrl, width: 40, height: 60),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 2),
Text(
year == null ? item.title : '${item.title}$year',
style: theme.textTheme.bodyMedium,
maxLines: 2,
overflow: .ellipsis,
),
],
),
),
AppIcon(
Symbols.chevron_right_rounded,
fill: 1,
size: 18,
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
],
),
),
),
);
}
@@ -1284,10 +1378,6 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
const SizedBox(height: 24),
_buildBackgroundSection(theme, background),
],
if (item.gallery case final List<String> gallery when gallery.isNotEmpty) ...[
const SizedBox(height: 24),
_buildGallerySection(theme, gallery),
],
if (_buildFactsSection(theme) case final Widget facts) ...[
const SizedBox(height: 24),
facts,
@@ -1320,10 +1410,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
const SizedBox(height: 28),
_buildCastSection(theme, cast),
],
for (var index = 0; index < _relations.length; index++) ...[
const SizedBox(height: 20),
_buildRelationSection(_relations[index], index),
],
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
const SizedBox(height: 20),
_buildRelatedSection(related),
+1 -19
View File
@@ -131,14 +131,7 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
.add(relationItems[i]);
}
final detailPosterUrl = detail.anime.mainPicture?.primary ?? item.posterUrl;
final detailItem = _toCatalogItem(
detail.anime,
null,
statistics: detail.statistics,
gallery: _galleryFor(detail.pictures, detailPosterUrl),
background: detail.background,
);
final detailItem = _toCatalogItem(detail.anime, null, statistics: detail.statistics, background: detail.background);
return CatalogDetail(
item: item.enrichedWith(detailItem),
cast: cast,
@@ -193,7 +186,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
CatalogRankScope? rankingScope,
int? recommendationCount,
MalStatistics? statistics,
List<String>? gallery,
String? background,
}) {
final title = anime.displayTitle;
@@ -247,7 +239,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
sourceMaterial: _sourceMaterialFor(anime.source),
isAdult: _isAdultFor(anime.nsfw),
recommendationCount: recommendationCount,
gallery: gallery,
background: background,
);
}
@@ -358,15 +349,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
static DateTime? _dateFor(String? value) => value == null ? null : DateTime.tryParse(value);
static List<String>? _galleryFor(List<MalPicture> pictures, String? posterUrl) {
final urls = <String>{};
for (final picture in pictures) {
final url = picture.primary;
if (url != null && url != posterUrl) urls.add(url);
}
return urls.isEmpty ? null : urls.toList(growable: false);
}
static CatalogRelationType _relationTypeFor(String? type) => switch (type) {
'prequel' => CatalogRelationType.prequel,
'sequel' => CatalogRelationType.sequel,
+3 -2
View File
@@ -91,10 +91,11 @@ class MalClient implements DisposableTrackerClient {
'nsfw,source,end_date';
/// The existing detail request widened by +22.3% in the measured sample
/// (18,624 -> 22,772 bytes), still with no additional round trip.
/// (18,624 -> 22,772 bytes) — less since the unused `pictures` gallery came
/// back out — still with no additional round trip.
static const String detailFields =
'$catalogFields,recommendations%7B$catalogFields%7D,'
'related_anime%7B$catalogFields%7D,statistics,pictures,background';
'related_anime%7B$catalogFields%7D,statistics,background';
static const String _characterFields = 'role,main_picture,first_name,last_name';
+43 -9
View File
@@ -1,5 +1,6 @@
/// Parses Plex ratingImage / audienceRatingImage URIs and returns
/// the corresponding local asset path and a display-formatted value.
/// Resolves the brand badge shown beside a score: Plex's
/// ratingImage/audienceRatingImage URIs, and the attributed source keys the
/// Explore catalog carries instead of them.
library;
class RatingInfo {
@@ -18,22 +19,21 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) {
if (imageUri.startsWith('rottentomatoes://image.rating.')) {
final suffix = imageUri.substring('rottentomatoes://image.rating.'.length);
final percent = '${(value * 10).toStringAsFixed(0)}%';
return switch (suffix) {
'ripe' => RatingInfo('assets/rating_icons/rt_fresh.svg', percent),
'rotten' => RatingInfo('assets/rating_icons/rt_rotten.svg', percent),
'upright' => RatingInfo('assets/rating_icons/rt_upright.svg', percent),
'spilled' => RatingInfo('assets/rating_icons/rt_spilled.svg', percent),
'ripe' => RatingInfo(_rtFreshAsset, _percent(value)),
'rotten' => RatingInfo(_rtRottenAsset, _percent(value)),
'upright' => RatingInfo(_rtUprightAsset, _percent(value)),
'spilled' => RatingInfo(_rtSpilledAsset, _percent(value)),
_ => null,
};
}
if (imageUri.startsWith('imdb://')) {
return RatingInfo('assets/rating_icons/imdb.svg', value.toStringAsFixed(1));
return RatingInfo(_imdbAsset, value.toStringAsFixed(1));
}
if (imageUri.startsWith('themoviedb://')) {
return RatingInfo('assets/rating_icons/tmdb.svg', '${(value * 10).toStringAsFixed(0)}%');
return RatingInfo(_tmdbAsset, _percent(value));
}
return null;
@@ -41,3 +41,37 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) {
/// Whether the URI is a Rotten Tomatoes rating source.
bool isRottenTomatoes(String? imageUri) => imageUri != null && imageUri.startsWith('rottentomatoes://');
/// The same badge for a [CatalogRatingSource]-style source key.
///
/// Catalog providers attribute their scores by name (`imdb`, `tmdb`,
/// `rottenTomatoesCritic`, …) and publish no badge URI, so the icon is chosen
/// from the key. Rotten Tomatoes picks fresh/rotten and upright/spilled by the
/// 60% threshold the tomatometer itself uses — the same state Plex encodes in
/// `image.rating.ripe` / `.rotten`.
///
/// Returns null for keys with no brand badge (`critic`, `audience`, `simkl`,
/// `mal`, `anilist`, `trakt`); those stay labelled with their source name.
RatingInfo? catalogRatingInfo(String source, double value) => switch (source) {
'imdb' => RatingInfo(_imdbAsset, value.toStringAsFixed(1)),
'tmdb' => RatingInfo(_tmdbAsset, _percent(value)),
'rottenTomatoes' ||
'rottenTomatoesCritic' => RatingInfo(value >= _rottenTomatoesFresh ? _rtFreshAsset : _rtRottenAsset, _percent(value)),
'rottenTomatoesAudience' => RatingInfo(
value >= _rottenTomatoesFresh ? _rtUprightAsset : _rtSpilledAsset,
_percent(value),
),
_ => null,
};
const String _imdbAsset = 'assets/rating_icons/imdb.svg';
const String _tmdbAsset = 'assets/rating_icons/tmdb.svg';
const String _rtFreshAsset = 'assets/rating_icons/rt_fresh.svg';
const String _rtRottenAsset = 'assets/rating_icons/rt_rotten.svg';
const String _rtUprightAsset = 'assets/rating_icons/rt_upright.svg';
const String _rtSpilledAsset = 'assets/rating_icons/rt_spilled.svg';
/// Ratings are normalized to a 0-10 scale; Rotten Tomatoes is a percentage.
const double _rottenTomatoesFresh = 6.0;
String _percent(double value) => '${(value * 10).toStringAsFixed(0)}%';
+12 -2
View File
@@ -9,12 +9,16 @@ class StatChip extends StatelessWidget {
final Color? iconColor;
final String label;
/// Leading artwork for chips whose source has a brand mark (rating badges).
/// Takes precedence over [icon].
final Widget? leading;
/// Overrides the default fill. Needed where the chip sits on a surface that
/// already uses `surfaceContainerHigh` — the mono theme collapses several
/// container roles onto one colour, so the default would be invisible.
final Color? backgroundColor;
const StatChip({super.key, this.icon, this.iconColor, required this.label, this.backgroundColor});
const StatChip({super.key, this.icon, this.iconColor, required this.label, this.leading, this.backgroundColor});
@override
Widget build(BuildContext context) {
@@ -28,7 +32,13 @@ class StatChip extends StatelessWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[AppIcon(icon!, size: 14, fill: 1, color: iconColor), const SizedBox(width: 4)],
if (leading case final leading?) ...[
leading,
const SizedBox(width: 4),
] else if (icon != null) ...[
AppIcon(icon!, size: 14, fill: 1, color: iconColor),
const SizedBox(width: 4),
],
Text(label, style: theme.textTheme.labelMedium),
],
),
+187 -23
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/date_symbol_data_local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_kind.dart';
@@ -20,6 +21,7 @@ import 'package:plezy/services/settings_service.dart';
import 'package:plezy/theme/mono_theme.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/hub_section.dart';
import 'package:plezy/widgets/media_card.dart';
import 'package:plezy/widgets/optimized_media_image.dart';
import 'package:provider/provider.dart';
@@ -267,7 +269,7 @@ void main() {
expect(find.text(t.explore.detail.revealSpoilerTags), findsNothing);
});
testWidgets('ratings row labels every supported score source and its vote count', (tester) async {
testWidgets('ratings row labels every score source without a brand mark', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
@@ -275,7 +277,6 @@ void main() {
ids: CatalogItemIds(tmdb: 11),
ratings: [
CatalogRatingSource(source: 'simkl', value: 8.1, votes: 11),
CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12),
CatalogRatingSource(source: 'mal', value: 8.3, votes: 13),
CatalogRatingSource(source: 'critic', value: 7.2, votes: 14),
CatalogRatingSource(source: 'audience', value: 8.8, votes: 15),
@@ -287,12 +288,50 @@ void main() {
expect(find.text(t.explore.detail.ratings), findsOneWidget);
expect(find.text('Simkl 8.1 (11 votes)'), findsOneWidget);
expect(find.text('IMDb 7.9 (12 votes)'), findsOneWidget);
expect(find.text('MyAnimeList 8.3 (13 votes)'), findsOneWidget);
expect(find.text('Critics 7.2 (14 votes)'), findsOneWidget);
expect(find.text('Audience 8.8 (15 votes)'), findsOneWidget);
});
testWidgets('scores whose source owns a logo render the mark and that source scale', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Attributed Movie',
ids: CatalogItemIds(tmdb: 21),
ratings: [
CatalogRatingSource(source: 'rottenTomatoesCritic', value: 8.4),
CatalogRatingSource(source: 'rottenTomatoesAudience', value: 4.1),
CatalogRatingSource(source: 'imdb', value: 7.9, votes: 12),
CatalogRatingSource(source: 'tmdb', value: 7.5),
],
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
expect(
tester
.widgetList<SvgPicture>(find.byType(SvgPicture))
.map((picture) => picture.bytesLoader)
.whereType<SvgAssetLoader>()
.map((loader) => loader.assetName),
containsAll(const [
'assets/rating_icons/rt_fresh.svg',
'assets/rating_icons/rt_spilled.svg',
'assets/rating_icons/imdb.svg',
'assets/rating_icons/tmdb.svg',
]),
);
// The mark carries the attribution, so the chip keeps only the score, on
// the scale that source publishes.
expect(find.text('84%'), findsOneWidget);
expect(find.text('41%'), findsOneWidget);
expect(find.text('7.9 (12 votes)'), findsOneWidget);
expect(find.text('75%'), findsOneWidget);
expect(find.text('${t.explore.ratingSource.rottenTomatoesCritic} 8.4'), findsNothing);
});
testWidgets('seasonal rank keeps its season window instead of claiming all-time rank', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
@@ -371,17 +410,12 @@ void main() {
expect(find.byTooltip(t.explore.detail.watchTrailer), findsOneWidget);
});
testWidgets('gallery and background sections render when populated', (tester) async {
const galleryUrls = [
'https://cdn.myanimelist.net/images/anime/gallery-1.jpg',
'https://cdn.myanimelist.net/images/anime/gallery-2.jpg',
];
testWidgets('background prose renders as its own section', (tester) async {
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Production Movie',
ids: CatalogItemIds(tmdb: 15),
gallery: galleryUrls,
background: 'Filmed over three winters.',
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
@@ -390,14 +424,36 @@ void main() {
expect(find.text(t.explore.detail.background), findsOneWidget);
expect(find.text('Filmed over three winters.'), findsOneWidget);
expect(find.text(t.explore.detail.gallery), findsOneWidget);
final galleryFinder = find.byKey(const Key('catalog_detail_gallery'));
expect(galleryFinder, findsOneWidget);
expect(tester.widget<ListView>(galleryFinder).scrollDirection, Axis.horizontal);
final images = tester
.widgetList<OptimizedMediaImage>(find.descendant(of: galleryFinder, matching: find.byType(OptimizedMediaImage)))
.map((image) => image.imagePath);
expect(images, galleryUrls);
});
testWidgets('budget and box office pair into columns on a wide window and stack on a narrow one', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1400, 900);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
const item = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Expensive Movie',
ids: CatalogItemIds(tmdb: 22),
budget: 165000000,
revenue: 675000000,
);
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
await _pumpDetail(tester, source, item: item);
final budget = find.text(t.explore.detail.budget);
final revenue = find.text(t.explore.detail.revenue);
expect(tester.getTopLeft(revenue).dy, tester.getTopLeft(budget).dy);
expect(tester.getTopLeft(revenue).dx, greaterThan(tester.getTopLeft(budget).dx));
tester.view.physicalSize = const Size(420, 900);
await tester.pumpAndSettle();
expect(tester.getTopLeft(revenue).dy, greaterThan(tester.getTopLeft(budget).dy));
expect(tester.getTopLeft(revenue).dx, tester.getTopLeft(budget).dx);
});
testWidgets('all-null metadata renders without an empty optional section header', (tester) async {
@@ -420,17 +476,25 @@ void main() {
expect(find.text(t.explore.detail.watchOn), findsNothing);
expect(find.text(t.explore.cast), findsNothing);
expect(find.text(t.discover.moreLikeThis), findsNothing);
expect(find.text(t.explore.detail.gallery), findsNothing);
expect(find.text(t.explore.detail.relatedTitles), findsNothing);
expect(find.text(t.explore.detail.background), findsNothing);
});
testWidgets('franchise relations keep their labelled shelf separate from recommendations', (tester) async {
const relationItem = CatalogItem(
testWidgets('single-entry relations share one labelled section instead of a shelf each', (tester) async {
const sequel = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'The Sequel',
year: 2019,
posterUrl: 'https://example.com/sequel.jpg',
ids: CatalogItemIds(tmdb: 17),
);
const spinOff = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'The Spin-off',
ids: CatalogItemIds(tmdb: 20),
);
const recommendation = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
@@ -442,17 +506,117 @@ void main() {
item: _item,
related: [recommendation],
relations: [
CatalogRelation(type: CatalogRelationType.sequel, items: [relationItem]),
CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]),
CatalogRelation(type: CatalogRelationType.spinOff, items: [spinOff]),
],
),
);
await _pumpDetail(tester, source);
expect(find.text(t.explore.relation.sequel), findsOneWidget);
// Recommendations keep their shelf; two one-title relations do not get one
// each.
expect(find.byType(HubSection), findsOneWidget);
expect(find.text(t.discover.moreLikeThis), findsOneWidget);
expect(find.text('The Sequel'), findsOneWidget);
expect(find.text('A Similar Movie'), findsOneWidget);
expect(find.text(t.explore.detail.relatedTitles), findsOneWidget);
expect(find.text(t.explore.relation.sequel), findsOneWidget);
expect(find.text(t.explore.relation.spinOff), findsOneWidget);
expect(find.text('The Sequel • 2019'), findsOneWidget);
expect(find.text('The Spin-off'), findsOneWidget);
expect(
tester.widgetList<OptimizedMediaImage>(find.byType(OptimizedMediaImage)).map((image) => image.imagePath),
contains('https://example.com/sequel.jpg'),
);
});
testWidgets('a relation row opens the catalog detail screen of that title', (tester) async {
const sequel = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'The Sequel',
ids: CatalogItemIds(tmdb: 17),
);
final source = _FakeCatalogSource(
detail: const CatalogDetail(
item: _item,
relations: [
CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]),
],
),
);
await _pumpDetail(tester, source);
await tester.tap(find.text('The Sequel'));
await tester.pumpAndSettle();
expect(find.byType(CatalogItemDetailScreen, skipOffstage: false), findsNWidgets(2));
expect(find.text('The Sequel'), findsOneWidget);
});
testWidgets('D-pad walks the relation rows between the cast strip and recommendations', (tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
const prequel = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'The Prequel',
ids: CatalogItemIds(tmdb: 16),
);
const sequel = CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'The Sequel',
ids: CatalogItemIds(tmdb: 17),
);
final source = _FakeCatalogSource(
detail: const CatalogDetail(
item: _item,
cast: [CatalogCastMember(name: 'First Actor', secondary: 'Lead')],
related: [
CatalogItem(
source: CatalogSourceId.trakt,
kind: MediaKind.movie,
title: 'Related Movie',
ids: CatalogItemIds(tmdb: 2),
),
],
relations: [
CatalogRelation(type: CatalogRelationType.prequel, items: [prequel]),
CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]),
],
),
);
await _pumpDetail(tester, source);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_relation_0');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_relation_1');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, startsWith('hub_catalog-related:'));
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_relation_1');
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'catalog_cast_row');
});
testWidgets('social recommendation keeps its person, reason, and note', (tester) async {
@@ -90,7 +90,7 @@ const _expectedCatalogFields =
'nsfw,source,end_date';
const _expectedDetailFields =
'$_expectedCatalogFields,recommendations{$_expectedCatalogFields},'
'related_anime{$_expectedCatalogFields},statistics,pictures,background';
'related_anime{$_expectedCatalogFields},statistics,background';
void main() {
// Attack on Titan: split-cour show — one Fribb row per season, same tvdb id.
@@ -456,19 +456,6 @@ void main() {
'plan_to_watch': '350000',
},
},
'pictures': [
{
'medium': 'https://cdn.myanimelist.net/images/anime/16498-poster-medium.jpg',
'large': 'https://cdn.myanimelist.net/images/anime/16498.jpg',
},
{
'medium': 'https://cdn.myanimelist.net/images/anime/16498-gallery-medium.jpg',
'large': 'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg',
},
{'medium': 'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg'},
{'medium': 'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg', 'large': ''},
{'medium': '', 'large': ''},
],
'background': 'Created from the original manga.',
}),
200,
@@ -504,11 +491,6 @@ void main() {
expect(detail.item.audience?.onHold, 40000);
expect(detail.item.audience?.dropped, 90000);
expect(detail.item.audience?.planning, 350000);
expect(detail.item.gallery, [
'https://cdn.myanimelist.net/images/anime/16498-gallery-large.jpg',
'https://cdn.myanimelist.net/images/anime/16498-medium-only.jpg',
'https://cdn.myanimelist.net/images/anime/16498-fallback-medium.jpg',
]);
expect(detail.item.background, 'Created from the original manga.');
expect(detail.item.posterVariants, isNull);
@@ -530,20 +512,13 @@ void main() {
expect(detail.relations.single.items.single.title, 'Shingeki no Kyojin Season 3');
});
test('fetchDetail normalizes a blank background and pictures without URLs to null', () async {
test('fetchDetail normalizes a blank background to null', () async {
http.Response respond(http.Request request) {
if (request.url.path.endsWith('/characters')) {
return http.Response(json.encode({'data': <Object>[], 'paging': <String, dynamic>{}}), 200);
}
return http.Response(
json.encode({
..._node(id: 16498, title: 'Shingeki no Kyojin'),
'pictures': [
<String, dynamic>{},
{'medium': ' ', 'large': ''},
],
'background': ' \n\t ',
}),
json.encode({..._node(id: 16498, title: 'Shingeki no Kyojin'), 'background': ' \n\t '}),
200,
);
}
@@ -560,7 +535,6 @@ void main() {
final detail = await source.fetchDetail(item);
expect(detail.item.gallery, isNull);
expect(detail.item.background, isNull);
expect(detail.item.posterVariants, isNull);
});