From 3296af38fbeea36661239394a92c25f49312c979 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Wed, 19 Aug 2026 23:22:06 +0200 Subject: [PATCH 1/5] feat: add available platforms badge with url launcher and improve grid layouts - Add url field to SourceModel for platform redirection - Create AvailablePlatformsBadge allowing direct launch or popup selection of streaming platforms - Modularize platform widgets into PlatformsStack and PlatformsBadge in lib/core/widgets/platforms/ - Switch grid layouts in CatalogView, GroupedEpisodesView, and WeeklyView to AlignedGridView.count - Add 'availableOn' localization string and adjust theme text style --- lib/core/theme/app_theme.dart | 1 + .../platforms/available_platforms_badge.dart | 135 ++++++++++++++++++ .../widgets/platforms/platforms_badge.dart | 19 +++ .../widgets/platforms/platforms_stack.dart | 48 +++++++ lib/core/widgets/platforms_badge.dart | 51 ------- lib/l10n/app_fr.arb | 1 + lib/l10n/app_localizations.dart | 6 + lib/l10n/app_localizations_fr.dart | 3 + lib/main.dart | 2 +- lib/models/source_model.dart | 3 +- lib/models/source_model.g.dart | 1 + lib/views/catalog/catalog_view.dart | 19 ++- lib/views/catalog/widgets/anime_card.dart | 28 +--- .../grouped_episodes_view.dart | 11 +- .../widgets/grouped_episode_card.dart | 13 +- lib/views/weekly/weekly_view.dart | 11 +- lib/views/weekly/widgets/release_card.dart | 6 - 17 files changed, 250 insertions(+), 108 deletions(-) create mode 100644 lib/core/widgets/platforms/available_platforms_badge.dart create mode 100644 lib/core/widgets/platforms/platforms_badge.dart create mode 100644 lib/core/widgets/platforms/platforms_stack.dart delete mode 100644 lib/core/widgets/platforms_badge.dart diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 5fae3f2..07a264c 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -87,6 +87,7 @@ sealed class AppTheme { ), bodyMedium: TextStyle(color: greyColor), bodySmall: TextStyle(color: greyColor, fontSize: bodySmallFontSize), + labelSmall: TextStyle(color: textColor, fontSize: bodySmallFontSize), ), iconTheme: IconThemeData(color: greyColor), snackBarTheme: SnackBarThemeData( diff --git a/lib/core/widgets/platforms/available_platforms_badge.dart b/lib/core/widgets/platforms/available_platforms_badge.dart new file mode 100644 index 0000000..a90a266 --- /dev/null +++ b/lib/core/widgets/platforms/available_platforms_badge.dart @@ -0,0 +1,135 @@ +import 'package:application/core/widgets/app_blur_badge.dart'; +import 'package:application/core/widgets/app_skeleton.dart'; +import 'package:application/core/widgets/cached_network_image.dart'; +import 'package:application/core/widgets/platforms/platforms_stack.dart'; +import 'package:application/l10n/app_localizations.dart'; +import 'package:application/models/platform_model.dart'; +import 'package:application/models/source_model.dart'; +import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AvailablePlatformsBadge extends StatelessWidget { + const AvailablePlatformsBadge( + this._globalKey, { + super.key, + required this.sources, + }); + + final GlobalKey _globalKey; + final Iterable sources; + + Set get _platforms => + sources.map((source) => source.platform).toSet(); + + Future _launch(String url) async { + final modes = [ + .externalNonBrowserApplication, + .externalApplication, + .platformDefault, + ]; + + debugPrint('Launch url...'); + + for (final mode in modes) { + try { + if (await launchUrl(.parse(url), mode: mode)) { + return true; + } + } on PlatformException catch (e) { + debugPrint('Failed to launch URL with mode $mode: $e'); + } + } + + return false; + } + + @override + Widget build(BuildContext context) { + final platforms = _platforms; + final labelSmall = Theme.of(context).textTheme.labelSmall; + + return Positioned( + left: 8, + bottom: 8, + child: GestureDetector( + key: _globalKey, + onTap: () async { + if (platforms.length == 1) { + _launch(sources.first.url); + return; + } + + final renderBox = + _globalKey.currentContext?.findRenderObject() as RenderBox?; + final overlayBox = + Navigator.of(context).overlay?.context.findRenderObject() + as RenderBox?; + + if (renderBox == null || overlayBox == null) return; + + final topLeft = renderBox.localToGlobal(.zero, ancestor: overlayBox); + final bottomRight = renderBox.localToGlobal( + renderBox.size.bottomRight(.zero), + ancestor: overlayBox, + ); + final menuWidth = renderBox.size.width < 240 + ? 240.0 + : renderBox.size.width; + + await showMenu( + context: context, + position: .fromRect( + .fromPoints(topLeft, bottomRight), + Offset.zero & overlayBox.size, + ), + constraints: .tightFor(width: menuWidth), + items: [ + for (final platform in platforms) + PopupMenuItem( + onTap: () { + final source = sources.singleWhere( + (source) => source.platform.name == platform.name, + ); + _launch(source.url); + }, + child: ListTile( + leading: ClipOval( + child: CachedNetworkImage( + 'https://www.shikkanime.fr/assets/img/platforms/${platform.image}', + width: 16, + height: 16, + fit: .cover, + loading: const AppSkeleton(), + error: const AppSkeleton(), + ), + ), + title: Text(platform.name), + trailing: const Icon(Icons.north_east), + ), + ), + ], + ); + }, + child: AppBlurBadge( + child: Flex( + direction: .horizontal, + spacing: 4, + children: [ + Icon( + Icons.open_in_new, + color: labelSmall?.color, + size: labelSmall?.fontSize, + ), + Text( + AppLocalizations.of(context)!.availableOn, + style: labelSmall, + ), + PlatformsStack(platforms: platforms), + ], + ), + ), + ), + ); + } +} diff --git a/lib/core/widgets/platforms/platforms_badge.dart b/lib/core/widgets/platforms/platforms_badge.dart new file mode 100644 index 0000000..92a79b0 --- /dev/null +++ b/lib/core/widgets/platforms/platforms_badge.dart @@ -0,0 +1,19 @@ +import 'package:application/core/widgets/app_blur_badge.dart'; +import 'package:application/core/widgets/platforms/platforms_stack.dart'; +import 'package:application/models/platform_model.dart'; +import 'package:material_ui/material_ui.dart'; + +class PlatformsBadge extends StatelessWidget { + const PlatformsBadge({super.key, required this.platforms}); + + final Iterable platforms; + + @override + Widget build(BuildContext context) { + return Positioned( + top: 8, + left: 8, + child: AppBlurBadge(child: PlatformsStack(platforms: platforms)), + ); + } +} diff --git a/lib/core/widgets/platforms/platforms_stack.dart b/lib/core/widgets/platforms/platforms_stack.dart new file mode 100644 index 0000000..bd78da1 --- /dev/null +++ b/lib/core/widgets/platforms/platforms_stack.dart @@ -0,0 +1,48 @@ +import 'package:application/core/widgets/app_skeleton.dart'; +import 'package:application/core/widgets/cached_network_image.dart'; +import 'package:application/models/platform_model.dart'; +import 'package:material_ui/material_ui.dart'; + +class PlatformsStack extends StatelessWidget { + const PlatformsStack({ + super.key, + required this.platforms, + this.width = 16, + this.height = 16, + this.widthOffset = 7.5, + this.heightOffset = 5, + }); + + final Iterable platforms; + final double width; + final double height; + final double widthOffset; + final double heightOffset; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width + (platforms.length - 1) * widthOffset, + height: height + (platforms.length - 1) * heightOffset, + child: Stack( + children: [ + for (final (index, platform) in platforms.indexed) + Positioned( + left: index * widthOffset, + top: index * heightOffset, + child: ClipOval( + child: CachedNetworkImage( + 'https://www.shikkanime.fr/assets/img/platforms/${platform.image}', + width: width, + height: height, + fit: .cover, + loading: const AppSkeleton(), + error: const AppSkeleton(), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/core/widgets/platforms_badge.dart b/lib/core/widgets/platforms_badge.dart deleted file mode 100644 index 145e479..0000000 --- a/lib/core/widgets/platforms_badge.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:application/core/widgets/app_blur_badge.dart'; -import 'package:application/core/widgets/app_skeleton.dart'; -import 'package:application/core/widgets/cached_network_image.dart'; -import 'package:application/models/platform_model.dart'; -import 'package:material_ui/material_ui.dart'; - -class PlatformsBadge extends StatelessWidget { - const PlatformsBadge({ - super.key, - required this.platforms, - this.width = 16, - this.height = 16, - this.widthOffset = 7.5, - this.heightOffset = 5, - }); - - final Iterable platforms; - final double width; - final double height; - final double widthOffset; - final double heightOffset; - - @override - Widget build(BuildContext context) { - return AppBlurBadge( - child: SizedBox( - width: width + (platforms.length - 1) * widthOffset, - height: height + (platforms.length - 1) * heightOffset, - child: Stack( - children: [ - for (final (index, platform) in platforms.indexed) - Positioned( - left: index * widthOffset, - top: index * heightOffset, - child: ClipOval( - child: CachedNetworkImage( - 'https://www.shikkanime.fr/assets/img/platforms/${platform.image}', - width: width, - height: height, - fit: .cover, - loading: const AppSkeleton(), - error: const AppSkeleton(), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index b2a5a28..387607c 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -6,6 +6,7 @@ "search": "Rechercher...", "warningCatalog": "Le catalogue s'enrichit progressivement : certains animés plus anciens peuvent ne pas encore apparaître", "calendar": "Calendrier", + "availableOn": "Disponible sur", "langType": "{type, select, subtitles{Sous-titrage} voice{Doublage} other{Inconnu}}", "episodeType": "{type, select, episode{Épisode} film{Film} special{Spécial} summary{Épisode récapitulatif} spinOff{Spin-off} other{Inconnu}}", "season": "{type, select, spring{Printemps} summer{Été} autumn{Automne} winter{Hiver} other{Inconnu}}", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e985fb6..2da899f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -130,6 +130,12 @@ abstract class AppLocalizations { /// **'Calendrier'** String get calendar; + /// No description provided for @availableOn. + /// + /// In fr, this message translates to: + /// **'Disponible sur'** + String get availableOn; + /// No description provided for @langType. /// /// In fr, this message translates to: diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9bf9f87..261ce20 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -28,6 +28,9 @@ class AppLocalizationsFr extends AppLocalizations { @override String get calendar => 'Calendrier'; + @override + String get availableOn => 'Disponible sur'; + @override String langType(String type) { String _temp0 = intl.Intl.selectLogic(type, { diff --git a/lib/main.dart b/lib/main.dart index 3f96087..721669e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -116,7 +116,7 @@ class _MyHomePageState extends State { appBar: const _AppAppBar(), body: SafeArea( child: Padding( - padding: const .symmetric(horizontal: 8), + padding: const .only(left: 8, right: 8, bottom: 8), child: PageView( controller: viewModel.controller, onPageChanged: viewModel.onChange, diff --git a/lib/models/source_model.dart b/lib/models/source_model.dart index b3b454b..88f891d 100644 --- a/lib/models/source_model.dart +++ b/lib/models/source_model.dart @@ -7,9 +7,10 @@ part 'source_model.g.dart'; @JsonSerializable(createToJson: false) class SourceModel { final PlatformModel platform; + final String url; final LangType langType; - SourceModel(this.platform, this.langType); + SourceModel(this.platform, this.url, this.langType); factory SourceModel.fromJson(Map json) => _$SourceModelFromJson(json); diff --git a/lib/models/source_model.g.dart b/lib/models/source_model.g.dart index ba474c3..3c4f91f 100644 --- a/lib/models/source_model.g.dart +++ b/lib/models/source_model.g.dart @@ -8,6 +8,7 @@ part of 'source_model.dart'; SourceModel _$SourceModelFromJson(Map json) => SourceModel( PlatformModel.fromJson(json['platform'] as Map), + json['url'] as String, $enumDecode(_$LangTypeEnumMap, json['langType']), ); diff --git a/lib/views/catalog/catalog_view.dart b/lib/views/catalog/catalog_view.dart index 60d2bca..a5dbc66 100644 --- a/lib/views/catalog/catalog_view.dart +++ b/lib/views/catalog/catalog_view.dart @@ -85,9 +85,13 @@ class _CatalogViewState extends State { mainAxisAlignment: .center, spacing: 8, children: [ - const Icon(Icons.warning), + const Icon(Icons.warning, color: Colors.black), Flexible( - child: Text(AppLocalizations.of(context)!.warningCatalog), + child: Text( + AppLocalizations.of(context)!.warningCatalog, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.black), + ), ), ], ), @@ -104,11 +108,9 @@ class _CatalogViewState extends State { return RefreshIndicator( onRefresh: () => viewModel.init(bypass: true), - child: MasonryGridView.builder( + child: AlignedGridView.count( + crossAxisCount: crossAxisCount, controller: viewModel.scrollController, - gridDelegate: SliverSimpleGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - ), mainAxisSpacing: 8, crossAxisSpacing: 8, itemCount: viewModel.length, @@ -120,7 +122,10 @@ class _CatalogViewState extends State { return const AnimeSkeletonCard(); } - return AnimeCard(anime); + return Align( + alignment: .topCenter, + child: AnimeCard(anime), + ); }, ), ); diff --git a/lib/views/catalog/widgets/anime_card.dart b/lib/views/catalog/widgets/anime_card.dart index 9282c48..3098b64 100644 --- a/lib/views/catalog/widgets/anime_card.dart +++ b/lib/views/catalog/widgets/anime_card.dart @@ -3,7 +3,7 @@ import 'package:application/core/widgets/app_skeleton.dart'; import 'package:application/core/widgets/cached_network_image.dart'; import 'package:application/core/widgets/lang_types/lang_type_label.dart'; import 'package:application/core/widgets/app_card.dart'; -import 'package:application/core/widgets/platforms_badge.dart'; +import 'package:application/core/widgets/platforms/platforms_badge.dart'; import 'package:application/models/anime_model.dart'; import 'package:material_ui/material_ui.dart'; @@ -32,14 +32,10 @@ class AnimeCard extends StatelessWidget { loading: const AppSkeleton(), error: const AppSkeleton(), ), - Positioned( - top: 8, - right: 8, - child: PlatformsBadge( - platforms: _anime.platformIds - .map((source) => source.platform) - .toSet(), - ), + PlatformsBadge( + platforms: _anime.platformIds + .map((source) => source.platform) + .toSet(), ), ], ), @@ -53,20 +49,6 @@ class AnimeCard extends StatelessWidget { style: Theme.of(context).textTheme.bodyLarge, ), ..._anime.langTypes.map(LangTypeLabel.new), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () {}, - style: ElevatedButton.styleFrom( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - ), - child: const Flex( - direction: .horizontal, - mainAxisSize: .max, - mainAxisAlignment: .center, - spacing: 4, - children: [Icon(Icons.add), Text('Ajouter')], - ), - ), ], ), ); diff --git a/lib/views/grouped_episodes/grouped_episodes_view.dart b/lib/views/grouped_episodes/grouped_episodes_view.dart index 7b0b78d..abe5d98 100644 --- a/lib/views/grouped_episodes/grouped_episodes_view.dart +++ b/lib/views/grouped_episodes/grouped_episodes_view.dart @@ -44,11 +44,9 @@ class _GroupedEpisodesViewState extends State { return RefreshIndicator( onRefresh: () => viewModel.init(bypass: true), - child: MasonryGridView.builder( + child: AlignedGridView.count( + crossAxisCount: crossAxisCount, controller: viewModel.scrollController, - gridDelegate: SliverSimpleGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - ), mainAxisSpacing: 8, crossAxisSpacing: 8, itemCount: viewModel.length, @@ -60,7 +58,10 @@ class _GroupedEpisodesViewState extends State { return const GroupedEpisodeSkeletonCard(); } - return GroupedEpisodeCard(groupedEpisode); + return Align( + alignment: .topCenter, + child: GroupedEpisodeCard(groupedEpisode), + ); }, ), ); diff --git a/lib/views/grouped_episodes/widgets/grouped_episode_card.dart b/lib/views/grouped_episodes/widgets/grouped_episode_card.dart index 00e06fb..7fdcff8 100644 --- a/lib/views/grouped_episodes/widgets/grouped_episode_card.dart +++ b/lib/views/grouped_episodes/widgets/grouped_episode_card.dart @@ -3,7 +3,7 @@ import 'package:application/core/widgets/app_skeleton.dart'; import 'package:application/core/widgets/cached_network_image.dart'; import 'package:application/core/widgets/lang_types/lang_type_label.dart'; import 'package:application/core/widgets/app_card.dart'; -import 'package:application/core/widgets/platforms_badge.dart'; +import 'package:application/core/widgets/platforms/available_platforms_badge.dart'; import 'package:application/l10n/app_localizations.dart'; import 'package:application/models/grouped_episode_model.dart'; import 'package:material_ui/material_ui.dart'; @@ -33,14 +33,9 @@ class GroupedEpisodeCard extends StatelessWidget { loading: const AppSkeleton(), error: const AppSkeleton(), ), - Positioned( - top: 8, - right: 8, - child: PlatformsBadge( - platforms: _groupedEpisode.sources - .map((source) => source.platform) - .toSet(), - ), + AvailablePlatformsBadge( + GlobalKey(), + sources: _groupedEpisode.sources, ), ], ), diff --git a/lib/views/weekly/weekly_view.dart b/lib/views/weekly/weekly_view.dart index 51eaa43..07123ed 100644 --- a/lib/views/weekly/weekly_view.dart +++ b/lib/views/weekly/weekly_view.dart @@ -68,10 +68,8 @@ class _WeeklyViewState extends State { return RefreshIndicator( onRefresh: () => viewModel.init(bypass: true), - child: MasonryGridView.builder( - gridDelegate: SliverSimpleGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - ), + child: AlignedGridView.count( + crossAxisCount: crossAxisCount, mainAxisSpacing: 8, crossAxisSpacing: 8, itemCount: viewModel.length, @@ -83,7 +81,10 @@ class _WeeklyViewState extends State { return const ReleaseSkeletonCard(); } - return ReleaseCard(release); + return Align( + alignment: .topCenter, + child: ReleaseCard(release), + ); }, ), ); diff --git a/lib/views/weekly/widgets/release_card.dart b/lib/views/weekly/widgets/release_card.dart index 108dc52..4009ba7 100644 --- a/lib/views/weekly/widgets/release_card.dart +++ b/lib/views/weekly/widgets/release_card.dart @@ -3,7 +3,6 @@ import 'package:application/core/widgets/app_skeleton.dart'; import 'package:application/core/widgets/cached_network_image.dart'; import 'package:application/core/widgets/lang_types/lang_type_label.dart'; import 'package:application/core/widgets/app_card.dart'; -import 'package:application/core/widgets/platforms_badge.dart'; import 'package:application/l10n/app_localizations.dart'; import 'package:application/models/weekly_release_model.dart'; import 'package:material_ui/material_ui.dart'; @@ -42,11 +41,6 @@ class ReleaseCard extends StatelessWidget { loading: const AppSkeleton(), error: const AppSkeleton(), ), - Positioned( - top: 8, - right: 8, - child: PlatformsBadge(platforms: _release.platforms), - ), ], ), ), From 68cf715723947a6f91610fd5ecae98a235414385 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Thu, 20 Aug 2026 13:47:32 +0200 Subject: [PATCH 2/5] feat: enhance logging and UI components, add BASE_URL to env config --- .env.example | 1 + lib/core/config/env_config.dart | 5 + lib/core/logger/app_logger.dart | 11 ++ lib/core/network/http_client.dart | 8 +- lib/core/theme/app_theme.dart | 4 + lib/core/theme/app_theme_colors.dart | 9 ++ lib/core/widgets/app_blur_badge.dart | 10 +- .../widgets/app_elevated_dropdown_button.dart | 85 +++++------- .../platforms/available_platforms_badge.dart | 128 +++++++----------- .../widgets/platforms/platform_image.dart | 27 ++++ .../widgets/platforms/platforms_stack.dart | 14 +- lib/core/widgets/show_app_menu.dart | 32 +++++ lib/models/grouped_episode_model.dart | 6 +- lib/models/grouped_episode_model.g.dart | 1 + lib/viewmodels/anime_view_model.dart | 20 ++- .../grouped_episode_view_model.dart | 3 +- lib/viewmodels/simulcast_view_model.dart | 3 +- lib/viewmodels/weekly_view_model.dart | 5 +- lib/views/catalog/catalog_view.dart | 12 +- .../widgets/simulcast_dropdown_button.dart | 1 - .../widgets/grouped_episode_card.dart | 7 +- lib/views/weekly/widgets/release_card.dart | 10 ++ 22 files changed, 230 insertions(+), 172 deletions(-) create mode 100644 lib/core/logger/app_logger.dart create mode 100644 lib/core/widgets/platforms/platform_image.dart create mode 100644 lib/core/widgets/show_app_menu.dart diff --git a/.env.example b/.env.example index 2f15ff6..36c6a0a 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ # Copy this file to .env.dev or .env.prod and adjust variables accordingly API_BASE_URL=https://api.shikkanime.fr +BASE_URL=https://www.shikkanime.fr \ No newline at end of file diff --git a/lib/core/config/env_config.dart b/lib/core/config/env_config.dart index db454eb..53a2a98 100644 --- a/lib/core/config/env_config.dart +++ b/lib/core/config/env_config.dart @@ -3,4 +3,9 @@ abstract final class EnvConfig { 'API_BASE_URL', defaultValue: 'http://localhost:37100/api', ); + + static const baseUrl = String.fromEnvironment( + 'BASE_URL', + defaultValue: 'http://localhost:37100', + ); } diff --git a/lib/core/logger/app_logger.dart b/lib/core/logger/app_logger.dart new file mode 100644 index 0000000..d48e327 --- /dev/null +++ b/lib/core/logger/app_logger.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; + +class AppLogger { + static void print(String message) { + final entry = '${DateTime.now().toIso8601String()} - $message'; + + if (kDebugMode) { + debugPrint(entry); + } + } +} diff --git a/lib/core/network/http_client.dart b/lib/core/network/http_client.dart index c466ae4..570e09c 100644 --- a/lib/core/network/http_client.dart +++ b/lib/core/network/http_client.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'package:application/core/config/env_config.dart'; +import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; -import 'package:material_ui/material_ui.dart'; import 'package:http/http.dart' as http; class HttpClient { @@ -42,13 +42,15 @@ class HttpClient { try { final response = await request().timeout(timeout); stopWatch.stop(); - debugPrint( + AppLogger.print( 'Request to ${response.request?.url} took ${stopWatch.elapsedMilliseconds} ms', ); return _checkStatus(response); } on Exception catch (e) { stopWatch.stop(); - debugPrint('Request failed after ${stopWatch.elapsedMilliseconds} ms'); + AppLogger.print( + 'Request failed after ${stopWatch.elapsedMilliseconds} ms', + ); return ApiFailure(e.toString()); } } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 07a264c..178a409 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -10,6 +10,7 @@ sealed class AppTheme { canvasColor: Colors.white, textColor: Colors.black, inverseTextColor: Colors.white, + warningTextColor: Colors.black, greyColor: Colors.grey[800]!, snackBarBackgroundColor: Colors.white, elevatedButtonBackgroundColor: Colors.white, @@ -24,6 +25,7 @@ sealed class AppTheme { canvasColor: const Color(0xff161616), textColor: Colors.white, inverseTextColor: Colors.black, + warningTextColor: Colors.black, greyColor: Colors.grey[400]!, snackBarBackgroundColor: Colors.grey[900]!, elevatedButtonBackgroundColor: const Color(0xff282828), @@ -38,6 +40,7 @@ sealed class AppTheme { required Color canvasColor, required Color textColor, required Color inverseTextColor, + required Color warningTextColor, required Color greyColor, required Color snackBarBackgroundColor, required Color elevatedButtonBackgroundColor, @@ -61,6 +64,7 @@ sealed class AppTheme { extensions: [ AppThemeExtension( inverseTextColor: inverseTextColor, + warningTextColor: warningTextColor, iconImage: iconImage, ), ], diff --git a/lib/core/theme/app_theme_colors.dart b/lib/core/theme/app_theme_colors.dart index 0225708..9d5b2b7 100644 --- a/lib/core/theme/app_theme_colors.dart +++ b/lib/core/theme/app_theme_colors.dart @@ -4,19 +4,23 @@ import 'package:material_ui/material_ui.dart'; class AppThemeExtension extends ThemeExtension { const AppThemeExtension({ required this.inverseTextColor, + required this.warningTextColor, required this.iconImage, }); final Color inverseTextColor; + final Color warningTextColor; final ImageProvider iconImage; @override ThemeExtension copyWith({ Color? inverseTextColor, + Color? warningTextColor, ImageProvider? iconImage, }) { return AppThemeExtension( inverseTextColor: inverseTextColor ?? this.inverseTextColor, + warningTextColor: warningTextColor ?? this.warningTextColor, iconImage: iconImage ?? this.iconImage, ); } @@ -36,6 +40,11 @@ class AppThemeExtension extends ThemeExtension { other.inverseTextColor, t, )!, + warningTextColor: Color.lerp( + warningTextColor, + other.warningTextColor, + t, + )!, iconImage: iconImage, ); } diff --git a/lib/core/widgets/app_blur_badge.dart b/lib/core/widgets/app_blur_badge.dart index c6bcec9..81898c8 100644 --- a/lib/core/widgets/app_blur_badge.dart +++ b/lib/core/widgets/app_blur_badge.dart @@ -8,14 +8,18 @@ class AppBlurBadge extends StatelessWidget { @override Widget build(BuildContext context) { final badgeBackgroundColor = Theme.of(context).scaffoldBackgroundColor - .withValues(alpha: 0.3); + .withValues(alpha: 0.4); return ClipRRect( - borderRadius: const .all(.circular(16)), + borderRadius: const .all(.circular(8)), child: BackdropFilter( filter: .blur(sigmaX: 8, sigmaY: 8), child: DecoratedBox( - decoration: BoxDecoration(color: badgeBackgroundColor), + decoration: BoxDecoration( + color: badgeBackgroundColor, + border: .all(color: badgeBackgroundColor, width: 0.5), + borderRadius: const .all(.circular(8)), + ), child: Padding( padding: const .symmetric(horizontal: 8, vertical: 4), child: child, diff --git a/lib/core/widgets/app_elevated_dropdown_button.dart b/lib/core/widgets/app_elevated_dropdown_button.dart index f7d4c4b..5b971f7 100644 --- a/lib/core/widgets/app_elevated_dropdown_button.dart +++ b/lib/core/widgets/app_elevated_dropdown_button.dart @@ -1,15 +1,14 @@ +import 'package:application/core/widgets/show_app_menu.dart'; import 'package:material_ui/material_ui.dart'; class AppElevatedDropdownButton extends StatelessWidget { - const AppElevatedDropdownButton( - this._globalKey, { + const AppElevatedDropdownButton({ super.key, this.value, required this.items, required this.onChanged, }); - final GlobalKey _globalKey; final T? value; final List> items; final ValueChanged onChanged; @@ -26,57 +25,37 @@ class AppElevatedDropdownButton extends StatelessWidget { Widget build(BuildContext context) { final selectedItem = _getSelectedItem(); - return ElevatedButton( - key: _globalKey, - onPressed: () async { - final renderBox = - _globalKey.currentContext?.findRenderObject() as RenderBox?; - final overlayBox = - Navigator.of(context).overlay?.context.findRenderObject() - as RenderBox?; - - if (renderBox == null || overlayBox == null || items.isEmpty) return; - - final topLeft = renderBox.localToGlobal(.zero, ancestor: overlayBox); - final bottomRight = renderBox.localToGlobal( - renderBox.size.bottomRight(.zero), - ancestor: overlayBox, - ); - final maxHeight = MediaQuery.heightOf(context) * 0.3; - final menuHeight = (items.length * kMinInteractiveDimension) - .clamp(0.0, maxHeight) - .toDouble(); - final menuWidth = renderBox.size.width < 240 - ? 240.0 - : renderBox.size.width; - - final selection = await showMenu<_MenuSelection>( - context: context, - position: .fromRect( - .fromPoints(topLeft, bottomRight), - Offset.zero & overlayBox.size, - ), - constraints: .tightFor(width: menuWidth), - items: [ - _LazyPopupMenuEntry( - height: menuHeight, - selectedValue: value, - items: items, - ), + return Builder( + builder: (context) => ElevatedButton( + onPressed: () async { + final maxHeight = MediaQuery.heightOf(context) * 0.3; + final menuHeight = (items.length * kMinInteractiveDimension) + .clamp(0.0, maxHeight) + .toDouble(); + + final selection = await showAppPopupMenu<_MenuSelection>( + context: context, + items: [ + _LazyPopupMenuEntry( + height: menuHeight, + selectedValue: value, + items: items, + ), + ], + ); + + if (selection != null) { + onChanged(selection.value); + } + }, + child: Flex( + spacing: 8, + direction: .horizontal, + children: [ + if (selectedItem != null) selectedItem.child, + const Icon(Icons.arrow_drop_down), ], - ); - - if (selection != null) { - onChanged(selection.value); - } - }, - child: Flex( - spacing: 8, - direction: .horizontal, - children: [ - if (selectedItem != null) selectedItem.child, - const Icon(Icons.arrow_drop_down), - ], + ), ), ); } diff --git a/lib/core/widgets/platforms/available_platforms_badge.dart b/lib/core/widgets/platforms/available_platforms_badge.dart index a90a266..50b7054 100644 --- a/lib/core/widgets/platforms/available_platforms_badge.dart +++ b/lib/core/widgets/platforms/available_platforms_badge.dart @@ -1,7 +1,8 @@ +import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/widgets/app_blur_badge.dart'; -import 'package:application/core/widgets/app_skeleton.dart'; -import 'package:application/core/widgets/cached_network_image.dart'; +import 'package:application/core/widgets/platforms/platform_image.dart'; import 'package:application/core/widgets/platforms/platforms_stack.dart'; +import 'package:application/core/widgets/show_app_menu.dart'; import 'package:application/l10n/app_localizations.dart'; import 'package:application/models/platform_model.dart'; import 'package:application/models/source_model.dart'; @@ -10,13 +11,8 @@ import 'package:material_ui/material_ui.dart'; import 'package:url_launcher/url_launcher.dart'; class AvailablePlatformsBadge extends StatelessWidget { - const AvailablePlatformsBadge( - this._globalKey, { - super.key, - required this.sources, - }); + const AvailablePlatformsBadge({super.key, required this.sources}); - final GlobalKey _globalKey; final Iterable sources; Set get _platforms => @@ -29,7 +25,7 @@ class AvailablePlatformsBadge extends StatelessWidget { .platformDefault, ]; - debugPrint('Launch url...'); + AppLogger.print('Launch url: $url...'); for (final mode in modes) { try { @@ -37,7 +33,7 @@ class AvailablePlatformsBadge extends StatelessWidget { return true; } } on PlatformException catch (e) { - debugPrint('Failed to launch URL with mode $mode: $e'); + AppLogger.print('Failed to launch URL with mode $mode: $e'); } } @@ -52,81 +48,51 @@ class AvailablePlatformsBadge extends StatelessWidget { return Positioned( left: 8, bottom: 8, - child: GestureDetector( - key: _globalKey, - onTap: () async { - if (platforms.length == 1) { - _launch(sources.first.url); - return; - } + child: Builder( + builder: (context) => GestureDetector( + onTap: () { + if (platforms.length == 1) { + _launch(sources.first.url); + return; + } - final renderBox = - _globalKey.currentContext?.findRenderObject() as RenderBox?; - final overlayBox = - Navigator.of(context).overlay?.context.findRenderObject() - as RenderBox?; - - if (renderBox == null || overlayBox == null) return; - - final topLeft = renderBox.localToGlobal(.zero, ancestor: overlayBox); - final bottomRight = renderBox.localToGlobal( - renderBox.size.bottomRight(.zero), - ancestor: overlayBox, - ); - final menuWidth = renderBox.size.width < 240 - ? 240.0 - : renderBox.size.width; - - await showMenu( - context: context, - position: .fromRect( - .fromPoints(topLeft, bottomRight), - Offset.zero & overlayBox.size, - ), - constraints: .tightFor(width: menuWidth), - items: [ - for (final platform in platforms) - PopupMenuItem( - onTap: () { - final source = sources.singleWhere( - (source) => source.platform.name == platform.name, - ); - _launch(source.url); - }, - child: ListTile( - leading: ClipOval( - child: CachedNetworkImage( - 'https://www.shikkanime.fr/assets/img/platforms/${platform.image}', - width: 16, - height: 16, - fit: .cover, - loading: const AppSkeleton(), - error: const AppSkeleton(), - ), + showAppPopupMenu( + context: context, + items: [ + for (final platform in platforms) + PopupMenuItem( + onTap: () { + final source = sources.singleWhere( + (source) => source.platform.name == platform.name, + ); + _launch(source.url); + }, + child: ListTile( + leading: PlatformImage(platform, width: 16, height: 16), + title: Text(platform.name), + trailing: const Icon(Icons.north_east), ), - title: Text(platform.name), - trailing: const Icon(Icons.north_east), ), + ], + ); + }, + child: AppBlurBadge( + child: Flex( + direction: .horizontal, + spacing: 4, + children: [ + Icon( + Icons.open_in_new, + color: labelSmall?.color, + size: labelSmall?.fontSize, + ), + Text( + AppLocalizations.of(context)!.availableOn, + style: labelSmall, ), - ], - ); - }, - child: AppBlurBadge( - child: Flex( - direction: .horizontal, - spacing: 4, - children: [ - Icon( - Icons.open_in_new, - color: labelSmall?.color, - size: labelSmall?.fontSize, - ), - Text( - AppLocalizations.of(context)!.availableOn, - style: labelSmall, - ), - PlatformsStack(platforms: platforms), - ], + PlatformsStack(platforms: platforms), + ], + ), ), ), ), diff --git a/lib/core/widgets/platforms/platform_image.dart b/lib/core/widgets/platforms/platform_image.dart new file mode 100644 index 0000000..f466f1c --- /dev/null +++ b/lib/core/widgets/platforms/platform_image.dart @@ -0,0 +1,27 @@ +import 'package:application/core/config/env_config.dart'; +import 'package:application/core/widgets/app_skeleton.dart'; +import 'package:application/core/widgets/cached_network_image.dart'; +import 'package:application/models/platform_model.dart'; +import 'package:flutter/widgets.dart'; + +class PlatformImage extends StatelessWidget { + const PlatformImage(this._platform, {super.key, this.width, this.height}); + + final PlatformModel _platform; + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + return ClipOval( + child: CachedNetworkImage( + '${EnvConfig.baseUrl}/assets/img/platforms/${_platform.image}', + width: width, + height: height, + fit: .cover, + loading: const AppSkeleton(), + error: const AppSkeleton(), + ), + ); + } +} diff --git a/lib/core/widgets/platforms/platforms_stack.dart b/lib/core/widgets/platforms/platforms_stack.dart index bd78da1..85e23fc 100644 --- a/lib/core/widgets/platforms/platforms_stack.dart +++ b/lib/core/widgets/platforms/platforms_stack.dart @@ -1,5 +1,4 @@ -import 'package:application/core/widgets/app_skeleton.dart'; -import 'package:application/core/widgets/cached_network_image.dart'; +import 'package:application/core/widgets/platforms/platform_image.dart'; import 'package:application/models/platform_model.dart'; import 'package:material_ui/material_ui.dart'; @@ -30,16 +29,7 @@ class PlatformsStack extends StatelessWidget { Positioned( left: index * widthOffset, top: index * heightOffset, - child: ClipOval( - child: CachedNetworkImage( - 'https://www.shikkanime.fr/assets/img/platforms/${platform.image}', - width: width, - height: height, - fit: .cover, - loading: const AppSkeleton(), - error: const AppSkeleton(), - ), - ), + child: PlatformImage(platform, width: width, height: height), ), ], ), diff --git a/lib/core/widgets/show_app_menu.dart b/lib/core/widgets/show_app_menu.dart new file mode 100644 index 0000000..c0acc7b --- /dev/null +++ b/lib/core/widgets/show_app_menu.dart @@ -0,0 +1,32 @@ +import 'package:material_ui/material_ui.dart'; + +Future showAppPopupMenu({ + required BuildContext context, + required List> items, + double minWidth = 240, +}) async { + final renderBox = context.findRenderObject() as RenderBox?; + final overlayBox = + Navigator.of(context).overlay?.context.findRenderObject() as RenderBox?; + + if (renderBox == null || overlayBox == null || items.isEmpty) { + return null; + } + + final topLeft = renderBox.localToGlobal(.zero, ancestor: overlayBox); + final bottomRight = renderBox.localToGlobal( + renderBox.size.bottomRight(.zero), + ancestor: overlayBox, + ); + final menuWidth = renderBox.size.width < 240 ? 240.0 : renderBox.size.width; + + return await showMenu( + context: context, + position: .fromRect( + .fromPoints(topLeft, bottomRight), + Offset.zero & overlayBox.size, + ), + constraints: .tightFor(width: menuWidth), + items: items, + ); +} diff --git a/lib/models/grouped_episode_model.dart b/lib/models/grouped_episode_model.dart index 9db6be5..670ece5 100644 --- a/lib/models/grouped_episode_model.dart +++ b/lib/models/grouped_episode_model.dart @@ -13,6 +13,7 @@ class GroupedEpisodeModel { final String number; final List mappings; final List sources; + final int? duration; GroupedEpisodeModel( this.anime, @@ -20,8 +21,9 @@ class GroupedEpisodeModel { this.episodeType, this.number, this.mappings, - this.sources, - ); + this.sources, { + this.duration + }); factory GroupedEpisodeModel.fromJson(Map json) => _$GroupedEpisodeModelFromJson(json); diff --git a/lib/models/grouped_episode_model.g.dart b/lib/models/grouped_episode_model.g.dart index 3124156..695a233 100644 --- a/lib/models/grouped_episode_model.g.dart +++ b/lib/models/grouped_episode_model.g.dart @@ -16,6 +16,7 @@ GroupedEpisodeModel _$GroupedEpisodeModelFromJson(Map json) => (json['sources'] as List) .map((e) => SourceModel.fromJson(e as Map)) .toList(), + duration: (json['duration'] as num?)?.toInt(), ); const _$EpisodeTypeEnumMap = { diff --git a/lib/viewmodels/anime_view_model.dart b/lib/viewmodels/anime_view_model.dart index 6325b46..29ae6ac 100644 --- a/lib/viewmodels/anime_view_model.dart +++ b/lib/viewmodels/anime_view_model.dart @@ -1,3 +1,4 @@ +import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; import 'package:application/models/anime_model.dart'; import 'package:application/models/lang_type.dart'; @@ -50,6 +51,13 @@ class AnimeViewModel extends ChangeNotifier implements LangTypeFilterViewModel { ? simulcasts.firstWhere((s) => s.uuid == simulcastUuid) : null; _simulcastSelectionInitialized = true; + + if (simulcastUuid == null) { + AppLogger.print('Selected simulcast: All'); + } else { + AppLogger.print('Selected simulcast: ${_selectedSimulcast?.season} - ${_selectedSimulcast?.year}'); + } + notifyListeners(); init(bypass: true); } @@ -70,11 +78,13 @@ class AnimeViewModel extends ChangeNotifier implements LangTypeFilterViewModel { } Future init({bool bypass = false}) async { - await _simulcastViewModel.init(bypass: bypass); + if (!_simulcastSelectionInitialized) { + await _simulcastViewModel.init(bypass: bypass); - if (!_simulcastSelectionInitialized && simulcasts.isNotEmpty) { - _selectedSimulcast = simulcasts.first; - _simulcastSelectionInitialized = true; + if (simulcasts.isNotEmpty) { + _selectedSimulcast = simulcasts.first; + _simulcastSelectionInitialized = true; + } } if (!bypass && _animes.isNotEmpty) return; @@ -117,7 +127,7 @@ class AnimeViewModel extends ChangeNotifier implements LangTypeFilterViewModel { _canLoadMore = data.total > _animes.length; break; case ApiFailure> failure: - debugPrint( + AppLogger.print( 'Error fetching animes: ${failure.statusCode} - ${failure.error}', ); break; diff --git a/lib/viewmodels/grouped_episode_view_model.dart b/lib/viewmodels/grouped_episode_view_model.dart index 2fe5af5..c232b10 100644 --- a/lib/viewmodels/grouped_episode_view_model.dart +++ b/lib/viewmodels/grouped_episode_view_model.dart @@ -1,3 +1,4 @@ +import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; import 'package:application/models/grouped_episode_model.dart'; import 'package:application/models/lang_type.dart'; @@ -82,7 +83,7 @@ class GroupedEpisodeViewModel extends ChangeNotifier break; case ApiFailure> failure: // Handle error, e.g., log it or show a message to the user - debugPrint( + AppLogger.print( 'Error fetching grouped episodes: ${failure.statusCode} - ${failure.error}', ); break; diff --git a/lib/viewmodels/simulcast_view_model.dart b/lib/viewmodels/simulcast_view_model.dart index 2d83cc9..7d66e12 100644 --- a/lib/viewmodels/simulcast_view_model.dart +++ b/lib/viewmodels/simulcast_view_model.dart @@ -1,3 +1,4 @@ +import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; import 'package:application/models/simulcast_model.dart'; import 'package:application/repositories/simulcast_repository.dart'; @@ -29,7 +30,7 @@ class SimulcastViewModel extends ChangeNotifier { _simulcasts.addAll(data); break; case ApiFailure> failure: - debugPrint( + AppLogger.print( 'Failed to fetch simulcasts: ${failure.error}, status code: ${failure.statusCode}', ); break; diff --git a/lib/viewmodels/weekly_view_model.dart b/lib/viewmodels/weekly_view_model.dart index 780dd92..1f0aabd 100644 --- a/lib/viewmodels/weekly_view_model.dart +++ b/lib/viewmodels/weekly_view_model.dart @@ -1,3 +1,4 @@ +import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; import 'package:application/models/lang_type.dart'; import 'package:application/models/weekly_day_model.dart'; @@ -37,11 +38,13 @@ class WeeklyViewModel extends ChangeNotifier void setPreviousDay() { _selectedDay = (_selectedDay - 1) % 7; + AppLogger.print('Selected day: $_selectedDay'); notifyListeners(); } void setNextDay() { _selectedDay = (_selectedDay + 1) % 7; + AppLogger.print('Selected day: $_selectedDay'); notifyListeners(); } @@ -82,7 +85,7 @@ class WeeklyViewModel extends ChangeNotifier _weekly.addAll(data); break; case ApiFailure> failure: - debugPrint( + AppLogger.print( 'Error fetching weekly: ${failure.statusCode} - ${failure.error}', ); break; diff --git a/lib/views/catalog/catalog_view.dart b/lib/views/catalog/catalog_view.dart index a5dbc66..22b4164 100644 --- a/lib/views/catalog/catalog_view.dart +++ b/lib/views/catalog/catalog_view.dart @@ -37,6 +37,10 @@ class _CatalogViewState extends State { .extension() ?.inverseTextColor; + final warningTextColor = Theme.of(context) + .extension() + ?.warningTextColor; + return Column( mainAxisSize: .max, crossAxisAlignment: .start, @@ -78,19 +82,19 @@ class _CatalogViewState extends State { borderRadius: .all(.circular(24)), ), child: Padding( - padding: const .all(8), + padding: const .symmetric(horizontal: 16, vertical: 8), child: Flex( direction: .horizontal, mainAxisSize: .max, mainAxisAlignment: .center, - spacing: 8, + spacing: 16, children: [ - const Icon(Icons.warning, color: Colors.black), + Icon(Icons.warning, color: warningTextColor), Flexible( child: Text( AppLocalizations.of(context)!.warningCatalog, style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: Colors.black), + ?.copyWith(color: warningTextColor), ), ), ], diff --git a/lib/views/catalog/widgets/simulcast_dropdown_button.dart b/lib/views/catalog/widgets/simulcast_dropdown_button.dart index 6fd4064..60dc1ec 100644 --- a/lib/views/catalog/widgets/simulcast_dropdown_button.dart +++ b/lib/views/catalog/widgets/simulcast_dropdown_button.dart @@ -18,7 +18,6 @@ class SimulcastDropdownButton extends StatelessWidget { @override Widget build(BuildContext context) { return AppElevatedDropdownButton( - GlobalKey(), value: value?.uuid, items: [ AppElevatedPopupMenuEntry( diff --git a/lib/views/grouped_episodes/widgets/grouped_episode_card.dart b/lib/views/grouped_episodes/widgets/grouped_episode_card.dart index 7fdcff8..ae4e18d 100644 --- a/lib/views/grouped_episodes/widgets/grouped_episode_card.dart +++ b/lib/views/grouped_episodes/widgets/grouped_episode_card.dart @@ -1,8 +1,8 @@ import 'package:application/core/config/env_config.dart'; +import 'package:application/core/widgets/app_card.dart'; import 'package:application/core/widgets/app_skeleton.dart'; import 'package:application/core/widgets/cached_network_image.dart'; import 'package:application/core/widgets/lang_types/lang_type_label.dart'; -import 'package:application/core/widgets/app_card.dart'; import 'package:application/core/widgets/platforms/available_platforms_badge.dart'; import 'package:application/l10n/app_localizations.dart'; import 'package:application/models/grouped_episode_model.dart'; @@ -33,10 +33,7 @@ class GroupedEpisodeCard extends StatelessWidget { loading: const AppSkeleton(), error: const AppSkeleton(), ), - AvailablePlatformsBadge( - GlobalKey(), - sources: _groupedEpisode.sources, - ), + AvailablePlatformsBadge(sources: _groupedEpisode.sources), ], ), ), diff --git a/lib/views/weekly/widgets/release_card.dart b/lib/views/weekly/widgets/release_card.dart index 4009ba7..2fe01ba 100644 --- a/lib/views/weekly/widgets/release_card.dart +++ b/lib/views/weekly/widgets/release_card.dart @@ -3,7 +3,10 @@ import 'package:application/core/widgets/app_skeleton.dart'; import 'package:application/core/widgets/cached_network_image.dart'; import 'package:application/core/widgets/lang_types/lang_type_label.dart'; import 'package:application/core/widgets/app_card.dart'; +import 'package:application/core/widgets/platforms/available_platforms_badge.dart'; +import 'package:application/core/widgets/platforms/platforms_badge.dart'; import 'package:application/l10n/app_localizations.dart'; +import 'package:application/models/source_model.dart'; import 'package:application/models/weekly_release_model.dart'; import 'package:material_ui/material_ui.dart'; import 'package:intl/intl.dart'; @@ -13,6 +16,9 @@ class ReleaseCard extends StatelessWidget { final WeeklyReleaseModel _release; + Set get _sources => + _release.mappings?.expand((mapping) => mapping.sources).toSet() ?? {}; + @override Widget build(BuildContext context) { final isRelease = @@ -41,6 +47,10 @@ class ReleaseCard extends StatelessWidget { loading: const AppSkeleton(), error: const AppSkeleton(), ), + if (!isRelease) + PlatformsBadge(platforms: _release.platforms) + else + AvailablePlatformsBadge(sources: _sources), ], ), ), From 8b2b963647cb60a151231886557df5796ef4419c Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Thu, 20 Aug 2026 17:04:33 +0200 Subject: [PATCH 3/5] feat: validate URL before launching and improve error logging --- .../widgets/platforms/available_platforms_badge.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/core/widgets/platforms/available_platforms_badge.dart b/lib/core/widgets/platforms/available_platforms_badge.dart index 50b7054..c9db8fd 100644 --- a/lib/core/widgets/platforms/available_platforms_badge.dart +++ b/lib/core/widgets/platforms/available_platforms_badge.dart @@ -26,10 +26,16 @@ class AvailablePlatformsBadge extends StatelessWidget { ]; AppLogger.print('Launch url: $url...'); + final uri = Uri.tryParse(url); + + if (uri == null || !uri.isScheme('https')) { + AppLogger.print('Invalid URL: $url'); + return false; + } for (final mode in modes) { try { - if (await launchUrl(.parse(url), mode: mode)) { + if (await launchUrl(uri, mode: mode)) { return true; } } on PlatformException catch (e) { @@ -62,7 +68,7 @@ class AvailablePlatformsBadge extends StatelessWidget { for (final platform in platforms) PopupMenuItem( onTap: () { - final source = sources.singleWhere( + final source = sources.firstWhere( (source) => source.platform.name == platform.name, ); _launch(source.url); From 1c16d3bbc9d09221e415265d8c3cb92bec049e52 Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Fri, 21 Aug 2026 13:29:10 +0000 Subject: [PATCH 4/5] refactor: move platform launch policy to injected PlatformLaunchService - Add const PlatformLaunchService (URL validation, launch modes, fallback) - Inject service into GroupedEpisodeViewModel and WeeklyViewModel - Expose onSourcePress event on both ViewModels - Make AvailablePlatformsBadge, GroupedEpisodeCard and ReleaseCard passive widgets receiving onSourcePress closure - Wire PlatformLaunchService in MultiProvider - Apply dart format --- lib/core/config/env_config.dart | 2 +- .../services/platform_launch_service.dart | 34 ++++++++++++ .../platforms/available_platforms_badge.dart | 54 ++++++------------- lib/main.dart | 14 +++-- lib/models/grouped_episode_model.dart | 2 +- lib/viewmodels/anime_view_model.dart | 4 +- .../grouped_episode_view_model.dart | 8 ++- lib/viewmodels/weekly_view_model.dart | 8 ++- .../grouped_episodes_view.dart | 7 ++- .../widgets/grouped_episode_card.dart | 15 ++++-- lib/views/weekly/weekly_view.dart | 7 ++- lib/views/weekly/widgets/release_card.dart | 8 ++- 12 files changed, 108 insertions(+), 55 deletions(-) create mode 100644 lib/core/services/platform_launch_service.dart diff --git a/lib/core/config/env_config.dart b/lib/core/config/env_config.dart index 53a2a98..08785f0 100644 --- a/lib/core/config/env_config.dart +++ b/lib/core/config/env_config.dart @@ -3,7 +3,7 @@ abstract final class EnvConfig { 'API_BASE_URL', defaultValue: 'http://localhost:37100/api', ); - + static const baseUrl = String.fromEnvironment( 'BASE_URL', defaultValue: 'http://localhost:37100', diff --git a/lib/core/services/platform_launch_service.dart b/lib/core/services/platform_launch_service.dart new file mode 100644 index 0000000..fdaae90 --- /dev/null +++ b/lib/core/services/platform_launch_service.dart @@ -0,0 +1,34 @@ +import 'package:application/core/logger/app_logger.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class PlatformLaunchService { + const PlatformLaunchService(); + + static const _modes = [ + .externalNonBrowserApplication, + .externalApplication, + .platformDefault, + ]; + + Future launch(String rawUrl) async { + final Uri? uri = Uri.tryParse(rawUrl); + + if (uri == null || !uri.isScheme('https')) { + AppLogger.print('Invalid URL: $rawUrl'); + return false; + } + + for (final mode in _modes) { + try { + if (await launchUrl(uri, mode: mode)) { + return true; + } + } on PlatformException catch (e) { + AppLogger.print('Failed to launch URL with mode $mode: $e'); + } + } + + return false; + } +} diff --git a/lib/core/widgets/platforms/available_platforms_badge.dart b/lib/core/widgets/platforms/available_platforms_badge.dart index c9db8fd..25ae24b 100644 --- a/lib/core/widgets/platforms/available_platforms_badge.dart +++ b/lib/core/widgets/platforms/available_platforms_badge.dart @@ -1,4 +1,3 @@ -import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/widgets/app_blur_badge.dart'; import 'package:application/core/widgets/platforms/platform_image.dart'; import 'package:application/core/widgets/platforms/platforms_stack.dart'; @@ -6,46 +5,21 @@ import 'package:application/core/widgets/show_app_menu.dart'; import 'package:application/l10n/app_localizations.dart'; import 'package:application/models/platform_model.dart'; import 'package:application/models/source_model.dart'; -import 'package:flutter/services.dart'; import 'package:material_ui/material_ui.dart'; -import 'package:url_launcher/url_launcher.dart'; class AvailablePlatformsBadge extends StatelessWidget { - const AvailablePlatformsBadge({super.key, required this.sources}); + const AvailablePlatformsBadge({ + super.key, + required this.sources, + required this.onSourcePress, + }); final Iterable sources; + final Future Function(SourceModel source) onSourcePress; Set get _platforms => sources.map((source) => source.platform).toSet(); - Future _launch(String url) async { - final modes = [ - .externalNonBrowserApplication, - .externalApplication, - .platformDefault, - ]; - - AppLogger.print('Launch url: $url...'); - final uri = Uri.tryParse(url); - - if (uri == null || !uri.isScheme('https')) { - AppLogger.print('Invalid URL: $url'); - return false; - } - - for (final mode in modes) { - try { - if (await launchUrl(uri, mode: mode)) { - return true; - } - } on PlatformException catch (e) { - AppLogger.print('Failed to launch URL with mode $mode: $e'); - } - } - - return false; - } - @override Widget build(BuildContext context) { final platforms = _platforms; @@ -58,7 +32,7 @@ class AvailablePlatformsBadge extends StatelessWidget { builder: (context) => GestureDetector( onTap: () { if (platforms.length == 1) { - _launch(sources.first.url); + onSourcePress(sources.first); return; } @@ -67,17 +41,19 @@ class AvailablePlatformsBadge extends StatelessWidget { items: [ for (final platform in platforms) PopupMenuItem( - onTap: () { - final source = sources.firstWhere( - (source) => source.platform.name == platform.name, - ); - _launch(source.url); - }, child: ListTile( leading: PlatformImage(platform, width: 16, height: 16), title: Text(platform.name), trailing: const Icon(Icons.north_east), ), + onTap: () { + for (final source in sources) { + if (source.platform.name == platform.name) { + onSourcePress(source); + break; + } + } + }, ), ], ); diff --git a/lib/main.dart b/lib/main.dart index 721669e..b692ccb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:application/core/network/http_client.dart'; +import 'package:application/core/services/platform_launch_service.dart'; import 'package:application/core/theme/app_theme.dart'; import 'package:application/l10n/app_localizations.dart'; import 'package:application/repositories/anime_repository.dart'; @@ -25,6 +26,7 @@ Future main() async { MultiProvider( providers: [ Provider(create: (_) => const HttpClient()), + Provider(create: (_) => const PlatformLaunchService()), Provider( create: (context) => GroupedEpisodeRepository(context.read()), @@ -39,8 +41,10 @@ Future main() async { create: (context) => WeeklyRepository(context.read()), ), ChangeNotifierProvider( - create: (context) => - GroupedEpisodeViewModel(context.read()), + create: (context) => GroupedEpisodeViewModel( + context.read(), + context.read(), + ), ), ChangeNotifierProvider( create: (context) => @@ -53,8 +57,10 @@ Future main() async { ), ), ChangeNotifierProvider( - create: (context) => - WeeklyViewModel(context.read()), + create: (context) => WeeklyViewModel( + context.read(), + context.read(), + ), ), ChangeNotifierProvider(create: (_) => NavigationViewModel()), ], diff --git a/lib/models/grouped_episode_model.dart b/lib/models/grouped_episode_model.dart index 670ece5..18aaafe 100644 --- a/lib/models/grouped_episode_model.dart +++ b/lib/models/grouped_episode_model.dart @@ -22,7 +22,7 @@ class GroupedEpisodeModel { this.number, this.mappings, this.sources, { - this.duration + this.duration, }); factory GroupedEpisodeModel.fromJson(Map json) => diff --git a/lib/viewmodels/anime_view_model.dart b/lib/viewmodels/anime_view_model.dart index 29ae6ac..5c982c5 100644 --- a/lib/viewmodels/anime_view_model.dart +++ b/lib/viewmodels/anime_view_model.dart @@ -55,7 +55,9 @@ class AnimeViewModel extends ChangeNotifier implements LangTypeFilterViewModel { if (simulcastUuid == null) { AppLogger.print('Selected simulcast: All'); } else { - AppLogger.print('Selected simulcast: ${_selectedSimulcast?.season} - ${_selectedSimulcast?.year}'); + AppLogger.print( + 'Selected simulcast: ${_selectedSimulcast?.season} - ${_selectedSimulcast?.year}', + ); } notifyListeners(); diff --git a/lib/viewmodels/grouped_episode_view_model.dart b/lib/viewmodels/grouped_episode_view_model.dart index c232b10..17f0ba9 100644 --- a/lib/viewmodels/grouped_episode_view_model.dart +++ b/lib/viewmodels/grouped_episode_view_model.dart @@ -1,19 +1,22 @@ import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; +import 'package:application/core/services/platform_launch_service.dart'; import 'package:application/models/grouped_episode_model.dart'; import 'package:application/models/lang_type.dart'; import 'package:application/models/pageable_model.dart'; +import 'package:application/models/source_model.dart'; import 'package:application/repositories/grouped_episode_repository.dart'; import 'package:application/viewmodels/lang_type_filter_view_model.dart'; import 'package:material_ui/material_ui.dart'; class GroupedEpisodeViewModel extends ChangeNotifier implements LangTypeFilterViewModel { - GroupedEpisodeViewModel(this._repository) { + GroupedEpisodeViewModel(this._repository, this._launchService) { _scrollController.addListener(_scrollListener); } final GroupedEpisodeRepository _repository; + final PlatformLaunchService _launchService; final _episodes = []; final _scrollController = ScrollController(); bool _loading = false; @@ -29,6 +32,9 @@ class GroupedEpisodeViewModel extends ChangeNotifier ScrollController get scrollController => _scrollController; + Future onSourcePress(SourceModel source) => + _launchService.launch(source.url); + @override bool isLangTypeSelected(LangType langType) => _selectedLangTypes.contains(langType); diff --git a/lib/viewmodels/weekly_view_model.dart b/lib/viewmodels/weekly_view_model.dart index 1f0aabd..895e896 100644 --- a/lib/viewmodels/weekly_view_model.dart +++ b/lib/viewmodels/weekly_view_model.dart @@ -1,6 +1,8 @@ import 'package:application/core/logger/app_logger.dart'; import 'package:application/core/network/api_result.dart'; +import 'package:application/core/services/platform_launch_service.dart'; import 'package:application/models/lang_type.dart'; +import 'package:application/models/source_model.dart'; import 'package:application/models/weekly_day_model.dart'; import 'package:application/models/weekly_release_model.dart'; import 'package:application/repositories/weekly_repository.dart'; @@ -9,9 +11,10 @@ import 'package:material_ui/material_ui.dart'; class WeeklyViewModel extends ChangeNotifier implements LangTypeFilterViewModel { - WeeklyViewModel(this._repository); + WeeklyViewModel(this._repository, this._launchService); final WeeklyRepository _repository; + final PlatformLaunchService _launchService; final _weekly = []; int _selectedDay = DateTime.now().weekday - 1; bool _loading = false; @@ -36,6 +39,9 @@ class WeeklyViewModel extends ChangeNotifier int get selectedDay => _selectedDay; + Future onSourcePress(SourceModel source) => + _launchService.launch(source.url); + void setPreviousDay() { _selectedDay = (_selectedDay - 1) % 7; AppLogger.print('Selected day: $_selectedDay'); diff --git a/lib/views/grouped_episodes/grouped_episodes_view.dart b/lib/views/grouped_episodes/grouped_episodes_view.dart index abe5d98..66cbf45 100644 --- a/lib/views/grouped_episodes/grouped_episodes_view.dart +++ b/lib/views/grouped_episodes/grouped_episodes_view.dart @@ -60,7 +60,12 @@ class _GroupedEpisodesViewState extends State { return Align( alignment: .topCenter, - child: GroupedEpisodeCard(groupedEpisode), + child: GroupedEpisodeCard( + groupedEpisode, + onSourcePress: context + .read() + .onSourcePress, + ), ); }, ), diff --git a/lib/views/grouped_episodes/widgets/grouped_episode_card.dart b/lib/views/grouped_episodes/widgets/grouped_episode_card.dart index ae4e18d..e18d90c 100644 --- a/lib/views/grouped_episodes/widgets/grouped_episode_card.dart +++ b/lib/views/grouped_episodes/widgets/grouped_episode_card.dart @@ -1,17 +1,23 @@ import 'package:application/core/config/env_config.dart'; -import 'package:application/core/widgets/app_card.dart'; import 'package:application/core/widgets/app_skeleton.dart'; import 'package:application/core/widgets/cached_network_image.dart'; import 'package:application/core/widgets/lang_types/lang_type_label.dart'; +import 'package:application/core/widgets/app_card.dart'; import 'package:application/core/widgets/platforms/available_platforms_badge.dart'; import 'package:application/l10n/app_localizations.dart'; +import 'package:application/models/source_model.dart'; import 'package:application/models/grouped_episode_model.dart'; import 'package:material_ui/material_ui.dart'; class GroupedEpisodeCard extends StatelessWidget { - const GroupedEpisodeCard(this._groupedEpisode, {super.key}); + const GroupedEpisodeCard( + this._groupedEpisode, { + super.key, + required this.onSourcePress, + }); final GroupedEpisodeModel _groupedEpisode; + final Future Function(SourceModel source) onSourcePress; @override Widget build(BuildContext context) { @@ -33,7 +39,10 @@ class GroupedEpisodeCard extends StatelessWidget { loading: const AppSkeleton(), error: const AppSkeleton(), ), - AvailablePlatformsBadge(sources: _groupedEpisode.sources), + AvailablePlatformsBadge( + sources: _groupedEpisode.sources, + onSourcePress: onSourcePress, + ), ], ), ), diff --git a/lib/views/weekly/weekly_view.dart b/lib/views/weekly/weekly_view.dart index 07123ed..e926354 100644 --- a/lib/views/weekly/weekly_view.dart +++ b/lib/views/weekly/weekly_view.dart @@ -83,7 +83,12 @@ class _WeeklyViewState extends State { return Align( alignment: .topCenter, - child: ReleaseCard(release), + child: ReleaseCard( + release, + onSourcePress: context + .read() + .onSourcePress, + ), ); }, ), diff --git a/lib/views/weekly/widgets/release_card.dart b/lib/views/weekly/widgets/release_card.dart index 2fe01ba..b2e9417 100644 --- a/lib/views/weekly/widgets/release_card.dart +++ b/lib/views/weekly/widgets/release_card.dart @@ -12,9 +12,10 @@ import 'package:material_ui/material_ui.dart'; import 'package:intl/intl.dart'; class ReleaseCard extends StatelessWidget { - const ReleaseCard(this._release, {super.key}); + const ReleaseCard(this._release, {super.key, required this.onSourcePress}); final WeeklyReleaseModel _release; + final Future Function(SourceModel source) onSourcePress; Set get _sources => _release.mappings?.expand((mapping) => mapping.sources).toSet() ?? {}; @@ -50,7 +51,10 @@ class ReleaseCard extends StatelessWidget { if (!isRelease) PlatformsBadge(platforms: _release.platforms) else - AvailablePlatformsBadge(sources: _sources), + AvailablePlatformsBadge( + sources: _sources, + onSourcePress: onSourcePress, + ), ], ), ), From d2471af0d2a033528dc9f5b8f917ef1c7fc078be Mon Sep 17 00:00:00 2001 From: Ziedelth Date: Fri, 21 Aug 2026 14:04:27 +0000 Subject: [PATCH 5/5] test: add unit tests for PlatformLaunchService and onSourcePress events - FakeUrlLauncherPlatform and ThrowingUrlLauncherPlatform fakes - Cover URL validation (malformed, non-https), first-mode launch, mode fallback loop, and PlatformException recovery - Cover GroupedEpisodeViewModel and WeeklyViewModel delegation to the launch service with Given/When/Then structure - Add url_launcher_platform_interface as dev dependency --- pubspec.lock | 2 +- pubspec.yaml | 1 + .../services/fake_url_launcher_platform.dart | 19 ++++ .../platform_launch_service_test.dart | 91 +++++++++++++++++++ .../throwing_url_launcher_platform.dart | 17 ++++ .../grouped_episode_view_model_test.dart | 60 ++++++++++++ test/viewmodels/weekly_view_model_test.dart | 54 +++++++++++ 7 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 test/core/services/fake_url_launcher_platform.dart create mode 100644 test/core/services/platform_launch_service_test.dart create mode 100644 test/core/services/throwing_url_launcher_platform.dart create mode 100644 test/viewmodels/grouped_episode_view_model_test.dart create mode 100644 test/viewmodels/weekly_view_model_test.dart diff --git a/pubspec.lock b/pubspec.lock index b6483e5..0c744fc 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1259,7 +1259,7 @@ packages: source: hosted version: "3.2.5" url_launcher_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: url_launcher_platform_interface sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" diff --git a/pubspec.yaml b/pubspec.yaml index 2c8ceff..dbc3292 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dev_dependencies: flutter_test: sdk: flutter json_serializable: ^6.14.1 + url_launcher_platform_interface: ^2.3.2 flutter_launcher_icons: image_path: 'assets/icon.png' diff --git a/test/core/services/fake_url_launcher_platform.dart b/test/core/services/fake_url_launcher_platform.dart new file mode 100644 index 0000000..2096db2 --- /dev/null +++ b/test/core/services/fake_url_launcher_platform.dart @@ -0,0 +1,19 @@ +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +class FakeUrlLauncherPlatform extends UrlLauncherPlatform { + FakeUrlLauncherPlatform({this.result = true}); + + final bool result; + final List<(String, PreferredLaunchMode)> calls = []; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future launchUrl(String url, LaunchOptions options) async { + calls.add((url, options.mode)); + + return result; + } +} diff --git a/test/core/services/platform_launch_service_test.dart b/test/core/services/platform_launch_service_test.dart new file mode 100644 index 0000000..ac5931e --- /dev/null +++ b/test/core/services/platform_launch_service_test.dart @@ -0,0 +1,91 @@ +import 'package:application/core/services/platform_launch_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +import 'fake_url_launcher_platform.dart'; +import 'throwing_url_launcher_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('PlatformLaunchService.launch', () { + test('rejects a malformed URL without calling the platform', () async { + // Given + final platform = FakeUrlLauncherPlatform(); + UrlLauncherPlatform.instance = platform; + const service = PlatformLaunchService(); + + // When + final launched = await service.launch('::not a url::'); + + // Then + expect(launched, isFalse); + expect(platform.calls, isEmpty); + }); + + test('rejects a non-https URL without calling the platform', () async { + // Given + final platform = FakeUrlLauncherPlatform(); + UrlLauncherPlatform.instance = platform; + const service = PlatformLaunchService(); + + // When + final launched = await service.launch('http://example.com/page'); + + // Then + expect(launched, isFalse); + expect(platform.calls, isEmpty); + }); + + test('launches an https URL on the first mode and stops', () async { + // Given + final platform = FakeUrlLauncherPlatform(); + UrlLauncherPlatform.instance = platform; + const service = PlatformLaunchService(); + + // When + final launched = await service.launch('https://example.com/page'); + + // Then + expect(launched, isTrue); + expect(platform.calls, hasLength(1)); + expect( + platform.calls.first.$2, + PreferredLaunchMode.externalNonBrowserApplication, + ); + }); + + test('falls back to the next mode when the previous one fails', () async { + // Given + final platform = FakeUrlLauncherPlatform(result: false); + UrlLauncherPlatform.instance = platform; + const service = PlatformLaunchService(); + + // When + final launched = await service.launch('https://example.com/page'); + + // Then + expect(launched, isFalse); + expect(platform.calls, hasLength(3)); + expect(platform.calls.map((call) => call.$2), [ + PreferredLaunchMode.externalNonBrowserApplication, + PreferredLaunchMode.externalApplication, + PreferredLaunchMode.platformDefault, + ]); + }); + + test('returns false on PlatformException and keeps falling back', () async { + // Given + final platform = ThrowingUrlLauncherPlatform(); + UrlLauncherPlatform.instance = platform; + const service = PlatformLaunchService(); + + // When + final launched = await service.launch('https://example.com/page'); + + // Then + expect(launched, isFalse); + expect(platform.calls, hasLength(3)); + }); + }); +} diff --git a/test/core/services/throwing_url_launcher_platform.dart b/test/core/services/throwing_url_launcher_platform.dart new file mode 100644 index 0000000..d39fbe1 --- /dev/null +++ b/test/core/services/throwing_url_launcher_platform.dart @@ -0,0 +1,17 @@ +import 'package:flutter/services.dart'; +import 'package:url_launcher_platform_interface/link.dart'; +import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart'; + +class ThrowingUrlLauncherPlatform extends UrlLauncherPlatform { + final List<(String, PreferredLaunchMode)> calls = []; + + @override + LinkDelegate? get linkDelegate => null; + + @override + Future launchUrl(String url, LaunchOptions options) async { + calls.add((url, options.mode)); + + throw PlatformException(code: 'platform_error'); + } +} diff --git a/test/viewmodels/grouped_episode_view_model_test.dart b/test/viewmodels/grouped_episode_view_model_test.dart new file mode 100644 index 0000000..15bb46f --- /dev/null +++ b/test/viewmodels/grouped_episode_view_model_test.dart @@ -0,0 +1,60 @@ +import 'package:application/core/network/api_result.dart'; +import 'package:application/core/network/http_client.dart'; +import 'package:application/core/services/platform_launch_service.dart'; +import 'package:application/models/grouped_episode_model.dart'; +import 'package:application/models/lang_type.dart'; +import 'package:application/models/pageable_model.dart'; +import 'package:application/models/platform_model.dart'; +import 'package:application/models/source_model.dart'; +import 'package:application/repositories/grouped_episode_repository.dart'; +import 'package:application/viewmodels/grouped_episode_view_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeGroupedEpisodeRepository extends GroupedEpisodeRepository { + FakeGroupedEpisodeRepository() : super(const HttpClient()); + + @override + Future>> getGroupedEpisodes( + int page, + int limit, { + List? langTypes, + }) async { + return ApiSuccess(PageableModel([], 1, 0, 0)); + } +} + +class RecordingLaunchService extends PlatformLaunchService { + final List launched = []; + + @override + Future launch(String rawUrl) async { + launched.add(rawUrl); + + return true; + } +} + +void main() { + group('GroupedEpisodeViewModel.onSourcePress', () { + test('delegates the source URL to the launch service', () async { + // Given + final launchService = RecordingLaunchService(); + final viewModel = GroupedEpisodeViewModel( + FakeGroupedEpisodeRepository(), + launchService, + ); + final platform = PlatformModel('Crunchyroll', 'crunchyroll.png'); + final source = SourceModel( + platform, + 'https://example.com/watch', + LangType.subtitles, + ); + + // When + await viewModel.onSourcePress(source); + + // Then + expect(launchService.launched, ['https://example.com/watch']); + }); + }); +} diff --git a/test/viewmodels/weekly_view_model_test.dart b/test/viewmodels/weekly_view_model_test.dart new file mode 100644 index 0000000..bab0fff --- /dev/null +++ b/test/viewmodels/weekly_view_model_test.dart @@ -0,0 +1,54 @@ +import 'package:application/core/network/api_result.dart'; +import 'package:application/core/network/http_client.dart'; +import 'package:application/core/services/platform_launch_service.dart'; +import 'package:application/models/lang_type.dart'; +import 'package:application/models/platform_model.dart'; +import 'package:application/models/source_model.dart'; +import 'package:application/models/weekly_day_model.dart'; +import 'package:application/repositories/weekly_repository.dart'; +import 'package:application/viewmodels/weekly_view_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeWeeklyRepository extends WeeklyRepository { + FakeWeeklyRepository() : super(const HttpClient()); + + @override + Future>> getWeekly({ + List? langTypes, + }) async { + return const ApiSuccess([]); + } +} + +class RecordingLaunchService extends PlatformLaunchService { + final List launched = []; + + @override + Future launch(String rawUrl) async { + launched.add(rawUrl); + + return true; + } +} + +void main() { + group('WeeklyViewModel.onSourcePress', () { + test('delegates the source URL to the launch service', () async { + // Given + final launchService = RecordingLaunchService(); + final viewModel = WeeklyViewModel(FakeWeeklyRepository(), launchService); + final platform = PlatformModel('Crunchyroll', 'crunchyroll.png'); + final source = SourceModel( + platform, + 'https://example.com/watch', + LangType.subtitles, + ); + + // When + await viewModel.onSourcePress(source); + + // Then + expect(launchService.launched, ['https://example.com/watch']); + }); + }); +}