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:
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "Favorited by ${who}",
|
"favoritedBy": "Favorited by ${who}",
|
||||||
"unairedEpisodes": "${n} not aired yet",
|
"unairedEpisodes": "${n} not aired yet",
|
||||||
"recommendedByPercent": "Recommended by ${percent} of viewers",
|
"recommendedByPercent": "Recommended by ${percent} of viewers",
|
||||||
"gallery": "Gallery",
|
"relatedTitles": "Related titles",
|
||||||
"background": "Background"
|
"background": "Background"
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1155,7 +1155,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1155,7 +1155,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -5445,8 +5445,8 @@ class Translations$explore$detail$en {
|
|||||||
/// en: 'Recommended by ${percent} of viewers'
|
/// en: 'Recommended by ${percent} of viewers'
|
||||||
String recommendedByPercent({required Object percent}) => 'Recommended by ${percent} of viewers';
|
String recommendedByPercent({required Object percent}) => 'Recommended by ${percent} of viewers';
|
||||||
|
|
||||||
/// en: 'Gallery'
|
/// en: 'Related titles'
|
||||||
String get gallery => 'Gallery';
|
String get relatedTitles => 'Related titles';
|
||||||
|
|
||||||
/// en: 'Background'
|
/// en: 'Background'
|
||||||
String get background => 'Background';
|
String get background => 'Background';
|
||||||
@@ -6966,7 +6966,7 @@ extension on Translations {
|
|||||||
'explore.detail.favoritedBy' => ({required Object who}) => 'Favorited by ${who}',
|
'explore.detail.favoritedBy' => ({required Object who}) => 'Favorited by ${who}',
|
||||||
'explore.detail.unairedEpisodes' => ({required Object n}) => '${n} not aired yet',
|
'explore.detail.unairedEpisodes' => ({required Object n}) => '${n} not aired yet',
|
||||||
'explore.detail.recommendedByPercent' => ({required Object percent}) => 'Recommended by ${percent} of viewers',
|
'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.detail.background' => 'Background',
|
||||||
'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ),
|
'explore.totalResults' => ({required num n}) => (_root.$meta.cardinalResolver ?? PluralResolvers.cardinal('en'))(n, one: '${n} result', other: '${n} results', ),
|
||||||
'explore.relation.prequel' => 'Prequel',
|
'explore.relation.prequel' => 'Prequel',
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1147,7 +1147,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -1143,7 +1143,7 @@
|
|||||||
"favoritedBy": "",
|
"favoritedBy": "",
|
||||||
"unairedEpisodes": "",
|
"unairedEpisodes": "",
|
||||||
"recommendedByPercent": "",
|
"recommendedByPercent": "",
|
||||||
"gallery": "",
|
"relatedTitles": "",
|
||||||
"background": ""
|
"background": ""
|
||||||
},
|
},
|
||||||
"totalResults": {
|
"totalResults": {
|
||||||
|
|||||||
@@ -318,11 +318,6 @@ class CatalogItem {
|
|||||||
/// Provider search relevance. Ordering input only — never rendered.
|
/// Provider search relevance. Ordering input only — never rendered.
|
||||||
final double? relevance;
|
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
|
/// Production/background prose (MAL `background`) — trivia about how the
|
||||||
/// title came to exist, distinct from the plot [overview].
|
/// title came to exist, distinct from the plot [overview].
|
||||||
final String? background;
|
final String? background;
|
||||||
@@ -387,7 +382,6 @@ class CatalogItem {
|
|||||||
this.recommendationPercent,
|
this.recommendationPercent,
|
||||||
this.playState,
|
this.playState,
|
||||||
this.relevance,
|
this.relevance,
|
||||||
this.gallery,
|
|
||||||
this.background,
|
this.background,
|
||||||
this.cast,
|
this.cast,
|
||||||
this.unairedEpisodeCount,
|
this.unairedEpisodeCount,
|
||||||
@@ -461,7 +455,6 @@ class CatalogItem {
|
|||||||
recommendationCount: recommendationCount ?? detail.recommendationCount,
|
recommendationCount: recommendationCount ?? detail.recommendationCount,
|
||||||
recommendationPercent: recommendationPercent ?? detail.recommendationPercent,
|
recommendationPercent: recommendationPercent ?? detail.recommendationPercent,
|
||||||
relevance: relevance ?? detail.relevance,
|
relevance: relevance ?? detail.relevance,
|
||||||
gallery: detail.gallery ?? gallery,
|
|
||||||
background: detail.background ?? background,
|
background: detail.background ?? background,
|
||||||
cast: detail.cast ?? cast,
|
cast: detail.cast ?? cast,
|
||||||
recommenders: recommenders ?? detail.recommenders,
|
recommenders: recommenders ?? detail.recommenders,
|
||||||
@@ -563,7 +556,6 @@ class CatalogItem {
|
|||||||
if (recommendationPercent != null) 'recommendationPercent': recommendationPercent,
|
if (recommendationPercent != null) 'recommendationPercent': recommendationPercent,
|
||||||
if (playState != null) 'playState': playState!.toJson(),
|
if (playState != null) 'playState': playState!.toJson(),
|
||||||
if (relevance != null) 'relevance': relevance,
|
if (relevance != null) 'relevance': relevance,
|
||||||
if (gallery != null) 'gallery': gallery,
|
|
||||||
if (background != null) 'background': background,
|
if (background != null) 'background': background,
|
||||||
if (cast != null) 'cast': [for (final c in cast!) c.toJson()],
|
if (cast != null) 'cast': [for (final c in cast!) c.toJson()],
|
||||||
};
|
};
|
||||||
@@ -634,7 +626,6 @@ class CatalogItem {
|
|||||||
unairedEpisodeCount: json['unairedEpisodeCount'] as int?,
|
unairedEpisodeCount: json['unairedEpisodeCount'] as int?,
|
||||||
recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson),
|
recommenders: decodeCatalogList(json['recommenders'], CatalogRecommender.fromJson),
|
||||||
relevance: (json['relevance'] as num?)?.toDouble(),
|
relevance: (json['relevance'] as num?)?.toDouble(),
|
||||||
gallery: (json['gallery'] as List?)?.cast<String>(),
|
|
||||||
background: json['background'] as String?,
|
background: json['background'] as String?,
|
||||||
cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson),
|
cast: decodeCatalogList(json['cast'], CatalogCastMember.fromJson),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -283,7 +283,6 @@ class MalAnimeDetail {
|
|||||||
final List<MalAnimeRecommendation> recommendations;
|
final List<MalAnimeRecommendation> recommendations;
|
||||||
final List<MalAnimeRelation> relations;
|
final List<MalAnimeRelation> relations;
|
||||||
final MalStatistics? statistics;
|
final MalStatistics? statistics;
|
||||||
final List<MalPicture> pictures;
|
|
||||||
final String? background;
|
final String? background;
|
||||||
|
|
||||||
const MalAnimeDetail({
|
const MalAnimeDetail({
|
||||||
@@ -291,7 +290,6 @@ class MalAnimeDetail {
|
|||||||
this.recommendations = const [],
|
this.recommendations = const [],
|
||||||
this.relations = const [],
|
this.relations = const [],
|
||||||
this.statistics,
|
this.statistics,
|
||||||
this.pictures = const [],
|
|
||||||
this.background,
|
this.background,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -299,7 +297,6 @@ class MalAnimeDetail {
|
|||||||
final recommendations = json['recommendations'];
|
final recommendations = json['recommendations'];
|
||||||
final relations = json['related_anime'];
|
final relations = json['related_anime'];
|
||||||
final statistics = json['statistics'];
|
final statistics = json['statistics'];
|
||||||
final pictures = json['pictures'];
|
|
||||||
final rawBackground = json['background'];
|
final rawBackground = json['background'];
|
||||||
final background = rawBackground is String ? rawBackground.trim() : null;
|
final background = rawBackground is String ? rawBackground.trim() : null;
|
||||||
return MalAnimeDetail(
|
return MalAnimeDetail(
|
||||||
@@ -313,11 +310,6 @@ class MalAnimeDetail {
|
|||||||
for (final raw in relations.take(relatedLimit)) ?MalAnimeRelation.fromEntry(raw),
|
for (final raw in relations.take(relatedLimit)) ?MalAnimeRelation.fromEntry(raw),
|
||||||
],
|
],
|
||||||
statistics: statistics is Map ? MalStatistics.fromJson(statistics) : null,
|
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,
|
background: background == null || background.isEmpty ? null : background,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.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_action_bar.dart';
|
||||||
import '../focus/focusable_button.dart';
|
import '../focus/focusable_button.dart';
|
||||||
|
import '../focus/focusable_wrapper.dart';
|
||||||
import '../focus/dpad_navigator.dart';
|
import '../focus/dpad_navigator.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../focus/key_event_utils.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/catalog_source.dart';
|
||||||
import '../services/catalog/seerr_catalog_source.dart';
|
import '../services/catalog/seerr_catalog_source.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/catalog_navigation_helper.dart';
|
||||||
import '../utils/desktop_window_padding.dart';
|
import '../utils/desktop_window_padding.dart';
|
||||||
import '../utils/formatters.dart';
|
import '../utils/formatters.dart';
|
||||||
import '../utils/country_codes.dart';
|
import '../utils/country_codes.dart';
|
||||||
import '../utils/language_codes.dart';
|
import '../utils/language_codes.dart';
|
||||||
import '../utils/media_navigation_helper.dart';
|
import '../utils/media_navigation_helper.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
|
import '../utils/rating_utils.dart';
|
||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../widgets/app_bar_back_button.dart';
|
import '../widgets/app_bar_back_button.dart';
|
||||||
import '../widgets/app_icon.dart';
|
import '../widgets/app_icon.dart';
|
||||||
import '../widgets/backend_badge.dart';
|
import '../widgets/backend_badge.dart';
|
||||||
import '../widgets/cast_member_strip.dart';
|
import '../widgets/cast_member_strip.dart';
|
||||||
import '../widgets/horizontal_scroll_with_arrows.dart';
|
|
||||||
import '../widgets/focusable_list_tile.dart';
|
import '../widgets/focusable_list_tile.dart';
|
||||||
import '../widgets/hub_section.dart';
|
import '../widgets/hub_section.dart';
|
||||||
import '../widgets/optimized_media_image.dart';
|
import '../widgets/optimized_media_image.dart';
|
||||||
@@ -68,7 +71,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags');
|
final _spoilerTagFocusNode = FocusNode(debugLabel: 'catalog_spoiler_tags');
|
||||||
List<FocusNode> _linkFocusNodes = const [];
|
List<FocusNode> _linkFocusNodes = const [];
|
||||||
List<GlobalKey<HubSectionState>> _relationSectionKeys = const [];
|
List<FocusNode> _relationFocusNodes = const [];
|
||||||
List<CatalogTag> _orderedTags = const [];
|
List<CatalogTag> _orderedTags = const [];
|
||||||
List<CatalogLink> _streamingLinks = const [];
|
List<CatalogLink> _streamingLinks = const [];
|
||||||
List<CatalogLink> _otherLinks = const [];
|
List<CatalogLink> _otherLinks = const [];
|
||||||
@@ -91,8 +94,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
/// row only renders once loaded non-empty).
|
/// row only renders once loaded non-empty).
|
||||||
List<CatalogItem>? _related;
|
List<CatalogItem>? _related;
|
||||||
|
|
||||||
/// Labelled franchise edges, kept separate from taste-based recommendations.
|
/// Labelled franchise edges, flattened to one entry per title. Providers
|
||||||
List<CatalogRelation> _relations = const [];
|
/// 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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -132,6 +137,9 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
for (final node in _libraryMatchFocusNodes) {
|
for (final node in _libraryMatchFocusNodes) {
|
||||||
node.dispose();
|
node.dispose();
|
||||||
}
|
}
|
||||||
|
for (final node in _relationFocusNodes) {
|
||||||
|
node.dispose();
|
||||||
|
}
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -201,23 +209,32 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
try {
|
try {
|
||||||
final detail = await source.fetchDetail(widget.item);
|
final detail = await source.fetchDetail(widget.item);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final relations = [
|
final relationEntries = [
|
||||||
for (final relation in detail.relations)
|
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);
|
_syncDetailCollections(detail.item);
|
||||||
|
_replaceRelationFocusNodes(relationEntries.length);
|
||||||
setState(() {
|
setState(() {
|
||||||
_detailItem = detail.item;
|
_detailItem = detail.item;
|
||||||
_cast = detail.cast;
|
_cast = detail.cast;
|
||||||
_related = detail.related;
|
_related = detail.related;
|
||||||
_relations = relations;
|
_relationEntries = relationEntries;
|
||||||
_relationSectionKeys = [for (var index = 0; index < relations.length; index++) GlobalKey<HubSectionState>()];
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.d('Catalog detail load failed for ${widget.item.identityKey}', error: 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 _hasTrailer => _item.trailerUrl?.trim().isNotEmpty ?? false;
|
||||||
|
|
||||||
bool get _hasActions => _watchlistSource != null || _requestSource != null || _hasTrailer;
|
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 _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}) {
|
void _revealFocusNode(FocusNode? node, {double alignment = 0.3}) {
|
||||||
final focusContext = node?.context;
|
final focusContext = node?.context;
|
||||||
@@ -299,16 +322,18 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _requestRelationFocus(int index) {
|
void _requestRelationFocus(int index) {
|
||||||
if (index < 0 || index >= _relationSectionKeys.length) return;
|
if (index < 0 || index >= _relationFocusNodes.length) return;
|
||||||
_relationSectionKeys[index].currentState?.requestFocusFromMemory();
|
final node = _relationFocusNodes[index];
|
||||||
|
node.requestFocus();
|
||||||
|
_revealFocusNode(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _requestRelatedFocus() {
|
void _requestRelatedFocus() {
|
||||||
_relatedSectionKey.currentState?.requestFocusFromMemory();
|
_relatedSectionKey.currentState?.requestFocusFromMemory();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _requestFirstHubFocus() {
|
void _focusSectionBelowCast() {
|
||||||
if (_relations.isNotEmpty) {
|
if (_hasRelations) {
|
||||||
_requestRelationFocus(0);
|
_requestRelationFocus(0);
|
||||||
} else {
|
} else {
|
||||||
_requestRelatedFocus();
|
_requestRelatedFocus();
|
||||||
@@ -316,12 +341,12 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _focusSectionBelowLibraryMatches() {
|
bool _focusSectionBelowLibraryMatches() {
|
||||||
if (_cast?.isNotEmpty ?? false) {
|
if (_hasCast) {
|
||||||
_requestCastFocus();
|
_requestCastFocus();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (_hasHubRows) {
|
if (_hasSectionsBelowCast) {
|
||||||
_requestFirstHubFocus();
|
_focusSectionBelowCast();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -330,10 +355,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
void _focusSectionBelowDetailActions() {
|
void _focusSectionBelowDetailActions() {
|
||||||
if (_hasLibraryMatches) {
|
if (_hasLibraryMatches) {
|
||||||
_requestLibraryMatchFocus(0);
|
_requestLibraryMatchFocus(0);
|
||||||
} else if (_cast?.isNotEmpty ?? false) {
|
} else if (_hasCast) {
|
||||||
_requestCastFocus();
|
_requestCastFocus();
|
||||||
} else {
|
} else if (_hasSectionsBelowCast) {
|
||||||
_requestFirstHubFocus();
|
_focusSectionBelowCast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,23 +388,19 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _focusSectionAboveFirstHub() {
|
void _focusSectionAboveRelations() {
|
||||||
if (_cast?.isNotEmpty ?? false) {
|
if (_hasCast) {
|
||||||
_requestCastFocus();
|
_requestCastFocus();
|
||||||
} else if (_hasLibraryMatches) {
|
|
||||||
_requestLibraryMatchFocus(_libraryMatchFocusNodes.length - 1);
|
|
||||||
} else if (_hasDetailActions) {
|
|
||||||
_requestLastDetailActionFocus();
|
|
||||||
} else {
|
} else {
|
||||||
_requestActionBarFocus();
|
_focusSectionAboveCast();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _focusSectionAboveRelated() {
|
void _focusSectionAboveRelated() {
|
||||||
if (_relations.isNotEmpty) {
|
if (_hasRelations) {
|
||||||
_requestRelationFocus(_relations.length - 1);
|
_requestRelationFocus(_relationEntries.length - 1);
|
||||||
} else {
|
} else {
|
||||||
_focusSectionAboveFirstHub();
|
_focusSectionAboveRelations();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,17 +771,29 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
return Wrap(spacing: 8, runSpacing: 8, children: chips);
|
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) {
|
Widget? _buildRatingsSection(ThemeData theme) {
|
||||||
final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
|
final compact = NumberFormat.compact(locale: LocaleSettings.currentLocale.intlLocaleName);
|
||||||
final chips = <Widget>[];
|
final chips = <Widget>[];
|
||||||
for (final rating in _item.ratings ?? const <CatalogRatingSource>[]) {
|
for (final rating in _item.ratings ?? const <CatalogRatingSource>[]) {
|
||||||
final source = _ratingSourceLabel(rating.source);
|
final source = _ratingSourceLabel(rating.source);
|
||||||
if (source == null) continue;
|
final badge = catalogRatingInfo(rating.source, rating.value);
|
||||||
var label = '$source ${rating.value.toStringAsFixed(1)}';
|
if (source == null && badge == null) continue;
|
||||||
|
var label = badge == null ? '$source ${rating.value.toStringAsFixed(1)}' : badge.formattedValue;
|
||||||
if (rating.votes case final votes?) {
|
if (rating.votes case final votes?) {
|
||||||
label = '$label (${t.explore.stats.votes(n: compact.format(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;
|
if (chips.isEmpty) return null;
|
||||||
return Column(
|
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) {
|
Widget? _buildFactsSection(ThemeData theme) {
|
||||||
final item = _item;
|
final item = _item;
|
||||||
final locale = LocaleSettings.currentLocale.intlLocaleName;
|
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));
|
if (item.revenue case final revenue?) add(t.explore.detail.revenue, currency.format(revenue));
|
||||||
add(t.explore.detail.contentAdvisory, item.contentAdvisory);
|
add(t.explore.detail.contentAdvisory, item.contentAdvisory);
|
||||||
if (facts.isEmpty) return null;
|
if (facts.isEmpty) return null;
|
||||||
return Column(
|
return _buildFactGrid(theme, facts);
|
||||||
crossAxisAlignment: .start,
|
|
||||||
children: [for (final fact in facts) _buildFactRow(theme, fact.label, fact.value)],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget? _buildRecommendersSection(ThemeData theme) {
|
Widget? _buildRecommendersSection(ThemeData theme) {
|
||||||
@@ -890,10 +959,10 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
Widget? _buildCrewSection(ThemeData theme) {
|
Widget? _buildCrewSection(ThemeData theme) {
|
||||||
final credits = _item.credits;
|
final credits = _item.credits;
|
||||||
if (credits == null || credits.isEmpty) return null;
|
if (credits == null || credits.isEmpty) return null;
|
||||||
final rows = <Widget>[];
|
final rows = <({String label, String value})>[];
|
||||||
for (final role in CatalogCreditRole.values) {
|
for (final role in CatalogCreditRole.values) {
|
||||||
final names = _joinValues(credits.where((credit) => credit.role == role).map((credit) => credit.name));
|
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;
|
if (rows.isEmpty) return null;
|
||||||
return Column(
|
return Column(
|
||||||
@@ -901,7 +970,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Text(t.explore.detail.crew, style: theme.textTheme.titleMedium),
|
Text(t.explore.detail.crew, style: theme.textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
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
|
/// Horizontal cast strip — the same [CastMemberStrip] cards as the media
|
||||||
/// detail screen. Trakt serves actors with their character; MAL serves
|
/// detail screen. Trakt serves actors with their character; MAL serves
|
||||||
/// characters with their role, so the section is titled accordingly.
|
/// 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),
|
for (final member in cast) (name: member.name, secondary: member.secondary, imagePath: member.imageUrl),
|
||||||
],
|
],
|
||||||
onNavigateUp: _hasLibraryMatches || _hasDetailActions || _hasActions ? _focusSectionAboveCast : null,
|
onNavigateUp: _hasLibraryMatches || _hasDetailActions || _hasActions ? _focusSectionAboveCast : null,
|
||||||
onNavigateDown: _hasHubRows ? _requestFirstHubFocus : null,
|
onNavigateDown: _hasSectionsBelowCast ? _focusSectionBelowCast : null,
|
||||||
debugLabel: 'catalog_cast_row',
|
debugLabel: 'catalog_cast_row',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRelationSection(CatalogRelation relation, int index) {
|
/// Labelled franchise edges as compact rows rather than one hub per label:
|
||||||
return HubSection(
|
/// a provider that returns a single sequel used to spend an entire shelf on
|
||||||
key: _relationSectionKeys[index],
|
/// it. Rows flow into columns on wide viewports, like the facts above.
|
||||||
hub: MediaHub(
|
Widget _buildRelationsSection(ThemeData theme) {
|
||||||
id: 'catalog-relation:${_item.source.name}:${_item.identityKey}:${relation.type.name}:$index',
|
return LayoutBuilder(
|
||||||
identifier: 'explore.relation.${relation.type.name}',
|
builder: (context, constraints) {
|
||||||
title: _relationLabel(relation.type),
|
final count = _relationEntries.length;
|
||||||
type: 'mixed',
|
final columns = _gridColumns(constraints.maxWidth, count);
|
||||||
items: [for (final item in relation.items) item.toMediaItem()],
|
final tileWidth = (constraints.maxWidth - _relationTileSpacing * (columns - 1)) / columns;
|
||||||
size: relation.items.length,
|
return Column(
|
||||||
),
|
crossAxisAlignment: .start,
|
||||||
focusMemory: _hubFocusMemory,
|
children: [
|
||||||
icon: Symbols.link_rounded,
|
Text(t.explore.detail.relatedTitles, style: theme.textTheme.titleMedium),
|
||||||
inset: true,
|
const SizedBox(height: 8),
|
||||||
onNavigateUp: index == 0 ? _focusSectionAboveFirstHub : () => _requestRelationFocus(index - 1),
|
Wrap(
|
||||||
onVerticalNavigation: (isUp) {
|
spacing: _relationTileSpacing,
|
||||||
if (isUp) {
|
runSpacing: _relationTileSpacing,
|
||||||
if (index == 0) {
|
children: [
|
||||||
_focusSectionAboveFirstHub();
|
for (var index = 0; index < count; index++)
|
||||||
} else {
|
SizedBox(
|
||||||
_requestRelationFocus(index - 1);
|
width: tileWidth,
|
||||||
}
|
child: _buildRelationTile(theme, index, columns: columns, count: count),
|
||||||
return true;
|
),
|
||||||
}
|
],
|
||||||
if (index + 1 < _relations.length) {
|
),
|
||||||
_requestRelationFocus(index + 1);
|
],
|
||||||
return true;
|
);
|
||||||
}
|
|
||||||
if (_related?.isNotEmpty ?? false) {
|
|
||||||
_requestRelatedFocus();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
},
|
||||||
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),
|
const SizedBox(height: 24),
|
||||||
_buildBackgroundSection(theme, background),
|
_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) ...[
|
if (_buildFactsSection(theme) case final Widget facts) ...[
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
facts,
|
facts,
|
||||||
@@ -1320,10 +1410,7 @@ class _CatalogItemDetailScreenState extends State<CatalogItemDetailScreen> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
_buildCastSection(theme, cast),
|
_buildCastSection(theme, cast),
|
||||||
],
|
],
|
||||||
for (var index = 0; index < _relations.length; index++) ...[
|
if (_hasRelations) ...[const SizedBox(height: 24), _buildRelationsSection(theme)],
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildRelationSection(_relations[index], index),
|
|
||||||
],
|
|
||||||
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
if (_related case final List<CatalogItem> related when related.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildRelatedSection(related),
|
_buildRelatedSection(related),
|
||||||
|
|||||||
@@ -131,14 +131,7 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
|||||||
.add(relationItems[i]);
|
.add(relationItems[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final detailPosterUrl = detail.anime.mainPicture?.primary ?? item.posterUrl;
|
final detailItem = _toCatalogItem(detail.anime, null, statistics: detail.statistics, background: detail.background);
|
||||||
final detailItem = _toCatalogItem(
|
|
||||||
detail.anime,
|
|
||||||
null,
|
|
||||||
statistics: detail.statistics,
|
|
||||||
gallery: _galleryFor(detail.pictures, detailPosterUrl),
|
|
||||||
background: detail.background,
|
|
||||||
);
|
|
||||||
return CatalogDetail(
|
return CatalogDetail(
|
||||||
item: item.enrichedWith(detailItem),
|
item: item.enrichedWith(detailItem),
|
||||||
cast: cast,
|
cast: cast,
|
||||||
@@ -193,7 +186,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
|||||||
CatalogRankScope? rankingScope,
|
CatalogRankScope? rankingScope,
|
||||||
int? recommendationCount,
|
int? recommendationCount,
|
||||||
MalStatistics? statistics,
|
MalStatistics? statistics,
|
||||||
List<String>? gallery,
|
|
||||||
String? background,
|
String? background,
|
||||||
}) {
|
}) {
|
||||||
final title = anime.displayTitle;
|
final title = anime.displayTitle;
|
||||||
@@ -247,7 +239,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
|||||||
sourceMaterial: _sourceMaterialFor(anime.source),
|
sourceMaterial: _sourceMaterialFor(anime.source),
|
||||||
isAdult: _isAdultFor(anime.nsfw),
|
isAdult: _isAdultFor(anime.nsfw),
|
||||||
recommendationCount: recommendationCount,
|
recommendationCount: recommendationCount,
|
||||||
gallery: gallery,
|
|
||||||
background: background,
|
background: background,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -358,15 +349,6 @@ class MalCatalogSource with CatalogWatchlistMachinery implements CatalogSource {
|
|||||||
|
|
||||||
static DateTime? _dateFor(String? value) => value == null ? null : DateTime.tryParse(value);
|
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) {
|
static CatalogRelationType _relationTypeFor(String? type) => switch (type) {
|
||||||
'prequel' => CatalogRelationType.prequel,
|
'prequel' => CatalogRelationType.prequel,
|
||||||
'sequel' => CatalogRelationType.sequel,
|
'sequel' => CatalogRelationType.sequel,
|
||||||
|
|||||||
@@ -91,10 +91,11 @@ class MalClient implements DisposableTrackerClient {
|
|||||||
'nsfw,source,end_date';
|
'nsfw,source,end_date';
|
||||||
|
|
||||||
/// The existing detail request widened by +22.3% in the measured sample
|
/// 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 =
|
static const String detailFields =
|
||||||
'$catalogFields,recommendations%7B$catalogFields%7D,'
|
'$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';
|
static const String _characterFields = 'role,main_picture,first_name,last_name';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/// Parses Plex ratingImage / audienceRatingImage URIs and returns
|
/// Resolves the brand badge shown beside a score: Plex's
|
||||||
/// the corresponding local asset path and a display-formatted value.
|
/// ratingImage/audienceRatingImage URIs, and the attributed source keys the
|
||||||
|
/// Explore catalog carries instead of them.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
class RatingInfo {
|
class RatingInfo {
|
||||||
@@ -18,22 +19,21 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) {
|
|||||||
|
|
||||||
if (imageUri.startsWith('rottentomatoes://image.rating.')) {
|
if (imageUri.startsWith('rottentomatoes://image.rating.')) {
|
||||||
final suffix = imageUri.substring('rottentomatoes://image.rating.'.length);
|
final suffix = imageUri.substring('rottentomatoes://image.rating.'.length);
|
||||||
final percent = '${(value * 10).toStringAsFixed(0)}%';
|
|
||||||
return switch (suffix) {
|
return switch (suffix) {
|
||||||
'ripe' => RatingInfo('assets/rating_icons/rt_fresh.svg', percent),
|
'ripe' => RatingInfo(_rtFreshAsset, _percent(value)),
|
||||||
'rotten' => RatingInfo('assets/rating_icons/rt_rotten.svg', percent),
|
'rotten' => RatingInfo(_rtRottenAsset, _percent(value)),
|
||||||
'upright' => RatingInfo('assets/rating_icons/rt_upright.svg', percent),
|
'upright' => RatingInfo(_rtUprightAsset, _percent(value)),
|
||||||
'spilled' => RatingInfo('assets/rating_icons/rt_spilled.svg', percent),
|
'spilled' => RatingInfo(_rtSpilledAsset, _percent(value)),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageUri.startsWith('imdb://')) {
|
if (imageUri.startsWith('imdb://')) {
|
||||||
return RatingInfo('assets/rating_icons/imdb.svg', value.toStringAsFixed(1));
|
return RatingInfo(_imdbAsset, value.toStringAsFixed(1));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (imageUri.startsWith('themoviedb://')) {
|
if (imageUri.startsWith('themoviedb://')) {
|
||||||
return RatingInfo('assets/rating_icons/tmdb.svg', '${(value * 10).toStringAsFixed(0)}%');
|
return RatingInfo(_tmdbAsset, _percent(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -41,3 +41,37 @@ RatingInfo? parseRatingImage(String? imageUri, double? value) {
|
|||||||
|
|
||||||
/// Whether the URI is a Rotten Tomatoes rating source.
|
/// Whether the URI is a Rotten Tomatoes rating source.
|
||||||
bool isRottenTomatoes(String? imageUri) => imageUri != null && imageUri.startsWith('rottentomatoes://');
|
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)}%';
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ class StatChip extends StatelessWidget {
|
|||||||
final Color? iconColor;
|
final Color? iconColor;
|
||||||
final String label;
|
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
|
/// Overrides the default fill. Needed where the chip sits on a surface that
|
||||||
/// already uses `surfaceContainerHigh` — the mono theme collapses several
|
/// already uses `surfaceContainerHigh` — the mono theme collapses several
|
||||||
/// container roles onto one colour, so the default would be invisible.
|
/// container roles onto one colour, so the default would be invisible.
|
||||||
final Color? backgroundColor;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -28,7 +32,13 @@ class StatChip extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
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),
|
Text(label, style: theme.textTheme.labelMedium),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/focus/focusable_action_bar.dart';
|
||||||
import 'package:plezy/i18n/strings.g.dart';
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/media/media_kind.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/theme/mono_theme.dart';
|
||||||
import 'package:plezy/utils/platform_detector.dart';
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
import 'package:plezy/widgets/overlay_sheet.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/media_card.dart';
|
||||||
import 'package:plezy/widgets/optimized_media_image.dart';
|
import 'package:plezy/widgets/optimized_media_image.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -267,7 +269,7 @@ void main() {
|
|||||||
expect(find.text(t.explore.detail.revealSpoilerTags), findsNothing);
|
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(
|
const item = CatalogItem(
|
||||||
source: CatalogSourceId.trakt,
|
source: CatalogSourceId.trakt,
|
||||||
kind: MediaKind.movie,
|
kind: MediaKind.movie,
|
||||||
@@ -275,7 +277,6 @@ void main() {
|
|||||||
ids: CatalogItemIds(tmdb: 11),
|
ids: CatalogItemIds(tmdb: 11),
|
||||||
ratings: [
|
ratings: [
|
||||||
CatalogRatingSource(source: 'simkl', value: 8.1, votes: 11),
|
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: 'mal', value: 8.3, votes: 13),
|
||||||
CatalogRatingSource(source: 'critic', value: 7.2, votes: 14),
|
CatalogRatingSource(source: 'critic', value: 7.2, votes: 14),
|
||||||
CatalogRatingSource(source: 'audience', value: 8.8, votes: 15),
|
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(t.explore.detail.ratings), findsOneWidget);
|
||||||
expect(find.text('Simkl 8.1 (11 votes)'), 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('MyAnimeList 8.3 (13 votes)'), findsOneWidget);
|
||||||
expect(find.text('Critics 7.2 (14 votes)'), findsOneWidget);
|
expect(find.text('Critics 7.2 (14 votes)'), findsOneWidget);
|
||||||
expect(find.text('Audience 8.8 (15 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 {
|
testWidgets('seasonal rank keeps its season window instead of claiming all-time rank', (tester) async {
|
||||||
const item = CatalogItem(
|
const item = CatalogItem(
|
||||||
source: CatalogSourceId.trakt,
|
source: CatalogSourceId.trakt,
|
||||||
@@ -371,17 +410,12 @@ void main() {
|
|||||||
expect(find.byTooltip(t.explore.detail.watchTrailer), findsOneWidget);
|
expect(find.byTooltip(t.explore.detail.watchTrailer), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('gallery and background sections render when populated', (tester) async {
|
testWidgets('background prose renders as its own section', (tester) async {
|
||||||
const galleryUrls = [
|
|
||||||
'https://cdn.myanimelist.net/images/anime/gallery-1.jpg',
|
|
||||||
'https://cdn.myanimelist.net/images/anime/gallery-2.jpg',
|
|
||||||
];
|
|
||||||
const item = CatalogItem(
|
const item = CatalogItem(
|
||||||
source: CatalogSourceId.trakt,
|
source: CatalogSourceId.trakt,
|
||||||
kind: MediaKind.movie,
|
kind: MediaKind.movie,
|
||||||
title: 'Production Movie',
|
title: 'Production Movie',
|
||||||
ids: CatalogItemIds(tmdb: 15),
|
ids: CatalogItemIds(tmdb: 15),
|
||||||
gallery: galleryUrls,
|
|
||||||
background: 'Filmed over three winters.',
|
background: 'Filmed over three winters.',
|
||||||
);
|
);
|
||||||
final source = _FakeCatalogSource(detail: const CatalogDetail(item: item));
|
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(t.explore.detail.background), findsOneWidget);
|
||||||
expect(find.text('Filmed over three winters.'), 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);
|
testWidgets('budget and box office pair into columns on a wide window and stack on a narrow one', (tester) async {
|
||||||
expect(tester.widget<ListView>(galleryFinder).scrollDirection, Axis.horizontal);
|
tester.view.devicePixelRatio = 1;
|
||||||
final images = tester
|
tester.view.physicalSize = const Size(1400, 900);
|
||||||
.widgetList<OptimizedMediaImage>(find.descendant(of: galleryFinder, matching: find.byType(OptimizedMediaImage)))
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
.map((image) => image.imagePath);
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
expect(images, galleryUrls);
|
|
||||||
|
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 {
|
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.detail.watchOn), findsNothing);
|
||||||
expect(find.text(t.explore.cast), findsNothing);
|
expect(find.text(t.explore.cast), findsNothing);
|
||||||
expect(find.text(t.discover.moreLikeThis), 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);
|
expect(find.text(t.explore.detail.background), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('franchise relations keep their labelled shelf separate from recommendations', (tester) async {
|
testWidgets('single-entry relations share one labelled section instead of a shelf each', (tester) async {
|
||||||
const relationItem = CatalogItem(
|
const sequel = CatalogItem(
|
||||||
source: CatalogSourceId.trakt,
|
source: CatalogSourceId.trakt,
|
||||||
kind: MediaKind.movie,
|
kind: MediaKind.movie,
|
||||||
title: 'The Sequel',
|
title: 'The Sequel',
|
||||||
|
year: 2019,
|
||||||
|
posterUrl: 'https://example.com/sequel.jpg',
|
||||||
ids: CatalogItemIds(tmdb: 17),
|
ids: CatalogItemIds(tmdb: 17),
|
||||||
);
|
);
|
||||||
|
const spinOff = CatalogItem(
|
||||||
|
source: CatalogSourceId.trakt,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
title: 'The Spin-off',
|
||||||
|
ids: CatalogItemIds(tmdb: 20),
|
||||||
|
);
|
||||||
const recommendation = CatalogItem(
|
const recommendation = CatalogItem(
|
||||||
source: CatalogSourceId.trakt,
|
source: CatalogSourceId.trakt,
|
||||||
kind: MediaKind.movie,
|
kind: MediaKind.movie,
|
||||||
@@ -442,17 +506,117 @@ void main() {
|
|||||||
item: _item,
|
item: _item,
|
||||||
related: [recommendation],
|
related: [recommendation],
|
||||||
relations: [
|
relations: [
|
||||||
CatalogRelation(type: CatalogRelationType.sequel, items: [relationItem]),
|
CatalogRelation(type: CatalogRelationType.sequel, items: [sequel]),
|
||||||
|
CatalogRelation(type: CatalogRelationType.spinOff, items: [spinOff]),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _pumpDetail(tester, source);
|
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(t.discover.moreLikeThis), findsOneWidget);
|
||||||
expect(find.text('The Sequel'), findsOneWidget);
|
|
||||||
expect(find.text('A Similar Movie'), 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 {
|
testWidgets('social recommendation keeps its person, reason, and note', (tester) async {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const _expectedCatalogFields =
|
|||||||
'nsfw,source,end_date';
|
'nsfw,source,end_date';
|
||||||
const _expectedDetailFields =
|
const _expectedDetailFields =
|
||||||
'$_expectedCatalogFields,recommendations{$_expectedCatalogFields},'
|
'$_expectedCatalogFields,recommendations{$_expectedCatalogFields},'
|
||||||
'related_anime{$_expectedCatalogFields},statistics,pictures,background';
|
'related_anime{$_expectedCatalogFields},statistics,background';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
// Attack on Titan: split-cour show — one Fribb row per season, same tvdb id.
|
// Attack on Titan: split-cour show — one Fribb row per season, same tvdb id.
|
||||||
@@ -456,19 +456,6 @@ void main() {
|
|||||||
'plan_to_watch': '350000',
|
'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.',
|
'background': 'Created from the original manga.',
|
||||||
}),
|
}),
|
||||||
200,
|
200,
|
||||||
@@ -504,11 +491,6 @@ void main() {
|
|||||||
expect(detail.item.audience?.onHold, 40000);
|
expect(detail.item.audience?.onHold, 40000);
|
||||||
expect(detail.item.audience?.dropped, 90000);
|
expect(detail.item.audience?.dropped, 90000);
|
||||||
expect(detail.item.audience?.planning, 350000);
|
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.background, 'Created from the original manga.');
|
||||||
expect(detail.item.posterVariants, isNull);
|
expect(detail.item.posterVariants, isNull);
|
||||||
|
|
||||||
@@ -530,20 +512,13 @@ void main() {
|
|||||||
expect(detail.relations.single.items.single.title, 'Shingeki no Kyojin Season 3');
|
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) {
|
http.Response respond(http.Request request) {
|
||||||
if (request.url.path.endsWith('/characters')) {
|
if (request.url.path.endsWith('/characters')) {
|
||||||
return http.Response(json.encode({'data': <Object>[], 'paging': <String, dynamic>{}}), 200);
|
return http.Response(json.encode({'data': <Object>[], 'paging': <String, dynamic>{}}), 200);
|
||||||
}
|
}
|
||||||
return http.Response(
|
return http.Response(
|
||||||
json.encode({
|
json.encode({..._node(id: 16498, title: 'Shingeki no Kyojin'), 'background': ' \n\t '}),
|
||||||
..._node(id: 16498, title: 'Shingeki no Kyojin'),
|
|
||||||
'pictures': [
|
|
||||||
<String, dynamic>{},
|
|
||||||
{'medium': ' ', 'large': ''},
|
|
||||||
],
|
|
||||||
'background': ' \n\t ',
|
|
||||||
}),
|
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -560,7 +535,6 @@ void main() {
|
|||||||
|
|
||||||
final detail = await source.fetchDetail(item);
|
final detail = await source.fetchDetail(item);
|
||||||
|
|
||||||
expect(detail.item.gallery, isNull);
|
|
||||||
expect(detail.item.background, isNull);
|
expect(detail.item.background, isNull);
|
||||||
expect(detail.item.posterVariants, isNull);
|
expect(detail.item.posterVariants, isNull);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user