From 53288116feb5fc55fd7f510165a79edb786b6911 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:30:15 +0200 Subject: [PATCH] fix(explore): size catalog detail relations, ratings and facts to their content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/i18n/az.i18n.json | 2 +- lib/i18n/bg.i18n.json | 2 +- lib/i18n/da.i18n.json | 2 +- lib/i18n/de.i18n.json | 2 +- lib/i18n/en.i18n.json | 2 +- lib/i18n/es.i18n.json | 2 +- lib/i18n/fr.i18n.json | 2 +- lib/i18n/hu.i18n.json | 2 +- lib/i18n/it.i18n.json | 2 +- lib/i18n/ja.i18n.json | 2 +- lib/i18n/kk.i18n.json | 2 +- lib/i18n/ko.i18n.json | 2 +- lib/i18n/nb.i18n.json | 2 +- lib/i18n/nl.i18n.json | 2 +- lib/i18n/pl.i18n.json | 2 +- lib/i18n/pt.i18n.json | 2 +- lib/i18n/ru.i18n.json | 2 +- lib/i18n/strings_en.g.dart | 6 +- lib/i18n/sv.i18n.json | 2 +- lib/i18n/tr.i18n.json | 2 +- lib/i18n/uz.i18n.json | 2 +- lib/i18n/zh-Hant.i18n.json | 2 +- lib/i18n/zh.i18n.json | 2 +- lib/models/catalog/catalog_item.dart | 9 - lib/models/mal/mal_anime.dart | 8 - lib/screens/catalog_item_detail_screen.dart | 323 +++++++++++------- lib/services/catalog/mal_catalog_source.dart | 20 +- lib/services/trackers/mal/mal_client.dart | 5 +- lib/utils/rating_utils.dart | 52 ++- lib/widgets/stat_chip.dart | 14 +- .../catalog_item_detail_screen_test.dart | 210 ++++++++++-- .../catalog/mal_catalog_source_test.dart | 32 +- 32 files changed, 479 insertions(+), 244 deletions(-) diff --git a/lib/i18n/az.i18n.json b/lib/i18n/az.i18n.json index 3ce92a4b..61e5724b 100644 --- a/lib/i18n/az.i18n.json +++ b/lib/i18n/az.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/bg.i18n.json b/lib/i18n/bg.i18n.json index 2fb907fc..ed1aa3be 100644 --- a/lib/i18n/bg.i18n.json +++ b/lib/i18n/bg.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/da.i18n.json b/lib/i18n/da.i18n.json index fe3cb049..f8a26614 100644 --- a/lib/i18n/da.i18n.json +++ b/lib/i18n/da.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/de.i18n.json b/lib/i18n/de.i18n.json index 9048acf3..d66218ed 100644 --- a/lib/i18n/de.i18n.json +++ b/lib/i18n/de.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/en.i18n.json b/lib/i18n/en.i18n.json index 0307252c..203a2100 100644 --- a/lib/i18n/en.i18n.json +++ b/lib/i18n/en.i18n.json @@ -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": { diff --git a/lib/i18n/es.i18n.json b/lib/i18n/es.i18n.json index 30cb4ddf..3961dfcf 100644 --- a/lib/i18n/es.i18n.json +++ b/lib/i18n/es.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/fr.i18n.json b/lib/i18n/fr.i18n.json index 9b2dd53e..8bb2aabf 100644 --- a/lib/i18n/fr.i18n.json +++ b/lib/i18n/fr.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/hu.i18n.json b/lib/i18n/hu.i18n.json index e86a0131..272ea514 100644 --- a/lib/i18n/hu.i18n.json +++ b/lib/i18n/hu.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/it.i18n.json b/lib/i18n/it.i18n.json index 2bbddb10..e81cd9f4 100644 --- a/lib/i18n/it.i18n.json +++ b/lib/i18n/it.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/ja.i18n.json b/lib/i18n/ja.i18n.json index 6c409a90..5177255a 100644 --- a/lib/i18n/ja.i18n.json +++ b/lib/i18n/ja.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/kk.i18n.json b/lib/i18n/kk.i18n.json index 86809b42..62ea7585 100644 --- a/lib/i18n/kk.i18n.json +++ b/lib/i18n/kk.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/ko.i18n.json b/lib/i18n/ko.i18n.json index f09d81ec..36e209d4 100644 --- a/lib/i18n/ko.i18n.json +++ b/lib/i18n/ko.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/nb.i18n.json b/lib/i18n/nb.i18n.json index 49b234d3..f2969b60 100644 --- a/lib/i18n/nb.i18n.json +++ b/lib/i18n/nb.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/nl.i18n.json b/lib/i18n/nl.i18n.json index 7da2b63b..c0653ce9 100644 --- a/lib/i18n/nl.i18n.json +++ b/lib/i18n/nl.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/pl.i18n.json b/lib/i18n/pl.i18n.json index 0493bbf4..05a1d97c 100644 --- a/lib/i18n/pl.i18n.json +++ b/lib/i18n/pl.i18n.json @@ -1155,7 +1155,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/pt.i18n.json b/lib/i18n/pt.i18n.json index 82b8f630..d4324459 100644 --- a/lib/i18n/pt.i18n.json +++ b/lib/i18n/pt.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/ru.i18n.json b/lib/i18n/ru.i18n.json index 0925bf24..5b3fe329 100644 --- a/lib/i18n/ru.i18n.json +++ b/lib/i18n/ru.i18n.json @@ -1155,7 +1155,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/strings_en.g.dart b/lib/i18n/strings_en.g.dart index a14275d5..aff496c9 100644 --- a/lib/i18n/strings_en.g.dart +++ b/lib/i18n/strings_en.g.dart @@ -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', diff --git a/lib/i18n/sv.i18n.json b/lib/i18n/sv.i18n.json index 3cf09fd0..67bc1ce3 100644 --- a/lib/i18n/sv.i18n.json +++ b/lib/i18n/sv.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/tr.i18n.json b/lib/i18n/tr.i18n.json index e487304c..73ce29b6 100644 --- a/lib/i18n/tr.i18n.json +++ b/lib/i18n/tr.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/uz.i18n.json b/lib/i18n/uz.i18n.json index b73e060c..8f3d530c 100644 --- a/lib/i18n/uz.i18n.json +++ b/lib/i18n/uz.i18n.json @@ -1147,7 +1147,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/zh-Hant.i18n.json b/lib/i18n/zh-Hant.i18n.json index 894e68e1..12619eba 100644 --- a/lib/i18n/zh-Hant.i18n.json +++ b/lib/i18n/zh-Hant.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/i18n/zh.i18n.json b/lib/i18n/zh.i18n.json index 5efed546..aca8f07d 100644 --- a/lib/i18n/zh.i18n.json +++ b/lib/i18n/zh.i18n.json @@ -1143,7 +1143,7 @@ "favoritedBy": "", "unairedEpisodes": "", "recommendedByPercent": "", - "gallery": "", + "relatedTitles": "", "background": "" }, "totalResults": { diff --git a/lib/models/catalog/catalog_item.dart b/lib/models/catalog/catalog_item.dart index 18c06a4e..c502067b 100644 --- a/lib/models/catalog/catalog_item.dart +++ b/lib/models/catalog/catalog_item.dart @@ -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? 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(), background: json['background'] as String?, cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson), ); diff --git a/lib/models/mal/mal_anime.dart b/lib/models/mal/mal_anime.dart index 6723fa6a..13f3ce39 100644 --- a/lib/models/mal/mal_anime.dart +++ b/lib/models/mal/mal_anime.dart @@ -283,7 +283,6 @@ class MalAnimeDetail { final List recommendations; final List relations; final MalStatistics? statistics; - final List 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()), - ], background: background == null || background.isEmpty ? null : background, ); } diff --git a/lib/screens/catalog_item_detail_screen.dart b/lib/screens/catalog_item_detail_screen.dart index 12a4935c..03b3c11f 100644 --- a/lib/screens/catalog_item_detail_screen.dart +++ b/lib/screens/catalog_item_detail_screen.dart @@ -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 { final ScrollController _scrollController = ScrollController(); final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags'); List _linkFocusNodes = const []; - List> _relationSectionKeys = const []; + List _relationFocusNodes = const []; List _orderedTags = const []; List _streamingLinks = const []; List _otherLinks = const []; @@ -91,8 +94,10 @@ class _CatalogItemDetailScreenState extends State { /// row only renders once loaded non-empty). List? _related; - /// Labelled franchise edges, kept separate from taste-based recommendations. - List _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 { 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 { 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()]; + _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 { 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 { } 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 { } 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 { 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 { } } - 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 { 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 = []; for (final rating in _item.ratings ?? const []) { 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 { ); } + /// 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 { 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 { Widget? _buildCrewSection(ThemeData theme) { final credits = _item.credits; if (credits == null || credits.isEmpty) return null; - final rows = []; + 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 { 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 { ); } - Widget _buildGallerySection(ThemeData theme, List 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 { 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 { const SizedBox(height: 24), _buildBackgroundSection(theme, background), ], - if (item.gallery case final List 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 { 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 related when related.isNotEmpty) ...[ const SizedBox(height: 20), _buildRelatedSection(related), diff --git a/lib/services/catalog/mal_catalog_source.dart b/lib/services/catalog/mal_catalog_source.dart index 0a906974..3e47eb00 100644 --- a/lib/services/catalog/mal_catalog_source.dart +++ b/lib/services/catalog/mal_catalog_source.dart @@ -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? 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? _galleryFor(List pictures, String? posterUrl) { - final urls = {}; - 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, diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index ea5afb66..509a8b24 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -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'; diff --git a/lib/utils/rating_utils.dart b/lib/utils/rating_utils.dart index d86307d0..2fe2931a 100644 --- a/lib/utils/rating_utils.dart +++ b/lib/utils/rating_utils.dart @@ -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)}%'; diff --git a/lib/widgets/stat_chip.dart b/lib/widgets/stat_chip.dart index 7a39ac18..c4761419 100644 --- a/lib/widgets/stat_chip.dart +++ b/lib/widgets/stat_chip.dart @@ -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), ], ), diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 3101e5e2..b35bdd36 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -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(find.byType(SvgPicture)) + .map((picture) => picture.bytesLoader) + .whereType() + .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(galleryFinder).scrollDirection, Axis.horizontal); - final images = tester - .widgetList(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(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 { diff --git a/test/services/catalog/mal_catalog_source_test.dart b/test/services/catalog/mal_catalog_source_test.dart index 5749d5d9..64b34846 100644 --- a/test/services/catalog/mal_catalog_source_test.dart +++ b/test/services/catalog/mal_catalog_source_test.dart @@ -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': [], 'paging': {}}), 200); } return http.Response( - json.encode({ - ..._node(id: 16498, title: 'Shingeki no Kyojin'), - 'pictures': [ - {}, - {'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); });