diff --git a/lib/pages/discover/discover_view.dart b/lib/pages/discover/discover_view.dart index a520c43..656adcb 100644 --- a/lib/pages/discover/discover_view.dart +++ b/lib/pages/discover/discover_view.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import '../../core/config/app_config.dart'; import '../../core/data/models/drama.dart'; import '../../core/data/models/watch_progress.dart'; import '../../core/data/repositories/watch_history_repository.dart'; @@ -135,90 +136,99 @@ class _DiscoverPageState extends State { CategoryController categories, List allDramas, List visible, - ) => CustomScrollView( - controller: logic.scroll, - slivers: [ - SliverToBoxAdapter(child: _search()), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), - child: _categories(l10n, categories), + ) => RefreshIndicator( + key: const Key('discover-refresh'), + color: AppColors.blue, + backgroundColor: AppColors.card, + onRefresh: () async { + await Future.wait([categories.load(), logic.refreshResume()]); + }, + child: CustomScrollView( + controller: logic.scroll, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter(child: _search()), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 14, 0, 12), + child: _categories(l10n, categories), + ), ), - ), - if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + if (logic.resumeRecord != null && logic.resumeDrama(allDramas) != null) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), + child: _continueCard( + l10n, + logic.resumeDrama(allDramas)!, + logic.resumeRecord!, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: _continueCard( - l10n, - logic.resumeDrama(allDramas)!, - logic.resumeRecord!, + child: _quickActions(l10n, categories), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), + child: Row( + children: [ + Container( + width: 3, + height: 18, + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + l10n.trendingMicroDramas, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.onSurface, + fontSize: AppDimens.previewSectionTitle, + fontWeight: FontWeight.w800, + ), + ), + ), + TextButton( + onPressed: () => + categories.selectCode(CategoryController.allCode), + style: TextButton.styleFrom( + foregroundColor: AppColors.accent, + minimumSize: const Size(44, 44), + padding: EdgeInsets.zero, + ), + child: Text( + '${l10n.all} ›', + style: const TextStyle(fontSize: 10), + ), + ), + ], ), ), ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 14), - child: _quickActions(l10n, categories), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 2, 14, 10), - child: Row( - children: [ - Container( - width: 3, - height: 18, - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 6), - Expanded( - child: Text( - l10n.trendingMicroDramas, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.onSurface, - fontSize: AppDimens.previewSectionTitle, - fontWeight: FontWeight.w800, - ), - ), - ), - TextButton( - onPressed: () => - categories.selectCode(CategoryController.allCode), - style: TextButton.styleFrom( - foregroundColor: AppColors.accent, - minimumSize: const Size(44, 44), - padding: EdgeInsets.zero, - ), - child: Text( - '${l10n.all} ›', - style: const TextStyle(fontSize: 10), - ), - ), - ], + _HomeDramaGrid( + l10n: l10n, + categories: categories, + dramas: visible, + searching: logic.search.text.trim().isNotEmpty, + onOpen: (drama) => logic.openCard( + context, + drama, + embedded: widget.embedded, + onDramaSelected: widget.onDramaSelected, + onAction: widget.onAction, ), ), - ), - _HomeDramaGrid( - l10n: l10n, - categories: categories, - dramas: visible, - searching: logic.search.text.trim().isNotEmpty, - onOpen: (drama) => logic.openCard( - context, - drama, - embedded: widget.embedded, - onDramaSelected: widget.onDramaSelected, - onAction: widget.onAction, - ), - ), - ], + ], + ), ); Widget _header(AppLocalizations l10n) { diff --git a/lib/pages/discover/widgets/home_drama_grid.dart b/lib/pages/discover/widgets/home_drama_grid.dart index 7197dd2..d2bd04c 100644 --- a/lib/pages/discover/widgets/home_drama_grid.dart +++ b/lib/pages/discover/widgets/home_drama_grid.dart @@ -55,33 +55,44 @@ class _HomeDramaGrid extends StatelessWidget { } return SliverMainAxisGroup( slivers: [ - _dramaGrid(dramas), + SliverToBoxAdapter(child: _masonry(dramas)), const SliverToBoxAdapter(child: SizedBox(height: 24)), ], ); } - Widget _dramaGrid(List items) => SliverPadding( - key: const Key('home-all-dramas-section'), - padding: const EdgeInsets.symmetric(horizontal: 14), - sliver: SliverGrid( - key: const Key('home-all-dramas-grid'), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - mainAxisExtent: 164, + Widget _masonry(List items) { + return Padding( + key: const Key('home-all-dramas-section'), + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + key: const Key('home-all-dramas-grid'), + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _column(items, 0)), + const SizedBox(width: 10), + Expanded(child: _column(items, 1)), + ], ), - delegate: SliverChildBuilderDelegate( - (context, index) => _HomeDramaCard( + ); + } + + Widget _column(List items, int start) { + final children = []; + for (var index = start; index < items.length; index += 2) { + children.add( + _HomeDramaCard( l10n: l10n, drama: items[index], onTap: () => onOpen(items[index]), ), - childCount: items.length, - ), - ), - ); + ); + if (index + 2 < items.length) { + children.add(const SizedBox(height: 10)); + } + } + return Column(children: children); + } Widget _fill(Widget child) => SliverFillRemaining(hasScrollBody: false, child: child); @@ -111,47 +122,36 @@ class _HomeDramaCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox( + ClipRRect( key: Key('home-drama-cover-${drama.id}'), - height: 120, - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Stack( - fit: StackFit.expand, - children: [ - ColoredBox( - color: AppColors.card, - child: RemoteImage( - drama.posterUrl, - fit: BoxFit.contain, - showFallbackIcon: true, - ), - ), - if (free) - Positioned( - top: 6, - left: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.blue, - borderRadius: BorderRadius.circular(6), - ), - child: Text( - l10n.free, - style: const TextStyle( - color: AppColors.onPrimary, - fontSize: 8, - fontWeight: FontWeight.w800, - ), + borderRadius: BorderRadius.circular(10), + child: Stack( + children: [ + _DiscoverCoverImage(path: drama.posterUrl), + if (free) + Positioned( + top: 6, + left: 6, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.blue, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + l10n.free, + style: const TextStyle( + color: AppColors.onPrimary, + fontSize: 8, + fontWeight: FontWeight.w800, ), ), ), - ], - ), + ), + ], ), ), const SizedBox(height: 5), @@ -179,3 +179,41 @@ class _HomeDramaCard extends StatelessWidget { ); } } + +class _DiscoverCoverImage extends StatelessWidget { + const _DiscoverCoverImage({required this.path}); + + final String path; + + @override + Widget build(BuildContext context) { + final url = AppConfig.mediaUrl(path); + if (url.isEmpty) return _placeholder(); + return Image.network( + url, + width: double.infinity, + fit: BoxFit.fitWidth, + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return _placeholder(); + }, + errorBuilder: (_, _, _) => _placeholder(), + ); + } + + Widget _placeholder() { + return AspectRatio( + aspectRatio: 3 / 4, + child: ColoredBox( + color: AppColors.card, + child: const Center( + child: Icon( + Icons.image_not_supported_outlined, + color: AppColors.muted, + size: 32, + ), + ), + ), + ); + } +} diff --git a/test/discover/home_all_dramas_grid_test.dart b/test/discover/home_all_dramas_grid_test.dart index b7aad49..de51edf 100644 --- a/test/discover/home_all_dramas_grid_test.dart +++ b/test/discover/home_all_dramas_grid_test.dart @@ -12,7 +12,6 @@ import 'package:fruitshorts/core/data/repositories/category_repository.dart'; import 'package:fruitshorts/core/data/repositories/favorite_repository.dart'; import 'package:fruitshorts/core/data/repositories/playback_repository.dart'; import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart'; -import 'package:fruitshorts/core/widgets/remote_image.dart'; import '../support/fake_catalog.dart'; import '../support/fake_interaction_repositories.dart'; @@ -26,7 +25,7 @@ void main() { }); testWidgets( - 'should_show_all_dramas_in_two_column_sliver_grid_without_continue_watching', + 'should_show_all_dramas_in_two_column_masonry_without_continue_watching', (tester) async { tester.view.physicalSize = const Size(390, 844); tester.view.devicePixelRatio = 1; @@ -57,41 +56,48 @@ void main() { ); } expect(find.text('For You'), findsNothing); - final grid = tester.widget( + final masonry = tester.widget( find.byKey(const Key('home-all-dramas-grid')), ); - final delegate = - grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount; - expect(delegate.crossAxisCount, 2); - expect(delegate.crossAxisSpacing, 10); - expect(delegate.mainAxisSpacing, 10); - expect(delegate.mainAxisExtent, 164); + expect(masonry.children, hasLength(3)); + expect((masonry.children[1] as SizedBox).width, 10); expect( tester - .widget( - find.byKey(const Key('home-all-dramas-section')), - ) + .widget(find.byKey(const Key('home-all-dramas-section'))) .padding, const EdgeInsets.symmetric(horizontal: 14), ); expect(find.byType(GridView), findsNothing); expect(find.byType(CustomScrollView), findsOneWidget); - expect(find.byType(RemoteImage), findsWidgets); - - expect( - tester - .getSize(find.byKey(const Key('home-drama-cover-city-lights-demo'))) - .height, - 120, + final signalCard = find.byKey( + const Key('home-drama-card-the-signal-demo'), ); + final cityCard = find.byKey( + const Key('home-drama-card-city-lights-demo'), + ); + final vengeanceCard = find.byKey( + const Key('home-drama-card-vengeance-demo'), + ); + expect(tester.getTopLeft(signalCard).dy, tester.getTopLeft(cityCard).dy); + expect( + tester.getTopLeft(vengeanceCard).dx, + tester.getTopLeft(signalCard).dx, + ); + expect( + tester.getTopLeft(vengeanceCard).dy - + tester.getBottomLeft(signalCard).dy, + closeTo(10, 1), + ); + final cover = find.byKey(const Key('home-drama-cover-city-lights-demo')); + expect(tester.getSize(cover).height, greaterThan(120)); expect( tester - .widget( - find.descendant(of: cover, matching: find.byType(RemoteImage)), + .widget( + find.descendant(of: cover, matching: find.byType(Image)), ) .fit, - BoxFit.contain, + BoxFit.fitWidth, ); expect( find.descendant( @@ -104,6 +110,14 @@ void main() { find.byKey(const Key('home-drama-title-city-lights-demo')), findsOneWidget, ); + expect( + tester + .getTopLeft( + find.byKey(const Key('home-drama-title-city-lights-demo')), + ) + .dy, + greaterThan(tester.getBottomLeft(cover).dy), + ); expect( tester .widget( @@ -346,6 +360,29 @@ void main() { ); }); + testWidgets('should_refresh_feed_when_pulled_down', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final counter = _CountingCategoryRepository( + _GridCategoryRepository(discoverFeed: _tallDiscoverFeed), + ); + await _pumpApp(tester, categoryRepository: counter); + await tester.tap(find.byKey(const Key('tab-discover'))); + await tester.pumpAndSettle(); + + final before = counter.categoryLoads + counter.feedLoads; + await tester.drag(find.byType(CustomScrollView), const Offset(0, 320)); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + + expect(counter.categoryLoads + counter.feedLoads, greaterThan(before)); + expect(find.byType(RefreshProgressIndicator), findsNothing); + }); + testWidgets('should_show_continue_watching_only_when_a_record_exists', ( tester, ) async { @@ -469,3 +506,23 @@ final _tallDiscoverFeed = HomeFeed( ), ], ); + +class _CountingCategoryRepository implements CategoryRepository { + _CountingCategoryRepository(this.delegate); + + final CategoryRepository delegate; + int categoryLoads = 0; + int feedLoads = 0; + + @override + Future> fetchCategories() { + categoryLoads++; + return delegate.fetchCategories(); + } + + @override + Future fetchDiscoverFeed() { + feedLoads++; + return delegate.fetchDiscoverFeed(); + } +}