From 297d6b7fdb8273c18cc718f9a2befed9322e1d3d Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Sat, 5 Oct 2024 11:19:02 +0200 Subject: [PATCH 1/5] Revamp puzzle tab --- lib/src/model/puzzle/puzzle.dart | 2 + lib/src/utils/connectivity.dart | 58 +++++ .../view/puzzle/puzzle_history_screen.dart | 2 + lib/src/view/puzzle/puzzle_tab_screen.dart | 224 +++++++++++------- 4 files changed, 198 insertions(+), 88 deletions(-) diff --git a/lib/src/model/puzzle/puzzle.dart b/lib/src/model/puzzle/puzzle.dart index 4fa9aafaf3..d62db26e5b 100644 --- a/lib/src/model/puzzle/puzzle.dart +++ b/lib/src/model/puzzle/puzzle.dart @@ -54,6 +54,8 @@ class PuzzleData with _$PuzzleData { required ISet themes, }) = _PuzzleData; + Side get sideToMove => initialPly.isEven ? Side.black : Side.white; + factory PuzzleData.fromJson(Map json) => _$PuzzleDataFromJson(json); } diff --git a/lib/src/utils/connectivity.dart b/lib/src/utils/connectivity.dart index 6d80a51cee..a7323c8996 100644 --- a/lib/src/utils/connectivity.dart +++ b/lib/src/utils/connectivity.dart @@ -151,3 +151,61 @@ Future isOnline(Client client) { } return completer.future; } + +extension AsyncValueConnectivity on AsyncValue { + /// Switches between two functions based on the device's connectivity status. + /// + /// Using this method assumes the the device is offline when the status is + /// not yet available (i.e. [AsyncValue.isLoading]. + /// If you want to handle the loading state separately, use + /// [whenOnlineLoading] instead. + /// + /// This method is similar to [AsyncValueX.maybeWhen], but it takes two + /// functions, one for when the device is online and another for when it is + /// offline. + /// + /// Example: + /// ```dart + /// final status = ref.watch(connectivityChangesProvider); + /// final result = status.whenOnline( + /// online: () => 'Online', + /// offline: () => 'Offline', + /// ); + /// ``` + R whenOnline({ + required R Function() online, + required R Function() offline, + }) { + return maybeWhen( + data: (status) => status.isOnline ? online() : offline(), + orElse: offline, + ); + } + + /// Switches between three functions based on the device's connectivity status. + /// + /// This method is similar to [AsyncValueX.when], but it takes three + /// functions, one for when the device is online, another for when it is + /// offline, and the last for when the status is still loading. + /// + /// Example: + /// ```dart + /// final status = ref.watch(connectivityChangesProvider); + /// final result = status.whenOnlineLoading( + /// online: () => 'Online', + /// offline: () => 'Offline', + /// loading: () => 'Loading', + /// ); + /// ``` + R whenOnlineLoading({ + required R Function() online, + required R Function() offline, + required R Function() loading, + }) { + return when( + data: (status) => status.isOnline ? online() : offline(), + loading: loading, + error: (error, stack) => offline(), + ); + } +} diff --git a/lib/src/view/puzzle/puzzle_history_screen.dart b/lib/src/view/puzzle/puzzle_history_screen.dart index 9fbbab7774..d2df0b6ca2 100644 --- a/lib/src/view/puzzle/puzzle_history_screen.dart +++ b/lib/src/view/puzzle/puzzle_history_screen.dart @@ -21,6 +21,8 @@ import 'package:timeago/timeago.dart' as timeago; final _dateFormatter = DateFormat.yMMMd(); class PuzzleHistoryScreen extends StatelessWidget { + const PuzzleHistoryScreen(); + @override Widget build(BuildContext context) { return PlatformScaffold( diff --git a/lib/src/view/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index 7ebf8dbdfe..ec6dffea5a 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -64,6 +64,8 @@ class _PuzzleTabScreenState extends ConsumerState { ], ); + final isTablet = isTabletOrLarger(context); + return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, _) { @@ -74,8 +76,9 @@ class _PuzzleTabScreenState extends ConsumerState { child: Scaffold( appBar: AppBar( title: Text(context.l10n.puzzles), - actions: const [ - _DashboardButton(), + actions: [ + const _DashboardButton(), + if (!isTablet) const _HistoryButton(), ], ), body: userSession != null @@ -90,6 +93,8 @@ class _PuzzleTabScreenState extends ConsumerState { } Widget _iosBuilder(BuildContext context, AuthSessionState? userSession) { + final isTablet = isTabletOrLarger(context); + return CupertinoPageScaffold( child: CustomScrollView( controller: puzzlesScrollController, @@ -100,10 +105,14 @@ class _PuzzleTabScreenState extends ConsumerState { end: 8.0, ), largeTitle: Text(context.l10n.puzzles), - trailing: const Row( + trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - _DashboardButton(), + const _DashboardButton(), + if (!isTablet) ...[ + const SizedBox(width: 6.0), + const _HistoryButton(), + ], ], ), ), @@ -140,17 +149,15 @@ class _Body extends ConsumerWidget { final isTablet = isTabletOrLarger(context); final handsetChildren = [ - connectivity.when( - data: (data) => data.isOnline - ? const _DailyPuzzle() - : const _OfflinePuzzlePreview(), - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + connectivity.whenOnline( + online: () => const _DailyPuzzle(), + offline: () => const SizedBox.shrink(), ), + const SizedBox(height: 4.0), + const _PuzzlePreview(), if (Theme.of(context).platform == TargetPlatform.android) const SizedBox(height: 8.0), _PuzzleMenu(connectivity: connectivity), - PuzzleHistoryWidget(), ]; final tabletChildren = [ @@ -163,12 +170,9 @@ class _Body extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ const SizedBox(height: 8.0), - connectivity.when( - data: (data) => data.isOnline - ? const _DailyPuzzle() - : const _OfflinePuzzlePreview(), - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + connectivity.whenOnline( + online: () => const _DailyPuzzle(), + offline: () => const SizedBox.shrink(), ), _PuzzleMenu(connectivity: connectivity), ], @@ -244,21 +248,6 @@ class _PuzzleMenu extends StatelessWidget { return ListSection( hasLeading: true, children: [ - _PuzzleMenuListTile( - icon: PuzzleIcons.mix, - title: context.l10n.puzzlePuzzles, - subtitle: context.l10n.puzzleDesc, - onTap: () { - pushPlatformRoute( - context, - title: context.l10n.puzzleDesc, - rootNavigator: true, - builder: (context) => const PuzzleScreen( - angle: PuzzleTheme(PuzzleThemeKey.mix), - ), - ); - }, - ), _PuzzleMenuListTile( icon: PuzzleIcons.opening, title: context.l10n.puzzlePuzzleThemes, @@ -353,7 +342,7 @@ class PuzzleHistoryWidget extends ConsumerWidget { headerTrailing: NoPaddingTextButton( onPressed: () => pushPlatformRoute( context, - builder: (context) => PuzzleHistoryScreen(), + builder: (context) => const PuzzleHistoryScreen(), ), child: Text( context.l10n.more, @@ -402,23 +391,50 @@ class _DashboardButton extends ConsumerWidget { final session = ref.watch(authSessionProvider); if (session != null) { return AppBarIconButton( - icon: const Icon(Icons.history), + icon: const Icon(Icons.assessment_outlined), semanticsLabel: context.l10n.puzzlePuzzleDashboard, onPressed: () { ref.invalidate(puzzleDashboardProvider); - _showDashboard(context, session); + pushPlatformRoute( + context, + title: context.l10n.puzzlePuzzleDashboard, + builder: (_) => const PuzzleDashboardScreen(), + ); }, ); } return const SizedBox.shrink(); } +} + +class _HistoryButton extends ConsumerWidget { + const _HistoryButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(puzzleRecentActivityProvider); + return AppBarIconButton( + icon: const Icon(Icons.history_outlined), + semanticsLabel: context.l10n.puzzleHistory, + onPressed: asyncData.maybeWhen( + data: (_) => () { + pushPlatformRoute( + context, + title: context.l10n.puzzleHistory, + builder: (_) => const PuzzleHistoryScreen(), + ); + }, + orElse: () => null, + ), + ); + } +} - void _showDashboard(BuildContext context, AuthSessionState session) => - pushPlatformRoute( - context, - title: context.l10n.puzzlePuzzleDashboard, - builder: (_) => const PuzzleDashboardScreen(), - ); +TextStyle _puzzlePreviewSubtitleStyle(BuildContext context) { + return TextStyle( + fontSize: 14.0, + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.6), + ); } class _DailyPuzzle extends ConsumerWidget { @@ -449,6 +465,7 @@ class _DailyPuzzle extends ConsumerWidget { context.l10n .puzzlePlayedXTimes(data.puzzle.plays) .localizeNumbers(), + style: _puzzlePreviewSubtitleStyle(context), ), ], ), @@ -461,7 +478,7 @@ class _DailyPuzzle extends ConsumerWidget { ?.withValues(alpha: 0.6), ), Text( - data.puzzle.initialPly.isOdd + data.puzzle.sideToMove == Side.white ? context.l10n.whitePlays : context.l10n.blackPlays, ), @@ -503,59 +520,90 @@ class _DailyPuzzle extends ConsumerWidget { } } -class _OfflinePuzzlePreview extends ConsumerWidget { - const _OfflinePuzzlePreview(); +class _PuzzlePreview extends ConsumerWidget { + const _PuzzlePreview(); @override Widget build(BuildContext context, WidgetRef ref) { final puzzle = ref.watch(nextPuzzleProvider(const PuzzleTheme(PuzzleThemeKey.mix))); - return puzzle.maybeWhen( - data: (data) { - final preview = - data != null ? PuzzlePreview.fromPuzzle(data.puzzle) : null; - return SmallBoardPreview( - orientation: preview?.orientation ?? Side.white, - fen: preview?.initialFen ?? kEmptyFen, - lastMove: preview?.initialMove, - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - context.l10n.puzzleDesc, - style: Styles.boardPreviewTitle, - ), + + Widget buildPuzzlePreview(Puzzle? puzzle, {bool loading = false}) { + final preview = puzzle != null ? PuzzlePreview.fromPuzzle(puzzle) : null; + return SmallBoardPreview( + orientation: preview?.orientation ?? Side.white, + fen: preview?.initialFen ?? kEmptyFen, + lastMove: preview?.initialMove, + description: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.puzzleDesc, + style: Styles.boardPreviewTitle, + ), + Text( + context.l10n.puzzleThemeHealthyMixDescription, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + height: 1.2, + fontSize: 12.0, + color: DefaultTextStyle.of(context) + .style + .color + ?.withValues(alpha: 0.6), + ), + ), + ], + ), + Icon( + PuzzleIcons.mix, + size: 34, + color: DefaultTextStyle.of(context) + .style + .color + ?.withValues(alpha: 0.6), + ), + if (puzzle != null) Text( - context.l10n - .puzzlePlayedXTimes(data?.puzzle.puzzle.plays ?? 0) - .localizeNumbers(), + puzzle.puzzle.sideToMove == Side.white + ? context.l10n.whitePlays + : context.l10n.blackPlays, + ) + else if (!loading) + const Text( + 'No puzzles available, please go online to fetch them.', ), - ], - ), - onTap: data != null - ? () { - pushPlatformRoute( - context, - rootNavigator: true, - builder: (context) => const PuzzleScreen( - angle: PuzzleTheme(PuzzleThemeKey.mix), - ), - ).then((_) { - if (context.mounted) { - ref.invalidate( - nextPuzzleProvider( - const PuzzleTheme(PuzzleThemeKey.mix), - ), - ); - } - }); - } - : null, - ); - }, - orElse: () => const SizedBox.shrink(), + ], + ), + onTap: puzzle != null + ? () { + pushPlatformRoute( + context, + rootNavigator: true, + builder: (context) => const PuzzleScreen( + angle: PuzzleTheme(PuzzleThemeKey.mix), + ), + ).then((_) { + if (context.mounted) { + ref.invalidate( + nextPuzzleProvider(const PuzzleTheme(PuzzleThemeKey.mix)), + ); + } + }); + } + : null, + ); + } + + return puzzle.maybeWhen( + data: (data) => buildPuzzlePreview(data?.puzzle), + orElse: () => buildPuzzlePreview(null, loading: true), ); } } From 117760d55b4008ac1f21b87bed90424960c53695 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 7 Oct 2024 11:57:23 +0200 Subject: [PATCH 2/5] Update puzzle tab buttons --- lib/src/utils/connectivity.dart | 14 +- lib/src/view/puzzle/puzzle_tab_screen.dart | 149 +++++++++------------ 2 files changed, 70 insertions(+), 93 deletions(-) diff --git a/lib/src/utils/connectivity.dart b/lib/src/utils/connectivity.dart index a7323c8996..9f2e76829f 100644 --- a/lib/src/utils/connectivity.dart +++ b/lib/src/utils/connectivity.dart @@ -153,12 +153,12 @@ Future isOnline(Client client) { } extension AsyncValueConnectivity on AsyncValue { - /// Switches between two functions based on the device's connectivity status. + /// Switches between device's connectivity status. /// /// Using this method assumes the the device is offline when the status is /// not yet available (i.e. [AsyncValue.isLoading]. /// If you want to handle the loading state separately, use - /// [whenOnlineLoading] instead. + /// [whenIsLoading] instead. /// /// This method is similar to [AsyncValueX.maybeWhen], but it takes two /// functions, one for when the device is online and another for when it is @@ -167,12 +167,12 @@ extension AsyncValueConnectivity on AsyncValue { /// Example: /// ```dart /// final status = ref.watch(connectivityChangesProvider); - /// final result = status.whenOnline( + /// final result = status.whenIs( /// online: () => 'Online', /// offline: () => 'Offline', /// ); /// ``` - R whenOnline({ + R whenIs({ required R Function() online, required R Function() offline, }) { @@ -182,7 +182,7 @@ extension AsyncValueConnectivity on AsyncValue { ); } - /// Switches between three functions based on the device's connectivity status. + /// Switches between device's connectivity status, but handling the loading state. /// /// This method is similar to [AsyncValueX.when], but it takes three /// functions, one for when the device is online, another for when it is @@ -191,13 +191,13 @@ extension AsyncValueConnectivity on AsyncValue { /// Example: /// ```dart /// final status = ref.watch(connectivityChangesProvider); - /// final result = status.whenOnlineLoading( + /// final result = status.whenIsLoading( /// online: () => 'Online', /// offline: () => 'Offline', /// loading: () => 'Loading', /// ); /// ``` - R whenOnlineLoading({ + R whenIsLoading({ required R Function() online, required R Function() offline, required R Function() loading, diff --git a/lib/src/view/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index ec6dffea5a..061f63a8bd 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -35,37 +35,19 @@ import 'streak_screen.dart'; const _kNumberOfHistoryItemsOnHandset = 8; const _kNumberOfHistoryItemsOnTablet = 16; -class PuzzleTabScreen extends ConsumerStatefulWidget { +class PuzzleTabScreen extends ConsumerWidget { const PuzzleTabScreen({super.key}); @override - ConsumerState createState() => _PuzzleTabScreenState(); -} - -class _PuzzleTabScreenState extends ConsumerState { - final _androidRefreshKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - final session = ref.watch(authSessionProvider); - return PlatformWidget( - androidBuilder: (context) => _androidBuilder(context, session), - iosBuilder: (context) => _iosBuilder(context, session), + Widget build(BuildContext context, WidgetRef ref) { + return ConsumerPlatformWidget( + ref: ref, + androidBuilder: _androidBuilder, + iosBuilder: _iosBuilder, ); } - Widget _androidBuilder(BuildContext context, AuthSessionState? userSession) { - final body = Column( - children: [ - const ConnectivityBanner(), - Expanded( - child: _Body(userSession), - ), - ], - ); - - final isTablet = isTabletOrLarger(context); - + Widget _androidBuilder(BuildContext context, WidgetRef ref) { return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, _) { @@ -76,25 +58,24 @@ class _PuzzleTabScreenState extends ConsumerState { child: Scaffold( appBar: AppBar( title: Text(context.l10n.puzzles), - actions: [ - const _DashboardButton(), - if (!isTablet) const _HistoryButton(), + actions: const [ + _DashboardButton(), + _HistoryButton(), + ], + ), + body: const Column( + children: [ + ConnectivityBanner(), + Expanded( + child: _Body(), + ), ], ), - body: userSession != null - ? RefreshIndicator( - key: _androidRefreshKey, - onRefresh: _refreshData, - child: body, - ) - : body, ), ); } - Widget _iosBuilder(BuildContext context, AuthSessionState? userSession) { - final isTablet = isTabletOrLarger(context); - + Widget _iosBuilder(BuildContext context, WidgetRef ref) { return CupertinoPageScaffold( child: CustomScrollView( controller: puzzlesScrollController, @@ -105,42 +86,28 @@ class _PuzzleTabScreenState extends ConsumerState { end: 8.0, ), largeTitle: Text(context.l10n.puzzles), - trailing: Row( + trailing: const Row( mainAxisSize: MainAxisSize.min, children: [ - const _DashboardButton(), - if (!isTablet) ...[ - const SizedBox(width: 6.0), - const _HistoryButton(), - ], + _DashboardButton(), + SizedBox(width: 6.0), + _HistoryButton(), ], ), ), - if (userSession != null) - CupertinoSliverRefreshControl( - onRefresh: _refreshData, - ), const SliverToBoxAdapter(child: ConnectivityBanner()), - SliverSafeArea( + const SliverSafeArea( top: false, - sliver: _Body(userSession), + sliver: _Body(), ), ], ), ); } - - Future _refreshData() { - return Future.wait([ - ref.refresh(puzzleRecentActivityProvider.future), - ]); - } } class _Body extends ConsumerWidget { - const _Body(this.session); - - final AuthSessionState? session; + const _Body(); @override Widget build(BuildContext context, WidgetRef ref) { @@ -149,7 +116,7 @@ class _Body extends ConsumerWidget { final isTablet = isTabletOrLarger(context); final handsetChildren = [ - connectivity.whenOnline( + connectivity.whenIs( online: () => const _DailyPuzzle(), offline: () => const SizedBox.shrink(), ), @@ -170,7 +137,7 @@ class _Body extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ const SizedBox(height: 8.0), - connectivity.whenOnline( + connectivity.whenIs( online: () => const _DailyPuzzle(), offline: () => const SizedBox.shrink(), ), @@ -389,21 +356,25 @@ class _DashboardButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(authSessionProvider); - if (session != null) { - return AppBarIconButton( - icon: const Icon(Icons.assessment_outlined), - semanticsLabel: context.l10n.puzzlePuzzleDashboard, - onPressed: () { - ref.invalidate(puzzleDashboardProvider); - pushPlatformRoute( - context, - title: context.l10n.puzzlePuzzleDashboard, - builder: (_) => const PuzzleDashboardScreen(), - ); - }, - ); + if (session == null) { + return const SizedBox.shrink(); } - return const SizedBox.shrink(); + final onPressed = ref.watch(connectivityChangesProvider).whenIs( + online: () => () { + pushPlatformRoute( + context, + title: context.l10n.puzzlePuzzleDashboard, + builder: (_) => const PuzzleDashboardScreen(), + ); + }, + offline: () => null, + ); + + return AppBarIconButton( + icon: const Icon(Icons.assessment_outlined), + semanticsLabel: context.l10n.puzzlePuzzleDashboard, + onPressed: onPressed, + ); } } @@ -412,20 +383,24 @@ class _HistoryButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final asyncData = ref.watch(puzzleRecentActivityProvider); + final session = ref.watch(authSessionProvider); + if (session == null) { + return const SizedBox.shrink(); + } + final onPressed = ref.watch(connectivityChangesProvider).whenIs( + online: () => () { + pushPlatformRoute( + context, + title: context.l10n.puzzleHistory, + builder: (_) => const PuzzleHistoryScreen(), + ); + }, + offline: () => null, + ); return AppBarIconButton( icon: const Icon(Icons.history_outlined), semanticsLabel: context.l10n.puzzleHistory, - onPressed: asyncData.maybeWhen( - data: (_) => () { - pushPlatformRoute( - context, - title: context.l10n.puzzleHistory, - builder: (_) => const PuzzleHistoryScreen(), - ); - }, - orElse: () => null, - ), + onPressed: onPressed, ); } } @@ -547,6 +522,8 @@ class _PuzzlePreview extends ConsumerWidget { style: Styles.boardPreviewTitle, ), Text( + // TODO change this to a better description when + // translation tool is again available (#945) context.l10n.puzzleThemeHealthyMixDescription, maxLines: 3, overflow: TextOverflow.ellipsis, From f22c99889ccf4fa6db01dbd75b445254268879ee Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 7 Oct 2024 12:26:48 +0200 Subject: [PATCH 3/5] Improve puzzle tab loading; fix shimmer style --- lib/src/view/puzzle/puzzle_tab_screen.dart | 156 ++++++++++----------- lib/src/widgets/board_preview.dart | 105 +++++++++++--- lib/src/widgets/shimmer.dart | 63 ++------- 3 files changed, 175 insertions(+), 149 deletions(-) diff --git a/lib/src/view/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index 061f63a8bd..79ede93b07 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -8,6 +8,7 @@ import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_providers.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_service.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; @@ -472,19 +473,10 @@ class _DailyPuzzle extends ConsumerWidget { }, ); }, - loading: () => SmallBoardPreview( - orientation: Side.white, - fen: kEmptyFen, - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - context.l10n.puzzlePuzzleOfTheDay, - style: Styles.boardPreviewTitle, - ), - const Text(''), - ], + loading: () => const Shimmer( + child: ShimmerLoading( + isLoading: true, + child: SmallBoardPreview.loading(), ), ), error: (error, stack) => const Padding( @@ -505,77 +497,85 @@ class _PuzzlePreview extends ConsumerWidget { Widget buildPuzzlePreview(Puzzle? puzzle, {bool loading = false}) { final preview = puzzle != null ? PuzzlePreview.fromPuzzle(puzzle) : null; - return SmallBoardPreview( - orientation: preview?.orientation ?? Side.white, - fen: preview?.initialFen ?? kEmptyFen, - lastMove: preview?.initialMove, - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.puzzleDesc, - style: Styles.boardPreviewTitle, - ), - Text( - // TODO change this to a better description when - // translation tool is again available (#945) - context.l10n.puzzleThemeHealthyMixDescription, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: TextStyle( - height: 1.2, - fontSize: 12.0, + return loading + ? const Shimmer( + child: ShimmerLoading( + isLoading: true, + child: SmallBoardPreview.loading(), + ), + ) + : SmallBoardPreview( + orientation: preview?.orientation ?? Side.white, + fen: preview?.initialFen ?? kEmptyFen, + lastMove: preview?.initialMove, + description: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.puzzleDesc, + style: Styles.boardPreviewTitle, + ), + Text( + // TODO change this to a better description when + // translation tool is again available (#945) + context.l10n.puzzleThemeHealthyMixDescription, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + height: 1.2, + fontSize: 12.0, + color: DefaultTextStyle.of(context) + .style + .color + ?.withValues(alpha: 0.6), + ), + ), + ], + ), + Icon( + PuzzleIcons.mix, + size: 34, color: DefaultTextStyle.of(context) .style .color ?.withValues(alpha: 0.6), ), - ), - ], - ), - Icon( - PuzzleIcons.mix, - size: 34, - color: DefaultTextStyle.of(context) - .style - .color - ?.withValues(alpha: 0.6), - ), - if (puzzle != null) - Text( - puzzle.puzzle.sideToMove == Side.white - ? context.l10n.whitePlays - : context.l10n.blackPlays, - ) - else if (!loading) - const Text( - 'No puzzles available, please go online to fetch them.', + if (puzzle != null) + Text( + puzzle.puzzle.sideToMove == Side.white + ? context.l10n.whitePlays + : context.l10n.blackPlays, + ) + else + const Text( + 'No puzzles available, please go online to fetch them.', + ), + ], ), - ], - ), - onTap: puzzle != null - ? () { - pushPlatformRoute( - context, - rootNavigator: true, - builder: (context) => const PuzzleScreen( - angle: PuzzleTheme(PuzzleThemeKey.mix), - ), - ).then((_) { - if (context.mounted) { - ref.invalidate( - nextPuzzleProvider(const PuzzleTheme(PuzzleThemeKey.mix)), - ); - } - }); - } - : null, - ); + onTap: puzzle != null + ? () { + pushPlatformRoute( + context, + rootNavigator: true, + builder: (context) => const PuzzleScreen( + angle: PuzzleTheme(PuzzleThemeKey.mix), + ), + ).then((_) { + if (context.mounted) { + ref.invalidate( + nextPuzzleProvider( + const PuzzleTheme(PuzzleThemeKey.mix)), + ); + } + }); + } + : null, + ); } return puzzle.maybeWhen( diff --git a/lib/src/widgets/board_preview.dart b/lib/src/widgets/board_preview.dart index ea72369549..c52b9e22b6 100644 --- a/lib/src/widgets/board_preview.dart +++ b/lib/src/widgets/board_preview.dart @@ -16,7 +16,16 @@ class SmallBoardPreview extends ConsumerStatefulWidget { this.padding, this.lastMove, this.onTap, - }); + }) : _showLoadingPlaceholder = false; + + const SmallBoardPreview.loading({ + this.padding, + }) : orientation = Side.white, + fen = kEmptyFEN, + lastMove = null, + description = const SizedBox.shrink(), + onTap = null, + _showLoadingPlaceholder = true; /// Side by which the board is oriented. final Side orientation; @@ -33,6 +42,8 @@ class SmallBoardPreview extends ConsumerStatefulWidget { final EdgeInsetsGeometry? padding; + final bool _showLoadingPlaceholder; + @override ConsumerState createState() => _SmallBoardPreviewState(); } @@ -65,23 +76,85 @@ class _SmallBoardPreviewState extends ConsumerState { height: boardSize, child: Row( children: [ - Chessboard.fixed( - size: boardSize, - fen: widget.fen, - orientation: widget.orientation, - lastMove: widget.lastMove as NormalMove?, - settings: ChessboardSettings( - enableCoordinates: false, - borderRadius: - const BorderRadius.all(Radius.circular(4.0)), - boxShadow: boardShadows, - animationDuration: const Duration(milliseconds: 150), - pieceAssets: boardPrefs.pieceSet.assets, - colorScheme: boardPrefs.boardTheme.colors, + if (widget._showLoadingPlaceholder) + Container( + width: boardSize, + height: boardSize, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ), + ) + else + Chessboard.fixed( + size: boardSize, + fen: widget.fen, + orientation: widget.orientation, + lastMove: widget.lastMove as NormalMove?, + settings: ChessboardSettings( + enableCoordinates: false, + borderRadius: + const BorderRadius.all(Radius.circular(4.0)), + boxShadow: boardShadows, + animationDuration: const Duration(milliseconds: 150), + pieceAssets: boardPrefs.pieceSet.assets, + colorScheme: boardPrefs.boardTheme.colors, + ), ), - ), const SizedBox(width: 10.0), - Expanded(child: widget.description), + if (widget._showLoadingPlaceholder) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16.0, + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + const SizedBox(height: 4.0), + Container( + height: 16.0, + width: MediaQuery.sizeOf(context).width / 3, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + ], + ), + Container( + height: 44.0, + width: 44.0, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + Container( + height: 16.0, + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + ], + ), + ) + else + Expanded(child: widget.description), ], ), ), diff --git a/lib/src/widgets/shimmer.dart b/lib/src/widgets/shimmer.dart index 045a9dd546..825c17d88e 100644 --- a/lib/src/widgets/shimmer.dart +++ b/lib/src/widgets/shimmer.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class Shimmer extends StatefulWidget { @@ -21,26 +20,12 @@ class ShimmerState extends State with SingleTickerProviderStateMixin { late AnimationController _shimmerController; LinearGradient get _defaultGradient { - switch (Theme.of(context).platform) { - case TargetPlatform.android: - final brightness = Theme.of(context).brightness; - switch (brightness) { - case Brightness.light: - return androidLightShimmerGradient; - case Brightness.dark: - return androidDarkShimmerGradient; - } - case TargetPlatform.iOS: - final brightness = - CupertinoTheme.maybeBrightnessOf(context) ?? Brightness.light; - switch (brightness) { - case Brightness.light: - return iOSLightShimmerGradient; - case Brightness.dark: - return iOSDarkShimmerGradient; - } - default: - throw 'Unexpected platform $Theme.of(context).platform'; + final brightness = Theme.of(context).brightness; + switch (brightness) { + case Brightness.light: + return lightShimmerGradient; + case Brightness.dark: + return darkShimmerGradient; } } @@ -167,7 +152,7 @@ class _ShimmerLoadingState extends State { } } -const iOSLightShimmerGradient = LinearGradient( +const lightShimmerGradient = LinearGradient( colors: [ Color(0xFFE3E3E6), Color(0xFFECECEE), @@ -183,39 +168,7 @@ const iOSLightShimmerGradient = LinearGradient( tileMode: TileMode.clamp, ); -const iOSDarkShimmerGradient = LinearGradient( - colors: [ - Color(0xFF111111), - Color(0xFF1a1a1a), - Color(0xFF111111), - ], - stops: [ - 0.1, - 0.3, - 0.4, - ], - begin: Alignment(-1.0, -0.3), - end: Alignment(1.0, 0.3), - tileMode: TileMode.clamp, -); - -const androidLightShimmerGradient = LinearGradient( - colors: [ - Color(0xFFE6E6E6), - Color(0xFFEFEFEF), - Color(0xFFE6E6E6), - ], - stops: [ - 0.1, - 0.3, - 0.4, - ], - begin: Alignment(-1.0, -0.3), - end: Alignment(1.0, 0.3), - tileMode: TileMode.clamp, -); - -const androidDarkShimmerGradient = LinearGradient( +const darkShimmerGradient = LinearGradient( colors: [ Color(0xFF333333), Color(0xFF3c3c3c), From 47a75b80772269a7cfe055940dbb197b192222cb Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 7 Oct 2024 15:35:23 +0200 Subject: [PATCH 4/5] Don't use timers in test http mock responses --- test/model/auth/auth_controller_test.dart | 10 +++++----- test/test_helpers.dart | 23 ++++++----------------- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/test/model/auth/auth_controller_test.dart b/test/model/auth/auth_controller_test.dart index e0df8d1895..65e02b35a4 100644 --- a/test/model/auth/auth_controller_test.dart +++ b/test/model/auth/auth_controller_test.dart @@ -72,12 +72,12 @@ void main() { group('AuthController', () { test('sign in', () async { when(() => mockSessionStorage.read()) - .thenAnswer((_) => delayedAnswer(null)); + .thenAnswer((_) => Future.value(null)); when(() => mockFlutterAppAuth.authorizeAndExchangeCode(any())) - .thenAnswer((_) => delayedAnswer(signInResponse)); + .thenAnswer((_) => Future.value(signInResponse)); when( () => mockSessionStorage.write(any()), - ).thenAnswer((_) => delayedAnswer(null)); + ).thenAnswer((_) => Future.value(null)); final container = await makeContainer( overrides: [ @@ -117,10 +117,10 @@ void main() { test('sign out', () async { when(() => mockSessionStorage.read()) - .thenAnswer((_) => delayedAnswer(testUserSession)); + .thenAnswer((_) => Future.value(testUserSession)); when( () => mockSessionStorage.delete(), - ).thenAnswer((_) => delayedAnswer(null)); + ).thenAnswer((_) => Future.value(null)); final container = await makeContainer( overrides: [ diff --git a/test/test_helpers.dart b/test/test_helpers.dart index f1b109c6b0..da5507f50b 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -19,17 +19,14 @@ const kPlatformVariant = Matcher sameRequest(http.BaseRequest request) => _SameRequest(request); Matcher sameHeaders(Map headers) => _SameHeaders(headers); -Future delayedAnswer(T value) => - Future.delayed(const Duration(milliseconds: 5)).then((_) => value); - /// Mocks an http response with a delay of 20ms. Future mockResponse( String body, int code, { Map headers = const {}, }) => - Future.delayed(const Duration(milliseconds: 20)).then( - (_) => http.Response( + Future.value( + http.Response( body, code, headers: headers, @@ -37,23 +34,21 @@ Future mockResponse( ); Future mockStreamedResponse(String body, int code) => - Future.delayed(const Duration(milliseconds: 20)).then( - (_) => http.StreamedResponse(Stream.value(body).map(utf8.encode), code), + Future.value( + http.StreamedResponse(Stream.value(body).map(utf8.encode), code), ); Future mockHttpStreamFromIterable( Iterable events, ) async { - await Future.delayed(const Duration(milliseconds: 20)); return http.StreamedResponse( - _streamFromFutures(events.map((e) => _withDelay(utf8.encode(e)))), + _streamFromFutures(events.map((e) => Future.value(utf8.encode(e)))), 200, ); } Future mockHttpStream(Stream stream) => - Future.delayed(const Duration(milliseconds: 20)) - .then((_) => http.StreamedResponse(stream.map(utf8.encode), 200)); + Future.value(http.StreamedResponse(stream.map(utf8.encode), 200)); Future tapBackButton(WidgetTester tester) async { if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) { @@ -148,9 +143,3 @@ Stream _streamFromFutures(Iterable> futures) async* { yield result; } } - -Future _withDelay( - T value, { - Duration delay = const Duration(milliseconds: 10), -}) => - Future.delayed(delay).then((_) => value); From 520ba76ffce06be353e6a4b136d1c1b7afcab6f1 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Mon, 7 Oct 2024 15:35:41 +0200 Subject: [PATCH 5/5] Add puzzle tab screen tests --- lib/src/view/puzzle/puzzle_tab_screen.dart | 30 +-- test/model/puzzle/mock_server_responses.dart | 7 + test/model/puzzle/puzzle_repository_test.dart | 8 +- test/test_container.dart | 14 -- test/test_provider_scope.dart | 11 -- test/view/puzzle/example_data.dart | 86 ++++++++ test/view/puzzle/puzzle_screen_test.dart | 84 +------- test/view/puzzle/puzzle_tab_screen_test.dart | 187 ++++++++++++++++++ 8 files changed, 300 insertions(+), 127 deletions(-) create mode 100644 test/model/puzzle/mock_server_responses.dart create mode 100644 test/view/puzzle/example_data.dart create mode 100644 test/view/puzzle/puzzle_tab_screen_test.dart diff --git a/lib/src/view/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index 79ede93b07..e34871785a 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -8,7 +8,6 @@ import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_providers.dart'; -import 'package:lichess_mobile/src/model/puzzle/puzzle_service.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; import 'package:lichess_mobile/src/navigation.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; @@ -118,11 +117,11 @@ class _Body extends ConsumerWidget { final handsetChildren = [ connectivity.whenIs( - online: () => const _DailyPuzzle(), + online: () => const DailyPuzzle(), offline: () => const SizedBox.shrink(), ), const SizedBox(height: 4.0), - const _PuzzlePreview(), + const TacticalTrainingPreview(), if (Theme.of(context).platform == TargetPlatform.android) const SizedBox(height: 8.0), _PuzzleMenu(connectivity: connectivity), @@ -139,7 +138,7 @@ class _Body extends ConsumerWidget { children: [ const SizedBox(height: 8.0), connectivity.whenIs( - online: () => const _DailyPuzzle(), + online: () => const DailyPuzzle(), offline: () => const SizedBox.shrink(), ), _PuzzleMenu(connectivity: connectivity), @@ -413,8 +412,9 @@ TextStyle _puzzlePreviewSubtitleStyle(BuildContext context) { ); } -class _DailyPuzzle extends ConsumerWidget { - const _DailyPuzzle(); +/// A widget that displays the daily puzzle. +class DailyPuzzle extends ConsumerWidget { + const DailyPuzzle(); @override Widget build(BuildContext context, WidgetRef ref) { @@ -479,16 +479,19 @@ class _DailyPuzzle extends ConsumerWidget { child: SmallBoardPreview.loading(), ), ), - error: (error, stack) => const Padding( - padding: Styles.bodySectionPadding, - child: Text('Could not load the daily puzzle.'), - ), + error: (error, _) { + return const Padding( + padding: Styles.bodySectionPadding, + child: Text('Could not load the daily puzzle.'), + ); + }, ); } } -class _PuzzlePreview extends ConsumerWidget { - const _PuzzlePreview(); +/// A widget that displays a preview of the tactical training screen. +class TacticalTrainingPreview extends ConsumerWidget { + const TacticalTrainingPreview(); @override Widget build(BuildContext context, WidgetRef ref) { @@ -569,7 +572,8 @@ class _PuzzlePreview extends ConsumerWidget { if (context.mounted) { ref.invalidate( nextPuzzleProvider( - const PuzzleTheme(PuzzleThemeKey.mix)), + const PuzzleTheme(PuzzleThemeKey.mix), + ), ); } }); diff --git a/test/model/puzzle/mock_server_responses.dart b/test/model/puzzle/mock_server_responses.dart new file mode 100644 index 0000000000..01653958f3 --- /dev/null +++ b/test/model/puzzle/mock_server_responses.dart @@ -0,0 +1,7 @@ +const mockDailyPuzzleResponse = ''' +{"game":{"id":"MNMYnEjm","perf":{"key":"classical","name":"Classical"},"rated":true,"players":[{"name":"Igor76","id":"igor76","color":"white","rating":2211},{"name":"dmitriy_duyun","id":"dmitriy_duyun","color":"black","rating":2180}],"pgn":"e4 c6 d4 d5 Nc3 g6 Nf3 Bg7 h3 dxe4 Nxe4 Nf6 Bd3 Nxe4 Bxe4 Nd7 O-O Nf6 Bd3 O-O Re1 Bf5 Bxf5 gxf5 c3 e6 Bg5 Qb6 Qc2 Rac8 Ne5 Qc7 Rad1 Nd7 Bf4 Nxe5 Bxe5 Bxe5 Rxe5 Rcd8 Qd2 Kh8 Rde1 Rg8 Qf4","clock":"20+15"},"puzzle":{"id":"0XqV2","rating":1929,"plays":93270,"solution":["f7f6","e5f5","c7g7","g2g3","e6f5"],"themes":["clearance","endgame","advantage","intermezzo","long"],"initialPly":44}} +'''; + +const mockMixBatchResponse = ''' +{"puzzles":[{"game":{"id":"PrlkCqOv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"silverjo","name":"silverjo (1777)","color":"white"},{"userId":"robyarchitetto","name":"Robyarchitetto (1742)","color":"black"}],"pgn":"e4 Nc6 Bc4 e6 a3 g6 Nf3 Bg7 c3 Nge7 d3 O-O Be3 Na5 Ba2 b6 Qd2 Bb7 Bh6 d5 e5 d4 Bxg7 Kxg7 Qf4 Bxf3 Qxf3 dxc3 Nxc3 Nac6 Qf6+ Kg8 Rd1 Nd4 O-O c5 Ne4 Nef5 Rd2 Qxf6 Nxf6+ Kg7 Re1 h5 h3 Rad8 b4 Nh4 Re3 Nhf5 Re1 a5 bxc5 bxc5 Bc4 Ra8 Rb1 Nh4 Rdb2 Nc6 Rb7 Nxe5 Bxe6 Kxf6 Bd5 Nf5 R7b6+ Kg7 Bxa8 Rxa8 R6b3 Nd4 Rb7 Nxd3 Rd1 Ne2+ Kh2 Ndf4 Rdd7 Rf8 Ra7 c4 Rxa5 c3 Rc5 Ne6 Rc4 Ra8 a4 Rb8 a5 Rb2 a6 c2","clock":"5+8"},"puzzle":{"id":"20yWT","rating":1859,"plays":551,"initialPly":93,"solution":["a6a7","b2a2","c4c2","a2a7","d7a7"],"themes":["endgame","long","advantage","advancedPawn"]}},{"game":{"id":"0lwkiJbZ","perf":{"key":"classical","name":"Classical"},"rated":true,"players":[{"userId":"nirdosh","name":"nirdosh (2035)","color":"white"},{"userId":"burn_it_down","name":"burn_it_down (2139)","color":"black"}],"pgn":"d4 Nf6 Nf3 c5 e3 g6 Bd3 Bg7 c3 Qc7 O-O O-O Nbd2 d6 Qe2 Nbd7 e4 cxd4 cxd4 e5 dxe5 dxe5 b3 Nc5 Bb2 Nh5 g3 Bh3 Rfc1 Qd6 Bc4 Rac8 Bd5 Qb8 Ng5 Bd7 Ba3 b6 Rc2 h6 Ngf3 Rfe8 Rac1 Ne6 Nc4 Bb5 Qe3 Bxc4 Bxc4 Nd4 Nxd4 exd4 Qd3 Rcd8 f4 Nf6 e5 Ng4 Qxg6 Ne3 Bxf7+ Kh8 Rc7 Qa8 Qxg7+ Kxg7 Bd5+ Kg6 Bxa8 Rxa8 Rd7 Rad8 Rc6+ Kf5 Rcd6 Rxd7 Rxd7 Ke4 Bb2 Nc2 Kf2 d3 Bc1 Nd4 h3","clock":"15+15"},"puzzle":{"id":"7H5EV","rating":1852,"plays":410,"initialPly":84,"solution":["e8c8","d7d4","e4d4"],"themes":["endgame","short","advantage"]}},{"game":{"id":"eWGRX5AI","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"sacalot","name":"sacalot (2151)","color":"white"},{"userId":"landitirana","name":"landitirana (1809)","color":"black"}],"pgn":"e4 e5 Nf3 Nc6 d4 exd4 Bc4 Nf6 O-O Nxe4 Re1 d5 Bxd5 Qxd5 Nc3 Qd8 Rxe4+ Be6 Nxd4 Nxd4 Rxd4 Qf6 Ne4 Qe5 f4 Qf5 Ng3 Qa5 Bd2 Qb6 Be3 Bc5 f5 Bd5 Rxd5 Bxe3+ Kh1 O-O Rd3 Rfe8 Qf3 Qxb2 Rf1 Bd4 Nh5 Bf6 Rb3 Qd4 Rxb7 Re3 Nxf6+ gxf6 Qf2 Rae8 Rxc7 Qe5 Rc4 Re1 Rf4 Qa1 h3","clock":"10+0"},"puzzle":{"id":"1qUth","rating":1556,"plays":2661,"initialPly":60,"solution":["e1f1","f2f1","e8e1","f1e1","a1e1"],"themes":["endgame","master","advantage","fork","long","pin"]}}]} +'''; diff --git a/test/model/puzzle/puzzle_repository_test.dart b/test/model/puzzle/puzzle_repository_test.dart index 6098c0aa59..2002320c3a 100644 --- a/test/model/puzzle/puzzle_repository_test.dart +++ b/test/model/puzzle/puzzle_repository_test.dart @@ -6,18 +6,14 @@ import 'package:lichess_mobile/src/network/http.dart'; import '../../test_container.dart'; import '../../test_helpers.dart'; +import 'mock_server_responses.dart'; void main() { group('PuzzleRepository', () { test('selectBatch', () async { final mockClient = MockClient((request) { if (request.url.path == '/api/puzzle/batch/mix') { - return mockResponse( - ''' -{"puzzles":[{"game":{"id":"PrlkCqOv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"silverjo","name":"silverjo (1777)","color":"white"},{"userId":"robyarchitetto","name":"Robyarchitetto (1742)","color":"black"}],"pgn":"e4 Nc6 Bc4 e6 a3 g6 Nf3 Bg7 c3 Nge7 d3 O-O Be3 Na5 Ba2 b6 Qd2 Bb7 Bh6 d5 e5 d4 Bxg7 Kxg7 Qf4 Bxf3 Qxf3 dxc3 Nxc3 Nac6 Qf6+ Kg8 Rd1 Nd4 O-O c5 Ne4 Nef5 Rd2 Qxf6 Nxf6+ Kg7 Re1 h5 h3 Rad8 b4 Nh4 Re3 Nhf5 Re1 a5 bxc5 bxc5 Bc4 Ra8 Rb1 Nh4 Rdb2 Nc6 Rb7 Nxe5 Bxe6 Kxf6 Bd5 Nf5 R7b6+ Kg7 Bxa8 Rxa8 R6b3 Nd4 Rb7 Nxd3 Rd1 Ne2+ Kh2 Ndf4 Rdd7 Rf8 Ra7 c4 Rxa5 c3 Rc5 Ne6 Rc4 Ra8 a4 Rb8 a5 Rb2 a6 c2","clock":"5+8"},"puzzle":{"id":"20yWT","rating":1859,"plays":551,"initialPly":93,"solution":["a6a7","b2a2","c4c2","a2a7","d7a7"],"themes":["endgame","long","advantage","advancedPawn"]}},{"game":{"id":"0lwkiJbZ","perf":{"key":"classical","name":"Classical"},"rated":true,"players":[{"userId":"nirdosh","name":"nirdosh (2035)","color":"white"},{"userId":"burn_it_down","name":"burn_it_down (2139)","color":"black"}],"pgn":"d4 Nf6 Nf3 c5 e3 g6 Bd3 Bg7 c3 Qc7 O-O O-O Nbd2 d6 Qe2 Nbd7 e4 cxd4 cxd4 e5 dxe5 dxe5 b3 Nc5 Bb2 Nh5 g3 Bh3 Rfc1 Qd6 Bc4 Rac8 Bd5 Qb8 Ng5 Bd7 Ba3 b6 Rc2 h6 Ngf3 Rfe8 Rac1 Ne6 Nc4 Bb5 Qe3 Bxc4 Bxc4 Nd4 Nxd4 exd4 Qd3 Rcd8 f4 Nf6 e5 Ng4 Qxg6 Ne3 Bxf7+ Kh8 Rc7 Qa8 Qxg7+ Kxg7 Bd5+ Kg6 Bxa8 Rxa8 Rd7 Rad8 Rc6+ Kf5 Rcd6 Rxd7 Rxd7 Ke4 Bb2 Nc2 Kf2 d3 Bc1 Nd4 h3","clock":"15+15"},"puzzle":{"id":"7H5EV","rating":1852,"plays":410,"initialPly":84,"solution":["e8c8","d7d4","e4d4"],"themes":["endgame","short","advantage"]}},{"game":{"id":"eWGRX5AI","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"sacalot","name":"sacalot (2151)","color":"white"},{"userId":"landitirana","name":"landitirana (1809)","color":"black"}],"pgn":"e4 e5 Nf3 Nc6 d4 exd4 Bc4 Nf6 O-O Nxe4 Re1 d5 Bxd5 Qxd5 Nc3 Qd8 Rxe4+ Be6 Nxd4 Nxd4 Rxd4 Qf6 Ne4 Qe5 f4 Qf5 Ng3 Qa5 Bd2 Qb6 Be3 Bc5 f5 Bd5 Rxd5 Bxe3+ Kh1 O-O Rd3 Rfe8 Qf3 Qxb2 Rf1 Bd4 Nh5 Bf6 Rb3 Qd4 Rxb7 Re3 Nxf6+ gxf6 Qf2 Rae8 Rxc7 Qe5 Rc4 Re1 Rf4 Qa1 h3","clock":"10+0"},"puzzle":{"id":"1qUth","rating":1556,"plays":2661,"initialPly":60,"solution":["e1f1","f2f1","e8e1","f1e1","a1e1"],"themes":["endgame","master","advantage","fork","long","pin"]}}]} -''', - 200, - ); + return mockResponse(mockMixBatchResponse, 200); } return mockResponse('', 404); }); diff --git a/test/test_container.dart b/test/test_container.dart index 0a1a42cdc1..bdc0e711fa 100644 --- a/test/test_container.dart +++ b/test/test_container.dart @@ -1,10 +1,8 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:intl/intl.dart'; import 'package:lichess_mobile/src/crashlytics.dart'; import 'package:lichess_mobile/src/db/database.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; @@ -13,7 +11,6 @@ import 'package:lichess_mobile/src/model/notifications/notification_service.dart import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; import 'package:lichess_mobile/src/utils/connectivity.dart'; -import 'package:logging/logging.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import './fake_crashlytics.dart'; @@ -29,8 +26,6 @@ final testContainerMockClient = MockClient((request) async { return http.Response('', 200); }); -const shouldLog = false; - /// Returns a [ProviderContainer] with the [httpClientFactoryProvider] configured /// with the given [mockClient]. Future lichessClientContainer(MockClient mockClient) async { @@ -56,15 +51,6 @@ Future makeContainer({ await binding.preloadData(userSession); - Logger.root.onRecord.listen((record) { - if (shouldLog && record.level >= Level.FINE) { - final time = DateFormat.Hms().format(record.time); - debugPrint( - '${record.level.name} at $time [${record.loggerName}] ${record.message}${record.error != null ? '\n${record.error}' : ''}', - ); - } - }); - final container = ProviderContainer( overrides: [ connectivityPluginProvider.overrideWith((_) { diff --git a/test/test_provider_scope.dart b/test/test_provider_scope.dart index 253b503db1..e081e2df01 100644 --- a/test/test_provider_scope.dart +++ b/test/test_provider_scope.dart @@ -7,7 +7,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:intl/intl.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/crashlytics.dart'; import 'package:lichess_mobile/src/db/database.dart'; @@ -20,7 +19,6 @@ import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; import 'package:lichess_mobile/src/utils/connectivity.dart'; -import 'package:logging/logging.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -120,15 +118,6 @@ Future makeTestProviderScope( // TODO consider loading true fonts as well FlutterError.onError = _ignoreOverflowErrors; - Logger.root.onRecord.listen((record) { - if (record.level > Level.WARNING) { - final time = DateFormat.Hms().format(record.time); - debugPrint( - '${record.level.name} at $time [${record.loggerName}] ${record.message}${record.error != null ? '\n${record.error}' : ''}', - ); - } - }); - return ProviderScope( overrides: [ // ignore: scoped_providers_should_specify_dependencies diff --git a/test/view/puzzle/example_data.dart b/test/view/puzzle/example_data.dart new file mode 100644 index 0000000000..8a7b6a0b8c --- /dev/null +++ b/test/view/puzzle/example_data.dart @@ -0,0 +1,86 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/common/perf.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart'; + +final puzzle = Puzzle( + puzzle: PuzzleData( + id: const PuzzleId('6Sz3s'), + initialPly: 40, + plays: 68176, + rating: 1984, + solution: IList(const [ + 'h4h2', + 'h1h2', + 'e5f3', + 'h2h3', + 'b4h4', + ]), + themes: ISet(const [ + 'middlegame', + 'attraction', + 'long', + 'mateIn3', + 'sacrifice', + 'doubleCheck', + ]), + ), + game: const PuzzleGame( + rated: true, + id: GameId('zgBwsXLr'), + perf: Perf.blitz, + pgn: + 'e4 c5 Nf3 e6 c4 Nc6 d4 cxd4 Nxd4 Bc5 Nxc6 bxc6 Be2 Ne7 O-O Ng6 Nc3 Rb8 Kh1 Bb7 f4 d5 f5 Ne5 fxe6 fxe6 cxd5 cxd5 exd5 Bxd5 Qa4+ Bc6 Qf4 Bd6 Ne4 Bxe4 Qxe4 Rb4 Qe3 Qh4 Qxa7', + black: PuzzleGamePlayer( + side: Side.black, + name: 'CAMBIADOR', + ), + white: PuzzleGamePlayer( + side: Side.white, + name: 'arroyoM10', + ), + ), +); + +final batch = PuzzleBatch( + solved: IList(const []), + unsolved: IList([ + puzzle, + ]), +); + +final puzzle2 = Puzzle( + puzzle: PuzzleData( + id: const PuzzleId('2nNdI'), + rating: 1090, + plays: 23890, + initialPly: 88, + solution: IList(const ['g4h4', 'h8h4', 'b4h4']), + themes: ISet(const { + 'endgame', + 'short', + 'crushing', + 'fork', + 'queenRookEndgame', + }), + ), + game: const PuzzleGame( + id: GameId('w32JTzEf'), + perf: Perf.blitz, + rated: true, + white: PuzzleGamePlayer( + side: Side.white, + name: 'Li', + title: null, + ), + black: PuzzleGamePlayer( + side: Side.black, + name: 'Gabriela', + title: null, + ), + pgn: + 'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 b5 Bb3 Nf6 c3 Nxe4 d4 exd4 cxd4 Qe7 O-O Qd8 Bd5 Nf6 Bb3 Bd6 Nc3 O-O Bg5 h6 Bh4 g5 Nxg5 hxg5 Bxg5 Kg7 Ne4 Be7 Bxf6+ Bxf6 Qg4+ Kh8 Qh5+ Kg8 Qg6+ Kh8 Qxf6+ Qxf6 Nxf6 Nxd4 Rfd1 Ne2+ Kh1 d6 Rd5 Kg7 Nh5+ Kh6 Rad1 Be6 R5d2 Bxb3 axb3 Kxh5 Rxe2 Rfe8 Red2 Re5 h3 Rae8 Kh2 Re2 Rd5+ Kg6 f4 Rxb2 R1d3 Ree2 Rg3+ Kf6 h4 Re4 Rg4 Rxb3 h5 Rbb4 h6 Rxf4 h7 Rxg4 h8=Q+ Ke7 Rd3', + ), +); diff --git a/test/view/puzzle/puzzle_screen_test.dart b/test/view/puzzle/puzzle_screen_test.dart index 7cc0dca6a1..41497a07ab 100644 --- a/test/view/puzzle/puzzle_screen_test.dart +++ b/test/view/puzzle/puzzle_screen_test.dart @@ -6,9 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; -import 'package:lichess_mobile/src/model/common/id.dart'; -import 'package:lichess_mobile/src/model/common/perf.dart'; -import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_storage.dart'; @@ -21,6 +18,7 @@ import 'package:mocktail/mocktail.dart'; import '../../test_helpers.dart'; import '../../test_provider_scope.dart'; +import 'example_data.dart'; class MockPuzzleBatchStorage extends Mock implements PuzzleBatchStorage {} @@ -457,86 +455,6 @@ void main() { }); } -final puzzle = Puzzle( - puzzle: PuzzleData( - id: const PuzzleId('6Sz3s'), - initialPly: 40, - plays: 68176, - rating: 1984, - solution: IList(const [ - 'h4h2', - 'h1h2', - 'e5f3', - 'h2h3', - 'b4h4', - ]), - themes: ISet(const [ - 'middlegame', - 'attraction', - 'long', - 'mateIn3', - 'sacrifice', - 'doubleCheck', - ]), - ), - game: const PuzzleGame( - rated: true, - id: GameId('zgBwsXLr'), - perf: Perf.blitz, - pgn: - 'e4 c5 Nf3 e6 c4 Nc6 d4 cxd4 Nxd4 Bc5 Nxc6 bxc6 Be2 Ne7 O-O Ng6 Nc3 Rb8 Kh1 Bb7 f4 d5 f5 Ne5 fxe6 fxe6 cxd5 cxd5 exd5 Bxd5 Qa4+ Bc6 Qf4 Bd6 Ne4 Bxe4 Qxe4 Rb4 Qe3 Qh4 Qxa7', - black: PuzzleGamePlayer( - side: Side.black, - name: 'CAMBIADOR', - ), - white: PuzzleGamePlayer( - side: Side.white, - name: 'arroyoM10', - ), - ), -); - -final batch = PuzzleBatch( - solved: IList(const []), - unsolved: IList([ - puzzle, - ]), -); - -final puzzle2 = Puzzle( - puzzle: PuzzleData( - id: const PuzzleId('2nNdI'), - rating: 1090, - plays: 23890, - initialPly: 88, - solution: IList(const ['g4h4', 'h8h4', 'b4h4']), - themes: ISet(const { - 'endgame', - 'short', - 'crushing', - 'fork', - 'queenRookEndgame', - }), - ), - game: const PuzzleGame( - id: GameId('w32JTzEf'), - perf: Perf.blitz, - rated: true, - white: PuzzleGamePlayer( - side: Side.white, - name: 'Li', - title: null, - ), - black: PuzzleGamePlayer( - side: Side.black, - name: 'Gabriela', - title: null, - ), - pgn: - 'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 b5 Bb3 Nf6 c3 Nxe4 d4 exd4 cxd4 Qe7 O-O Qd8 Bd5 Nf6 Bb3 Bd6 Nc3 O-O Bg5 h6 Bh4 g5 Nxg5 hxg5 Bxg5 Kg7 Ne4 Be7 Bxf6+ Bxf6 Qg4+ Kh8 Qh5+ Kg8 Qg6+ Kh8 Qxf6+ Qxf6 Nxf6 Nxd4 Rfd1 Ne2+ Kh1 d6 Rd5 Kg7 Nh5+ Kh6 Rad1 Be6 R5d2 Bxb3 axb3 Kxh5 Rxe2 Rfe8 Red2 Re5 h3 Rae8 Kh2 Re2 Rd5+ Kg6 f4 Rxb2 R1d3 Ree2 Rg3+ Kf6 h4 Re4 Rg4 Rxb3 h5 Rbb4 h6 Rxf4 h7 Rxg4 h8=Q+ Ke7 Rd3', - ), -); - const batchOf1 = ''' {"puzzles":[{"game":{"id":"PrlkCqOv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"name":"silverjo", "rating":1777,"color":"white"},{"name":"Robyarchitetto", "rating":1742,"color":"black"}],"pgn":"e4 Nc6 Bc4 e6 a3 g6 Nf3 Bg7 c3 Nge7 d3 O-O Be3 Na5 Ba2 b6 Qd2 Bb7 Bh6 d5 e5 d4 Bxg7 Kxg7 Qf4 Bxf3 Qxf3 dxc3 Nxc3 Nac6 Qf6+ Kg8 Rd1 Nd4 O-O c5 Ne4 Nef5 Rd2 Qxf6 Nxf6+ Kg7 Re1 h5 h3 Rad8 b4 Nh4 Re3 Nhf5 Re1 a5 bxc5 bxc5 Bc4 Ra8 Rb1 Nh4 Rdb2 Nc6 Rb7 Nxe5 Bxe6 Kxf6 Bd5 Nf5 R7b6+ Kg7 Bxa8 Rxa8 R6b3 Nd4 Rb7 Nxd3 Rd1 Ne2+ Kh2 Ndf4 Rdd7 Rf8 Ra7 c4 Rxa5 c3 Rc5 Ne6 Rc4 Ra8 a4 Rb8 a5 Rb2 a6 c2","clock":"5+8"},"puzzle":{"id":"20yWT","rating":1859,"plays":551,"initialPly":93,"solution":["a6a7","b2a2","c4c2","a2a7","d7a7"],"themes":["endgame","long","advantage","advancedPawn"]}}]} '''; diff --git a/test/view/puzzle/puzzle_tab_screen_test.dart b/test/view/puzzle/puzzle_tab_screen_test.dart new file mode 100644 index 0000000000..32149feea2 --- /dev/null +++ b/test/view/puzzle/puzzle_tab_screen_test.dart @@ -0,0 +1,187 @@ +import 'package:chessground/chessground.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/view/puzzle/puzzle_tab_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../model/puzzle/mock_server_responses.dart'; +import '../../network/fake_http_client_factory.dart'; +import '../../test_helpers.dart'; +import '../../test_provider_scope.dart'; +import 'example_data.dart'; + +final mockClient = MockClient((request) async { + if (request.url.path == '/api/puzzle/daily') { + return mockResponse(mockDailyPuzzleResponse, 200); + } + return mockResponse('', 404); +}); + +class MockPuzzleBatchStorage extends Mock implements PuzzleBatchStorage {} + +void main() { + setUpAll(() { + registerFallbackValue( + PuzzleBatch( + solved: IList(const []), + unsolved: IList([puzzle]), + ), + ); + }); + + final mockBatchStorage = MockPuzzleBatchStorage(); + + testWidgets('meets accessibility guidelines', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + ], + ); + + await tester.pumpWidget(app); + + // wait for connectivity + await tester.pump(const Duration(milliseconds: 100)); + + // wait for the puzzles to load + await tester.pump(const Duration(milliseconds: 100)); + + await meetsTapTargetGuideline(tester); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + + testWidgets('shows puzzle menu', (WidgetTester tester) async { + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + httpClientFactoryProvider.overrideWith((ref) { + return FakeHttpClientFactory(() => mockClient); + }), + ], + ); + + await tester.pumpWidget(app); + + // wait for connectivity + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + + expect(find.text('Puzzle Themes'), findsOneWidget); + expect(find.text('Puzzle Streak'), findsOneWidget); + expect(find.text('Puzzle Storm'), findsOneWidget); + }); + + testWidgets('shows daily puzzle', (WidgetTester tester) async { + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + httpClientFactoryProvider.overrideWith((ref) { + return FakeHttpClientFactory(() => mockClient); + }), + ], + ); + + await tester.pumpWidget(app); + + // wait for connectivity + await tester.pump(const Duration(milliseconds: 100)); + + // wait for the puzzles to load + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(DailyPuzzle), findsOneWidget); + expect( + find.widgetWithText(DailyPuzzle, 'Puzzle of the day'), + findsOneWidget, + ); + expect( + find.widgetWithText(DailyPuzzle, 'Played 93,270 times'), + findsOneWidget, + ); + expect(find.widgetWithText(DailyPuzzle, 'Black to play'), findsOneWidget); + }); + + group('tactical training preview', () { + testWidgets('shows first puzzle from unsolved batch', + (WidgetTester tester) async { + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + httpClientFactoryProvider.overrideWith((ref) { + return FakeHttpClientFactory(() => mockClient); + }), + ], + ); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(TacticalTrainingPreview), findsOneWidget); + expect( + find.widgetWithText(TacticalTrainingPreview, 'Chess tactics trainer'), + findsOneWidget, + ); + final chessboard = find + .descendant( + of: find.byType(TacticalTrainingPreview), + matching: find.byType(Chessboard), + ) + .evaluate() + .first + .widget as Chessboard; + + expect( + chessboard.fen, + equals('4k2r/Q5pp/3bp3/4n3/1r5q/8/PP2B1PP/R1B2R1K b k - 0 21'), + ); + }); + }); +}