From fe43f9c80b23e06f57b19bad125f2c30cb2ce8d4 Mon Sep 17 00:00:00 2001 From: Vincent Velociter <423393+veloce@users.noreply.github.com> Date: Sat, 14 Jun 2025 12:20:40 +0200 Subject: [PATCH] New learn tab and revamp menus (#1860) --- assets/fonts/LichessIcons.ttf | Bin 16116 -> 16020 bytes fluttericon.json | 30 +- .../relation_repository_providers.dart | 16 - lib/src/model/study/study.dart | 10 +- lib/src/model/study/study_list_paginator.dart | 2 +- lib/src/model/study/study_repository.dart | 2 +- lib/src/styles/lichess_icons.dart | 8 +- lib/src/tab_scaffold.dart | 67 +- lib/src/theme.dart | 3 + lib/src/view/account/account_screen.dart | 595 ++++++++---------- lib/src/view/game/game_settings.dart | 3 +- lib/src/view/home/home_tab_screen.dart | 14 +- lib/src/view/learn/learn_tab_screen.dart | 189 ++++++ .../{tools => more}/load_position_screen.dart | 0 lib/src/view/more/more_tab_screen.dart | 186 ++++++ lib/src/view/puzzle/puzzle_tab_screen.dart | 7 +- lib/src/view/relation/friend_screen.dart | 44 +- .../background_theme_choice_screen.dart | 3 +- lib/src/view/settings/settings_screen.dart | 206 ++++++ lib/src/view/study/study_list_screen.dart | 129 ++-- lib/src/view/tools/tools_tab_screen.dart | 152 ----- lib/src/view/user/game_history_screen.dart | 1 + lib/src/view/user/online_bots_screen.dart | 3 +- lib/src/view/watch/watch_tab_screen.dart | 44 +- lib/src/widgets/list.dart | 2 +- lib/src/widgets/platform.dart | 16 +- .../widgets/platform_context_menu_button.dart | 2 +- test/app_test.dart | 2 +- test/view/account/account_screen_test.dart | 79 --- 29 files changed, 1079 insertions(+), 736 deletions(-) delete mode 100644 lib/src/model/relation/relation_repository_providers.dart create mode 100644 lib/src/view/learn/learn_tab_screen.dart rename lib/src/view/{tools => more}/load_position_screen.dart (100%) create mode 100644 lib/src/view/more/more_tab_screen.dart create mode 100644 lib/src/view/settings/settings_screen.dart delete mode 100644 lib/src/view/tools/tools_tab_screen.dart delete mode 100644 test/view/account/account_screen_test.dart diff --git a/assets/fonts/LichessIcons.ttf b/assets/fonts/LichessIcons.ttf index faa08325e3952d59f910e45d91971eca7bfbed3a..54c0c2b1938013c085ccd4e986977de2997ac4d2 100644 GIT binary patch delta 431 zcmexTJEeAlL;dzC@(~OS>?ar)4E|)KCZ_0YGEib*V0Zw;W*I;M4knfgAbSrGt7PPs zRGirHe;NY=;}anNN=|-qVo+tA22g(tke`#ASW&gLRkMX_X58!_xU?a#TXd~1jv_qIj*h;ucc#rTK5h)Q9Q4Y}rF*UIY zu}9)2;u|EmB469!g0CUFsVVKK0@x|*qpxf$arCPr69YmU1t;xZnrV(KbO_di+?(B#Zy_H4_) zkBkhAywXul%u?sXgCe0}A3>x`zGKUjTYV&lolPtT9v J{Mc525diN`bG`ro delta 529 zcmbPI`=xe*Lp_7Jw-Eya`w0dH!-R~~#1yTFH***m7#;wzSq4ymgNdaA$le3QDjB&Y z6(^SbpT@wz_yowml9QjD7;-UT6HtE)ke`#ASW&^3-a(3PMQU(T_2nGhGnGB2!6ZdP%Ox@A983�yjhvpD1AZ=n`bs)9s%*@<&Du(K->+?PAB*nM|Je<-JZ$>AYd!{6w z-&(-NVeHdfUo(eKPh3-0=|3|Mmmr@YFE1CDu(+> following(Ref ref) { - return ref.withClientCacheFor( - (client) => RelationRepository(client).getFollowing(), - const Duration(hours: 1), - ); -} diff --git a/lib/src/model/study/study.dart b/lib/src/model/study/study.dart index e6e2144b3..ee244b6c2 100644 --- a/lib/src/model/study/study.dart +++ b/lib/src/model/study/study.dart @@ -148,10 +148,10 @@ sealed class StudyChapterMeta with _$StudyChapterMeta { } @Freezed(fromJson: true) -sealed class StudyPageData with _$StudyPageData { - const StudyPageData._(); +sealed class StudyPageItem with _$StudyPageItem { + const StudyPageItem._(); - const factory StudyPageData({ + const factory StudyPageItem({ required StudyId id, required String name, required bool liked, @@ -162,9 +162,9 @@ sealed class StudyPageData with _$StudyPageData { required IList members, required IList chapters, required String? flair, - }) = _StudyPageData; + }) = _StudyPageItem; - factory StudyPageData.fromJson(Map json) => _$StudyPageDataFromJson(json); + factory StudyPageItem.fromJson(Map json) => _$StudyPageItemFromJson(json); } @Freezed(fromJson: true) diff --git a/lib/src/model/study/study_list_paginator.dart b/lib/src/model/study/study_list_paginator.dart index 29c856c69..57d0672fb 100644 --- a/lib/src/model/study/study_list_paginator.dart +++ b/lib/src/model/study/study_list_paginator.dart @@ -6,7 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'study_list_paginator.g.dart'; -typedef StudyList = ({IList studies, int? nextPage}); +typedef StudyList = ({IList studies, int? nextPage}); /// Gets a list of studies from the paginated API. @riverpod diff --git a/lib/src/model/study/study_repository.dart b/lib/src/model/study/study_repository.dart index 0d9850893..5777065a6 100644 --- a/lib/src/model/study/study_repository.dart +++ b/lib/src/model/study/study_repository.dart @@ -53,7 +53,7 @@ class StudyRepository { studies: pick( paginator, 'currentPageResults', - ).asListOrThrow((pick) => StudyPageData.fromJson(pick.asMapOrThrow())).toIList(), + ).asListOrThrow((pick) => StudyPageItem.fromJson(pick.asMapOrThrow())).toIList(), nextPage: pick(paginator, 'nextPage').asIntOrNull(), ); }, diff --git a/lib/src/styles/lichess_icons.dart b/lib/src/styles/lichess_icons.dart index 67675461d..ec6879ec1 100644 --- a/lib/src/styles/lichess_icons.dart +++ b/lib/src/styles/lichess_icons.dart @@ -1,5 +1,5 @@ /// Flutter icons LichessIcons -/// Copyright (C) 2024 by original authors @ fluttericon.com, fontello.com +/// Copyright (C) 2025 by original authors @ fluttericon.com, fontello.com /// This font was generated by FlutterIcon.com, which is derived from Fontello. /// /// To use this font, place it in your fonts/ directory and include the @@ -11,7 +11,7 @@ /// fonts: /// - asset: fonts/LichessIcons.ttf /// -/// +/// /// * Font Awesome 4, Copyright (C) 2016 by Dave Gandy /// Author: Dave Gandy /// License: SIL () @@ -27,13 +27,13 @@ /// import 'package:flutter/widgets.dart'; +// dart format off class LichessIcons { LichessIcons._(); static const _kFontFam = 'LichessIcons'; static const String? _kFontPkg = null; - // dart format off static const IconData patron = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData target = IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData blitz = IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg); @@ -61,7 +61,7 @@ class LichessIcons { static const IconData tournament_cup = IconData(0xe818, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData cogs = IconData(0xe819, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData crown = IconData(0xe81a, fontFamily: _kFontFam, fontPackage: _kFontPkg); - static const IconData lichess = IconData(0xe81b, fontFamily: _kFontFam, fontPackage: _kFontPkg); + static const IconData logo_lichess = IconData(0xe81b, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData adjust = IconData(0xe81c, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData flow_cascade = IconData(0xe81d, fontFamily: _kFontFam, fontPackage: _kFontPkg); static const IconData radio_tower_lichess = IconData(0xe81e, fontFamily: _kFontFam, fontPackage: _kFontPkg); diff --git a/lib/src/tab_scaffold.dart b/lib/src/tab_scaffold.dart index cc4965370..48719fb42 100644 --- a/lib/src/tab_scaffold.dart +++ b/lib/src/tab_scaffold.dart @@ -6,10 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/home/home_tab_screen.dart'; +import 'package:lichess_mobile/src/view/learn/learn_tab_screen.dart'; +import 'package:lichess_mobile/src/view/more/more_tab_screen.dart'; import 'package:lichess_mobile/src/view/puzzle/puzzle_tab_screen.dart'; -import 'package:lichess_mobile/src/view/tools/tools_tab_screen.dart'; import 'package:lichess_mobile/src/view/watch/watch_tab_screen.dart'; import 'package:lichess_mobile/src/widgets/background.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -17,8 +19,9 @@ import 'package:material_symbols_icons/symbols.dart'; enum BottomTab { home, puzzles, + learn, watch, - tools; + more; String label(AppLocalizations strings) { switch (this) { @@ -26,23 +29,27 @@ enum BottomTab { return strings.mobileHomeTab; case BottomTab.puzzles: return strings.mobilePuzzlesTab; - case BottomTab.tools: - return strings.mobileToolsTab; + case BottomTab.learn: + return strings.learnMenu; case BottomTab.watch: return strings.mobileWatchTab; + case BottomTab.more: + return strings.more; } } IconData get icon { switch (this) { case BottomTab.home: - return Symbols.home_rounded; + return LichessIcons.logo_lichess; case BottomTab.puzzles: return Symbols.extension_rounded; case BottomTab.watch: return Symbols.live_tv_rounded; - case BottomTab.tools: - return Symbols.handyman_rounded; + case BottomTab.learn: + return Symbols.school_rounded; + case BottomTab.more: + return Symbols.menu; } } } @@ -58,8 +65,10 @@ final currentNavigatorKeyProvider = Provider>((ref) { return puzzlesNavigatorKey; case BottomTab.watch: return watchNavigatorKey; - case BottomTab.tools: - return toolsNavigatorKey; + case BottomTab.learn: + return learnNavigatorKey; + case BottomTab.more: + return moreNavigatorKey; } }); @@ -70,22 +79,26 @@ final currentRootScrollControllerProvider = Provider((ref) { return homeScrollController; case BottomTab.puzzles: return puzzlesScrollController; - case BottomTab.tools: - return toolsScrollController; + case BottomTab.learn: + return learnScrollController; case BottomTab.watch: return watchScrollController; + case BottomTab.more: + return moreScrollController; } }); final homeNavigatorKey = GlobalKey(debugLabel: 'home'); final puzzlesNavigatorKey = GlobalKey(debugLabel: 'puzzles'); -final toolsNavigatorKey = GlobalKey(debugLabel: 'tools'); +final learnNavigatorKey = GlobalKey(debugLabel: 'learn'); final watchNavigatorKey = GlobalKey(debugLabel: 'watch'); +final moreNavigatorKey = GlobalKey(debugLabel: 'more'); final homeScrollController = ScrollController(debugLabel: 'HomeScroll'); final puzzlesScrollController = ScrollController(debugLabel: 'PuzzlesScroll'); -final toolsScrollController = ScrollController(debugLabel: 'ToolsScroll'); +final learnScrollController = ScrollController(debugLabel: 'learnScroll'); final watchScrollController = ScrollController(debugLabel: 'WatchScroll'); +final moreScrollController = ScrollController(debugLabel: 'MoreScroll'); final RouteObserver> rootNavPageRouteObserver = RouteObserver>(); @@ -97,14 +110,18 @@ final homeTabInteraction = _BottomTabInteraction(); /// interactions (pop stack, scroll to top) are done. final puzzlesTabInteraction = _BottomTabInteraction(); -/// A [ChangeNotifier] that can be used to notify when the Tools tab is tapped, and all the built interactions +/// A [ChangeNotifier] that can be used to notify when the learn tab is tapped, and all the built interactions /// (pop stack, scroll to top) are done. -final toolsTabInteraction = _BottomTabInteraction(); +final learnTabInteraction = _BottomTabInteraction(); /// A [ChangeNotifier] that can be used to notify when the Watch tab is tapped, and all the built in /// interactions (pop stack, scroll to top) are done. final watchTabInteraction = _BottomTabInteraction(); +/// A [ChangeNotifier] that can be used to notify when the More tab is tapped, and all the built in +/// interactions (pop stack, scroll to top) are done. +final moreTabInteraction = _BottomTabInteraction(); + class _BottomTabInteraction extends ChangeNotifier { void notifyItemTapped() { notifyListeners(); @@ -185,10 +202,12 @@ class MainTabScaffold extends ConsumerWidget { homeTabInteraction.notifyItemTapped(); case BottomTab.puzzles: puzzlesTabInteraction.notifyItemTapped(); - case BottomTab.tools: - toolsTabInteraction.notifyItemTapped(); + case BottomTab.learn: + learnTabInteraction.notifyItemTapped(); case BottomTab.watch: watchTabInteraction.notifyItemTapped(); + case BottomTab.more: + moreTabInteraction.notifyItemTapped(); } } } else { @@ -211,16 +230,22 @@ class MainTabScaffold extends ConsumerWidget { builder: (context) => const PuzzleTabScreen(), ); case 2: + return _MaterialTabView( + navigatorKey: learnNavigatorKey, + tab: BottomTab.learn, + builder: (context) => const LearnTabScreen(), + ); + case 3: return _MaterialTabView( navigatorKey: watchNavigatorKey, tab: BottomTab.watch, builder: (context) => const WatchTabScreen(), ); - case 3: + case 4: return _MaterialTabView( - navigatorKey: toolsNavigatorKey, - tab: BottomTab.tools, - builder: (context) => const ToolsTabScreen(), + navigatorKey: moreNavigatorKey, + tab: BottomTab.more, + builder: (context) => const MoreTabScreen(), ); default: assert(false, 'Unexpected tab'); diff --git a/lib/src/theme.dart b/lib/src/theme.dart index 976e2b5ae..093fb6852 100644 --- a/lib/src/theme.dart +++ b/lib/src/theme.dart @@ -210,6 +210,9 @@ ThemeData _makeBackgroundImageTheme({ ? lighten(baseTheme.colorScheme.surface, 0.1).withValues(alpha: 0.9) : baseTheme.colorScheme.surface.withValues(alpha: 0.9), ), + drawerTheme: DrawerThemeData( + backgroundColor: baseTheme.colorScheme.surfaceContainerLow.withValues(alpha: 0.9), + ), floatingActionButtonTheme: FloatingActionButtonThemeData( backgroundColor: baseTheme.colorScheme.secondaryFixedDim, foregroundColor: baseTheme.colorScheme.onSecondaryFixedVariant, diff --git a/lib/src/view/account/account_screen.dart b/lib/src/view/account/account_screen.dart index 34bca9b5d..9638c61c3 100644 --- a/lib/src/view/account/account_screen.dart +++ b/lib/src/view/account/account_screen.dart @@ -1,39 +1,27 @@ -import 'package:app_settings/app_settings.dart'; +import 'dart:math' show min; + +import 'package:auto_size_text/auto_size_text.dart'; import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/l10n/l10n.dart'; -import 'package:lichess_mobile/src/db/database.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/auth/auth_controller.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; -import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; -import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; -import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/network/connectivity.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; -import 'package:lichess_mobile/src/utils/l10n.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/lichess_assets.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/account/profile_screen.dart'; -import 'package:lichess_mobile/src/view/home/home_tab_screen.dart'; import 'package:lichess_mobile/src/view/settings/account_preferences_screen.dart'; -import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart'; -import 'package:lichess_mobile/src/view/settings/engine_settings_screen.dart'; -import 'package:lichess_mobile/src/view/settings/http_log_screen.dart'; -import 'package:lichess_mobile/src/view/settings/sound_settings_screen.dart'; -import 'package:lichess_mobile/src/view/settings/theme_settings_screen.dart'; +import 'package:lichess_mobile/src/view/settings/settings_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; -import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; -import 'package:lichess_mobile/src/widgets/misc.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; -import 'package:lichess_mobile/src/widgets/settings.dart'; -import 'package:lichess_mobile/src/widgets/user_full_name.dart'; -import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; class AccountIconButton extends ConsumerStatefulWidget { @@ -72,342 +60,175 @@ class _AccountIconButtonState extends ConsumerState { child: value.flair == null || errorLoadingFlair ? Text(value.initials) : null, ), onPressed: () { - Navigator.of(context, rootNavigator: true).push(AccountScreen.buildRoute(context)); + Scaffold.of(context).openDrawer(); }, ), _ => IconButton( icon: const Icon(Icons.account_circle_outlined, size: 30), tooltip: context.l10n.signIn, onPressed: () { - Navigator.of(context, rootNavigator: true).push(AccountScreen.buildRoute(context)); + Scaffold.of(context).openDrawer(); }, ), }; } } -class AccountScreen extends ConsumerWidget { - const AccountScreen({super.key}); - - static Route buildRoute(BuildContext context) { - return buildScreenRoute(context, screen: const AccountScreen()); - } +class AccountDrawer extends ConsumerStatefulWidget { + const AccountDrawer({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final generalPrefs = ref.watch(generalPreferencesProvider); + ConsumerState createState() => _AccountDrawerState(); +} + +class _AccountDrawerState extends ConsumerState { + bool errorLoadingFlair = false; + + @override + Widget build(BuildContext context) { + final isOnline = ref.watch( + connectivityChangesProvider.select((s) => s.value?.isOnline ?? false), + ); final authController = ref.watch(authControllerProvider); final account = ref.watch(accountProvider); final userSession = ref.watch(authSessionProvider); final LightUser? user = account.valueOrNull?.lightUser ?? userSession?.user; - final packageInfo = ref.read(preloadedDataProvider).requireValue.packageInfo; - final dbSize = ref.watch(getDbSizeInBytesProvider); - final Widget? donateButton = userSession == null || userSession.user.isPatron != true - ? ListTile( - leading: Icon( - LichessIcons.patron, - semanticLabel: context.l10n.patronLichessPatron, - color: context.lichessColors.brag, - ), - title: Text( - context.l10n.patronDonate, - style: TextStyle(color: context.lichessColors.brag), - ), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - launchUrl(Uri.parse('https://lichess.org/patron')); - }, - ) - : null; - - return PlatformScaffold( - appBar: PlatformAppBar( - title: user == null ? const SizedBox.shrink() : UserFullNameWidget(user: user), - actions: const [SocketPingRating()], - ), - body: ListView( + return Drawer( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero), + width: min(350.0, MediaQuery.sizeOf(context).width * 0.8), + child: ListView( children: [ - ListSection( - hasLeading: true, - children: [ - if (userSession != null) ...[ - ListTile( - leading: const Icon(Icons.person_outline), - title: Text(context.l10n.profile), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - ref.invalidate(accountProvider); - Navigator.of(context).push(ProfileScreen.buildRoute(context)); - }, - ), - ListTile( - leading: const Icon(Icons.manage_accounts_outlined), - title: Text(context.l10n.mobileAccountPreferences), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - Navigator.of(context).push(AccountPreferencesScreen.buildRoute(context)); - }, - ), - if (authController.isLoading) - const ListTile( - leading: Icon(Icons.logout_outlined), - title: Center(child: ButtonLoadingIndicator()), - ) - else - ListTile( - leading: const Icon(Icons.logout_outlined), - title: Text(context.l10n.logOut), - onTap: () { - _showSignOutConfirmDialog(context, ref); - }, - ), - ] else ...[ - if (authController.isLoading) - const ListTile( - leading: Icon(Icons.login_outlined), - title: Center(child: ButtonLoadingIndicator()), - ) - else - ListTile( - leading: const Icon(Icons.login_outlined), - title: Text(context.l10n.signIn), - onTap: () { - ref.read(authControllerProvider.notifier).signIn(); - }, - ), - ], - if (Theme.of(context).platform == TargetPlatform.android && donateButton != null) - donateButton, - ], - ), - ListSection( - hasLeading: true, - children: [ - SettingsListTile( - icon: const Icon(Icons.music_note_outlined), - settingsLabel: Text(context.l10n.sound), - settingsValue: - '${soundThemeL10n(context, generalPrefs.soundTheme)} (${volumeLabel(generalPrefs.masterVolume)})', - onTap: () { - Navigator.of(context).push(SoundSettingsScreen.buildRoute(context)); - }, + if (user != null) ...[ + ListTile( + leading: switch (account) { + AsyncData(:final value) => + value == null + ? const Icon(Icons.account_circle_outlined, size: 30) + : CircleAvatar( + radius: 20, + foregroundImage: value.flair != null + ? CachedNetworkImageProvider(lichessFlairSrc(value.flair!)) + : null, + onForegroundImageError: value.flair != null + ? (error, _) { + setState(() { + errorLoadingFlair = true; + }); + } + : null, + backgroundColor: value.flair == null || errorLoadingFlair + ? null + : ColorScheme.of(context).surfaceContainer, + child: value.flair == null || errorLoadingFlair + ? Text(value.initials) + : null, + ), + _ => const Icon(Icons.account_circle_outlined, size: 30), + }, + trailing: const SocketPingRating(), + title: AutoSizeText( + user.name, + style: Styles.callout, + maxLines: 1, + minFontSize: 14, + maxFontSize: 18, + overflow: TextOverflow.ellipsis, ), - Opacity( - opacity: generalPrefs.isForcedDarkMode ? 0.5 : 1.0, - child: SettingsListTile( - icon: const Icon(Icons.brightness_medium_outlined), - settingsLabel: Text(context.l10n.background), - settingsValue: generalPrefs.isForcedDarkMode - ? BackgroundThemeMode.dark.title(context.l10n) - : generalPrefs.themeMode.title(context.l10n), - onTap: generalPrefs.isForcedDarkMode - ? null - : () { - showChoicePicker( - context, - choices: BackgroundThemeMode.values, - selectedItem: generalPrefs.themeMode, - labelBuilder: (t) => Text(t.title(context.l10n)), - onSelectedItemChanged: (BackgroundThemeMode? value) => ref - .read(generalPreferencesProvider.notifier) - .setBackgroundThemeMode(value ?? BackgroundThemeMode.system), - ); - }, - ), - ), - ListTile( - leading: const Icon(Icons.palette_outlined), - title: Text(context.l10n.mobileTheme), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - Navigator.of(context).push(ThemeSettingsScreen.buildRoute(context)); - }, - ), - ListTile( - leading: const Icon(Icons.app_registration), - title: Text(context.l10n.mobileSettingsHomeWidgets), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - Navigator.of( - context, - ).push(HomeTabScreen.buildRoute(context, editModeEnabled: true)); - }, - ), - ListTile( - leading: const Icon(Symbols.chess_pawn), - title: Text(context.l10n.mobileBoardSettings, overflow: TextOverflow.ellipsis), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - Navigator.of(context).push(BoardSettingsScreen.buildRoute(context)); - }, - ), - ListTile( - leading: const Icon(Icons.memory_outlined), - title: const Text('Chess engine'), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: () { - Navigator.of(context).push(EngineSettingsScreen.buildRoute(context)); - }, - ), - SettingsListTile( - icon: const Icon(Icons.language_outlined), - settingsLabel: Text(context.l10n.language), - settingsValue: localeToLocalizedName( - generalPrefs.locale ?? Localizations.localeOf(context), - ), - onTap: () { - if (Theme.of(context).platform == TargetPlatform.android) { - showChoicePicker( - context, - choices: AppLocalizations.supportedLocales, - selectedItem: generalPrefs.locale ?? Localizations.localeOf(context), - labelBuilder: (t) => Text(localeToLocalizedName(t)), - onSelectedItemChanged: (Locale? locale) => - ref.read(generalPreferencesProvider.notifier).setLocale(locale), - ); - } else { - AppSettings.openAppSettings(); - } - }, - ), - ], - ), - ListSection( - hasLeading: true, - children: [ - ListTile( - leading: const Icon(Icons.info_outlined), - title: Text(context.l10n.aboutX('Lichess')), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/about')); - }, - ), - ListTile( - leading: const Icon(Icons.feedback_outlined), - title: Text(context.l10n.mobileFeedbackButton), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/contact')); - }, - ), - ListTile( - leading: const Icon(Icons.article_outlined), - title: Text(context.l10n.termsOfService), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/terms-of-service')); - }, - ), - ListTile( - leading: const Icon(Icons.privacy_tip_outlined), - title: Text(context.l10n.privacyPolicy), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/privacy')); - }, - ), - ], - ), - ListSection( - hasLeading: true, - children: [ - ListTile( - leading: const Icon(Icons.code_outlined), - title: Text(context.l10n.sourceCode), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/source')); - }, - ), - ListTile( - leading: const Icon(Icons.bug_report_outlined), - title: Text(context.l10n.contribute), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/help/contribute')); - }, - ), - ListTile( - leading: const Icon(Icons.star_border_outlined), - title: Text(context.l10n.thankYou), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(Uri.parse('https://lichess.org/thanks')); - }, - ), - ], - ), - ListSection( - hasLeading: true, - children: [ - ListTile( - leading: const Icon(Icons.storage_outlined), - title: const Text('Local database size'), - trailing: dbSize.hasValue ? Text(_getSizeString(dbSize.value)) : null, - ), - ListTile( - leading: const Icon(Icons.http), - title: const Text('HTTP logs'), - onTap: () => Navigator.push(context, HttpLogScreen.buildRoute(context)), - ), - ], - ), - if (userSession != null) - ListSection( - hasLeading: true, - children: [ - if (Theme.of(context).platform == TargetPlatform.iOS) - ListTile( - leading: Icon(Icons.dangerous_outlined, color: context.lichessColors.error), - title: Text( - 'Delete your account', - style: TextStyle(color: context.lichessColors.error), - ), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(lichessUri('/account/delete')); - }, - ) - else - ListTile( - leading: Icon(Icons.dangerous_outlined, color: context.lichessColors.error), - title: Text( - context.l10n.settingsCloseAccount, - style: TextStyle(color: context.lichessColors.error), - ), - trailing: const _OpenInNewIcon(), - onTap: () { - launchUrl(lichessUri('/account/close')); - }, - ), - ], + enabled: isOnline, + onTap: () { + ref.invalidate(accountProvider); + Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: true).push(ProfileScreen.buildRoute(context)); + }, ), - Padding( - padding: Styles.bodySectionPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - LichessMessage(style: TextTheme.of(context).bodyMedium), - const SizedBox(height: 10), - Text('v${packageInfo.version}', style: TextTheme.of(context).bodySmall), - ], + const PlatformDivider(indent: 0), + ], + if (user != null) ...[ + ListTile( + leading: const Icon(Icons.person_outlined), + title: Text(context.l10n.profile), + enabled: isOnline, + onTap: () { + ref.invalidate(accountProvider); + Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: true).push(ProfileScreen.buildRoute(context)); + }, ), + ListTile( + leading: const Icon(Icons.manage_accounts_outlined), + title: Text(context.l10n.mobileAccountPreferences), + enabled: isOnline, + onTap: () { + Navigator.of(context).pop(); + Navigator.of( + context, + rootNavigator: true, + ).push(AccountPreferencesScreen.buildRoute(context)); + }, + ), + if (authController.isLoading) + const ListTile( + leading: Icon(Icons.logout_outlined), + enabled: false, + title: Center(child: ButtonLoadingIndicator()), + ) + else + ListTile( + leading: const Icon(Icons.logout_outlined), + title: Text(context.l10n.logOut), + enabled: isOnline, + onTap: () { + _showSignOutConfirmDialog(context, ref); + }, + ), + ] else ...[ + if (authController.isLoading) + const ListTile( + leading: Icon(Icons.login_outlined), + trailing: SocketPingRating(), + enabled: false, + title: Center(child: ButtonLoadingIndicator()), + ) + else + ListTile( + leading: const Icon(Icons.login_outlined), + trailing: const SocketPingRating(), + title: Text(context.l10n.signIn), + enabled: isOnline, + onTap: () { + ref.read(authControllerProvider.notifier).signIn(); + }, + ), + ], + if (Theme.of(context).platform == TargetPlatform.android) + ListTile( + leading: Icon(LichessIcons.patron, semanticLabel: context.l10n.patronLichessPatron), + title: Text(context.l10n.patronDonate), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const Icon(Icons.chevron_right) + : null, + enabled: isOnline, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/patron')); + }, + ), + const PlatformDivider(indent: 0), + ListTile( + leading: const Icon(Icons.settings_outlined), + title: Text(context.l10n.settingsSettings), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: true).push(SettingsScreen.buildRoute(context)); + }, + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text(context.l10n.about), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context, rootNavigator: true).push(AboutScreen.buildRoute(context)); + }, ), ], ), @@ -456,17 +277,113 @@ class AccountScreen extends ConsumerWidget { ); } } - - String _getSizeString(int? bytes) => '${_bytesToMB(bytes ?? 0).toStringAsFixed(2)}MB'; - - double _bytesToMB(int bytes) => bytes * 0.000001; } -class _OpenInNewIcon extends StatelessWidget { - const _OpenInNewIcon(); +class AboutScreen extends StatelessWidget { + const AboutScreen({super.key}); + + static Route buildRoute(BuildContext context) { + return buildScreenRoute(context, screen: const AboutScreen()); + } @override Widget build(BuildContext context) { - return const Icon(Icons.open_in_new, size: 18); + return Scaffold( + appBar: AppBar(title: Text(context.l10n.about)), + body: ListView( + children: [ + ListSection( + hasLeading: true, + children: [ + ListTile( + leading: const Icon(Icons.info_outlined), + title: Text(context.l10n.aboutX('Lichess')), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/about')); + }, + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: Text(context.l10n.mobileFeedbackButton), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/contact')); + }, + ), + ListTile( + leading: const Icon(Icons.article_outlined), + title: Text(context.l10n.termsOfService), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/terms-of-service')); + }, + ), + ListTile( + leading: const Icon(Icons.privacy_tip_outlined), + title: Text(context.l10n.privacyPolicy), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/privacy')); + }, + ), + ], + ), + ListSection( + hasLeading: true, + children: [ + ListTile( + leading: const Icon(Symbols.database), + title: Text(context.l10n.database), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://database.lichess.org')); + }, + ), + ListTile( + leading: const Icon(Icons.code_outlined), + title: Text(context.l10n.sourceCode), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/source')); + }, + ), + ListTile( + leading: const Icon(Icons.bug_report_outlined), + title: Text(context.l10n.contribute), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/help/contribute')); + }, + ), + ListTile( + leading: const Icon(Icons.star_border_outlined), + title: Text(context.l10n.thankYou), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/thanks')); + }, + ), + ], + ), + ], + ), + ); } } diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 138296247..8872b394e 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -1,3 +1,4 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; @@ -75,7 +76,7 @@ class GameSettings extends ConsumerWidget { ), ListTile( title: Text(context.l10n.mobileBoardSettings), - trailing: const Icon(Icons.chevron_right), + trailing: const CupertinoListTileChevron(), onTap: () { Navigator.of( context, diff --git a/lib/src/view/home/home_tab_screen.dart b/lib/src/view/home/home_tab_screen.dart index d8fdd3984..ef0c9ba6b 100644 --- a/lib/src/view/home/home_tab_screen.dart +++ b/lib/src/view/home/home_tab_screen.dart @@ -172,12 +172,17 @@ class _HomeScreenState extends ConsumerState { isEditingWidgets: widget.editModeEnabled, child: PlatformScaffold( appBar: widget.editModeEnabled - ? PlatformAppBar(title: Text(context.l10n.mobileSettingsHomeWidgets)) + ? PlatformAppBar( + title: Text(context.l10n.mobileSettingsHomeWidgets), + leading: const BackButton(), + automaticallyImplyLeading: false, + ) : PlatformAppBar( title: const Text('lichess.org'), leading: const AccountIconButton(), actions: const [_ChallengeScreenButton(), _PlayerScreenButton()], ), + drawer: const AccountDrawer(), body: widget.editModeEnabled ? content : RefreshIndicator.adaptive( @@ -308,16 +313,19 @@ class _HomeScreenState extends ConsumerState { return [ if (isTablet) Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Expanded(child: _TabletCreateAGameSection()), Expanded( child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ ...welcomeWidgets, - FeaturedTournamentsWidget(featured: featuredTournaments), + const SizedBox(height: 32.0), + const _TabletCreateAGameSection(), ], ), ), + Expanded(child: FeaturedTournamentsWidget(featured: featuredTournaments)), ], ) else ...[ diff --git a/lib/src/view/learn/learn_tab_screen.dart b/lib/src/view/learn/learn_tab_screen.dart new file mode 100644 index 000000000..31aa6860d --- /dev/null +++ b/lib/src/view/learn/learn_tab_screen.dart @@ -0,0 +1,189 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; +import 'package:lichess_mobile/src/model/study/study.dart'; +import 'package:lichess_mobile/src/model/study/study_filter.dart'; +import 'package:lichess_mobile/src/model/study/study_repository.dart'; +import 'package:lichess_mobile/src/network/connectivity.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/tab_scaffold.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/view/account/account_screen.dart'; +import 'package:lichess_mobile/src/view/coordinate_training/coordinate_training_screen.dart'; +import 'package:lichess_mobile/src/view/study/study_list_screen.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +final _hotStudiesProvider = FutureProvider.autoDispose>((Ref ref) { + return ref.withClientCacheFor( + (client) => StudyRepository(ref, client) + .getStudies(category: StudyCategory.all, order: StudyListOrder.hot) + .then((value) => value.studies), + const Duration(hours: 6), + ); +}); + +final _myStudiesLengthProvider = FutureProvider.autoDispose((Ref ref) { + final session = ref.watch(authSessionProvider); + if (session == null) return Future.value(0); + + return ref.withClientCacheFor( + (client) => StudyRepository(ref, client) + .getStudies(category: StudyCategory.mine, order: StudyListOrder.updated) + .then((value) => value.studies.length), + const Duration(hours: 6), + ); +}); + +final _myFavoriteStudiesLengthProvider = FutureProvider.autoDispose((Ref ref) { + final session = ref.watch(authSessionProvider); + if (session == null) return Future.value(0); + + return ref.withClientCacheFor( + (client) => StudyRepository(ref, client) + .getStudies(category: StudyCategory.likes, order: StudyListOrder.updated) + .then((value) => value.studies.length), + const Duration(hours: 6), + ); +}); + +class LearnTabScreen extends ConsumerWidget { + const LearnTabScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, _) { + if (!didPop) { + ref.read(currentBottomTabProvider.notifier).state = BottomTab.home; + } + }, + child: PlatformScaffold( + appBar: PlatformAppBar( + leading: const AccountIconButton(), + title: Text(context.l10n.learnMenu), + ), + drawer: const AccountDrawer(), + body: const _Body(), + ), + ); + } +} + +class _ToolsButton extends StatelessWidget { + const _ToolsButton({required this.leading, required this.title, required this.onTap}); + + final Widget leading; + + final Widget title; + + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Opacity( + opacity: onTap == null ? 0.5 : 1.0, + child: ListTile( + leading: IconTheme.merge( + data: IconThemeData(color: ColorScheme.of(context).primary), + child: leading, + ), + title: title, + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const Icon(Symbols.chevron_right) + : null, + onTap: onTap, + ), + ); + } +} + +class _Body extends ConsumerWidget { + const _Body(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOnline = ref.watch(connectivityChangesProvider).valueOrNull?.isOnline ?? false; + final session = ref.watch(authSessionProvider); + final haveIStudies = + session != null && (ref.watch(_myStudiesLengthProvider).valueOrNull ?? 0) > 0; + final haveIFavoriteStudies = + session != null && (ref.watch(_myFavoriteStudiesLengthProvider).valueOrNull ?? 0) > 0; + + return ListView( + controller: learnScrollController, + children: [ + ListSection( + hasLeading: true, + children: [ + _ToolsButton( + leading: const Icon(Symbols.where_to_vote), + title: Text(context.l10n.coordinatesCoordinateTraining, style: Styles.callout), + onTap: () => Navigator.of( + context, + rootNavigator: true, + ).push(CoordinateTrainingScreen.buildRoute(context)), + ), + ], + ), + if (isOnline) ...[ + ListSection( + header: Text(context.l10n.studyMenu), + onHeaderTap: () => Navigator.of( + context, + rootNavigator: true, + ).push(StudyListScreen.buildRoute(context)), + hasLeading: true, + children: [ + ...(switch (ref.watch(_hotStudiesProvider)) { + AsyncData(:final value) => + value + .take(5) + .map((study) => StudyListItem(study: study, titleMaxLines: 1)) + .toList(growable: false), + _ => [], + }), + ], + ), + if (haveIStudies || haveIFavoriteStudies) + ListSection( + hasLeading: true, + margin: Styles.horizontalBodyPadding.add(Styles.sectionBottomPadding), + children: [ + if (haveIStudies) + _ToolsButton( + leading: const Icon(Symbols.local_library), + title: Text(context.l10n.studyMyStudies), + onTap: isOnline + ? () => Navigator.of(context).push( + StudyListScreen.buildRoute( + context, + initialCategory: StudyCategory.mine, + ), + ) + : null, + ), + if (haveIFavoriteStudies) + _ToolsButton( + leading: const Icon(Symbols.favorite), + title: Text(context.l10n.studyMyFavoriteStudies), + onTap: isOnline + ? () => Navigator.of(context).push( + StudyListScreen.buildRoute( + context, + initialCategory: StudyCategory.likes, + ), + ) + : null, + ), + ], + ), + ], + ], + ); + } +} diff --git a/lib/src/view/tools/load_position_screen.dart b/lib/src/view/more/load_position_screen.dart similarity index 100% rename from lib/src/view/tools/load_position_screen.dart rename to lib/src/view/more/load_position_screen.dart diff --git a/lib/src/view/more/more_tab_screen.dart b/lib/src/view/more/more_tab_screen.dart new file mode 100644 index 000000000..1da1e7aff --- /dev/null +++ b/lib/src/view/more/more_tab_screen.dart @@ -0,0 +1,186 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/network/connectivity.dart'; +import 'package:lichess_mobile/src/styles/lichess_icons.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/tab_scaffold.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/view/account/account_screen.dart'; +import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; +import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart'; +import 'package:lichess_mobile/src/view/clock/clock_tool_screen.dart'; +import 'package:lichess_mobile/src/view/more/load_position_screen.dart'; +import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_screen.dart'; +import 'package:lichess_mobile/src/view/relation/friend_screen.dart'; +import 'package:lichess_mobile/src/view/user/player_screen.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:lichess_mobile/src/widgets/misc.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class MoreTabScreen extends ConsumerWidget { + const MoreTabScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return PopScope( + canPop: false, + onPopInvokedWithResult: (bool didPop, _) { + if (!didPop) { + ref.read(currentBottomTabProvider.notifier).state = BottomTab.home; + } + }, + child: PlatformScaffold( + appBar: PlatformAppBar( + title: const Text('lichess.org'), + leading: const AccountIconButton(), + ), + drawer: const AccountDrawer(), + body: const _Body(), + ), + ); + } +} + +class _Body extends ConsumerWidget { + const _Body(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isOnline = ref.watch(connectivityChangesProvider).valueOrNull?.isOnline ?? false; + final session = ref.watch(authSessionProvider); + + return ListView( + controller: moreScrollController, + children: [ + ListSection( + header: SettingsSectionTitle(context.l10n.tools), + hasLeading: true, + children: [ + ListTile( + leading: const Icon(Icons.upload_file_outlined), + title: Text(context.l10n.loadPosition), + onTap: () => Navigator.of(context).push(LoadPositionScreen.buildRoute(context)), + ), + ListTile( + leading: const Icon(Icons.biotech_outlined), + title: Text(context.l10n.analysis), + onTap: () => Navigator.of(context, rootNavigator: true).push( + AnalysisScreen.buildRoute( + context, + const AnalysisOptions( + orientation: Side.white, + standalone: ( + pgn: '', + isComputerAnalysisAllowed: true, + variant: Variant.standard, + ), + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.explore_outlined), + title: Text(context.l10n.openingExplorer), + enabled: isOnline, + onTap: () => Navigator.of(context, rootNavigator: true).push( + OpeningExplorerScreen.buildRoute( + context, + const AnalysisOptions( + orientation: Side.white, + standalone: ( + pgn: '', + isComputerAnalysisAllowed: false, + variant: Variant.standard, + ), + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(context.l10n.boardEditor), + onTap: () => Navigator.of( + context, + rootNavigator: true, + ).push(BoardEditorScreen.buildRoute(context)), + ), + ListTile( + leading: const Icon(Icons.alarm_outlined), + title: Text(context.l10n.clock), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of( + context, + rootNavigator: true, + ).push(ClockToolScreen.buildRoute(context)); + }, + ), + ], + ), + ListSection( + header: SettingsSectionTitle(context.l10n.community), + hasLeading: true, + children: [ + ListTile( + leading: const Icon(Icons.groups_3_outlined), + title: Text(context.l10n.players), + enabled: isOnline, + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of(context, rootNavigator: true).push(PlayerScreen.buildRoute(context)); + }, + ), + if (session != null) + ListTile( + leading: const Icon(Icons.people_outline), + title: Text(context.l10n.friends), + enabled: isOnline, + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of(context, rootNavigator: true).push(FriendScreen.buildRoute(context)); + }, + ), + ], + ), + if (Theme.of(context).platform == TargetPlatform.android) + ListSection( + hasLeading: true, + children: [ + ListTile( + leading: Icon( + LichessIcons.patron, + semanticLabel: context.l10n.patronLichessPatron, + color: context.lichessColors.brag, + ), + title: Text(context.l10n.patronDonate), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const Icon(Icons.chevron_right) + : null, + enabled: isOnline, + onTap: () { + launchUrl(Uri.parse('https://lichess.org/patron')); + }, + ), + ], + ), + Padding( + padding: Styles.bodySectionPadding, + child: LichessMessage(style: TextTheme.of(context).bodyMedium), + ), + ], + ); + } +} diff --git a/lib/src/view/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index f5aab9e3e..b155f6b28 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -1,5 +1,6 @@ import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -30,7 +31,6 @@ import 'package:lichess_mobile/src/view/puzzle/storm_screen.dart'; import 'package:lichess_mobile/src/view/puzzle/streak_screen.dart'; import 'package:lichess_mobile/src/widgets/board_preview.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; @@ -123,7 +123,8 @@ class _MaterialTabBodyState extends ConsumerState<_MaterialTabBody> { title: Text(context.l10n.puzzles), actions: const [_DashboardButton(), _HistoryButton()], ), - bottomSheet: const OfflineBanner(), + + drawer: const AccountDrawer(), body: isTablet ? Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -233,7 +234,7 @@ class _PuzzleMenuListTile extends StatelessWidget { title: Text(title, style: Styles.mainListTileTitle), subtitle: Text(subtitle, maxLines: 3), trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) + ? const CupertinoListTileChevron() : null, onTap: onTap, ); diff --git a/lib/src/view/relation/friend_screen.dart b/lib/src/view/relation/friend_screen.dart index c7b79cd81..7e4663044 100644 --- a/lib/src/view/relation/friend_screen.dart +++ b/lib/src/view/relation/friend_screen.dart @@ -1,12 +1,13 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:lichess_mobile/src/model/relation/online_friends.dart'; import 'package:lichess_mobile/src/model/relation/relation_repository.dart'; -import 'package:lichess_mobile/src/model/relation/relation_repository_providers.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; +import 'package:lichess_mobile/src/model/user/user_repository.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -21,12 +22,15 @@ import 'package:lichess_mobile/src/widgets/shimmer.dart'; import 'package:lichess_mobile/src/widgets/user_full_name.dart'; import 'package:lichess_mobile/src/widgets/user_list_tile.dart'; -final _getFollowingAndOnlinesProvider = - FutureProvider.autoDispose<(IList, IList)>((ref) async { - final following = await ref.watch(followingProvider.future); - final onlines = await ref.watch(onlineFriendsProvider.future); - return (following, onlines); - }); +final _followingStatusesProvider = FutureProvider.autoDispose<(IList, IList)>(( + ref, +) async { + final following = await ref.withClient((client) => RelationRepository(client).getFollowing()); + final statuses = await ref.withClient( + (client) => UserRepository(client).getUsersStatuses(following.map((user) => user.id).toISet()), + ); + return (following, statuses); +}); class FriendScreen extends ConsumerStatefulWidget { const FriendScreen({super.key}); @@ -36,10 +40,10 @@ class FriendScreen extends ConsumerStatefulWidget { } @override - ConsumerState createState() => _FollowingScreenState(); + ConsumerState createState() => _FriendScreenState(); } -class _FollowingScreenState extends ConsumerState with TickerProviderStateMixin { +class _FriendScreenState extends ConsumerState with TickerProviderStateMixin { late final TabController _tabController; @override @@ -56,7 +60,7 @@ class _FollowingScreenState extends ConsumerState with TickerProvi @override Widget build(BuildContext context) { - final followingAndOnlines = ref.watch(_getFollowingAndOnlinesProvider); + final followingAndOnlines = ref.watch(_followingStatusesProvider); switch (followingAndOnlines) { case AsyncData(:final value): @@ -66,7 +70,11 @@ class _FollowingScreenState extends ConsumerState with TickerProvi bottom: TabBar( controller: _tabController, tabs: [ - Tab(text: context.l10n.nbFriendsOnline(value.$2.length)), + Tab( + text: context.l10n.nbFriendsOnline( + value.$2.where((status) => status.online ?? false).length, + ), + ), Tab(text: context.l10n.nbFollowing(value.$1.length)), ], ), @@ -94,15 +102,15 @@ class OnlineFriendsWidget extends ConsumerWidget { data: (data) { return ListSection( header: Text(context.l10n.nbFriendsOnline(data.length)), - onHeaderTap: data.isEmpty ? null : () => _handleTap(context, data), + onHeaderTap: data.isEmpty || data.length <= 10 ? null : () => _handleTap(context, data), children: [ if (data.isEmpty) ListTile( title: Text(context.l10n.friends), - trailing: const Icon(Icons.chevron_right), + trailing: const CupertinoListTileChevron(), onTap: () => _handleTap(context, data), ), - for (final friend in data.take(5)) _OnlineFriendListTile(onlineFriend: friend), + for (final friend in data.take(10)) _OnlineFriendListTile(onlineFriend: friend), ], ); }, @@ -190,7 +198,7 @@ class _Following extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final followingAndOnlines = ref.watch(_getFollowingAndOnlinesProvider); + final followingAndOnlines = ref.watch(_followingStatusesProvider); switch (followingAndOnlines) { case AsyncData(:final value): @@ -251,13 +259,13 @@ class _Following extends ConsumerWidget { ); case AsyncError(:final error, :final stackTrace): debugPrint('SEVERE: [FriendScreen] could not load following users; $error\n$stackTrace'); - return FullScreenRetryRequest(onRetry: () => ref.invalidate(followingProvider)); + return FullScreenRetryRequest(onRetry: () => ref.invalidate(_followingStatusesProvider)); case _: return const CenterLoadingIndicator(); } } - bool _isOnline(User user, IList followingOnlines) { - return followingOnlines.any((v) => v.user.id == user.id); + bool _isOnline(User user, IList statuses) { + return statuses.firstWhere((status) => status.id == user.id).online ?? false; } } diff --git a/lib/src/view/settings/background_theme_choice_screen.dart b/lib/src/view/settings/background_theme_choice_screen.dart index 5dd6b75bc..1e18a9068 100644 --- a/lib/src/view/settings/background_theme_choice_screen.dart +++ b/lib/src/view/settings/background_theme_choice_screen.dart @@ -4,6 +4,7 @@ import 'dart:ui' as ui; import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart' show Side, kInitialFEN; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; @@ -60,7 +61,7 @@ class _Body extends ConsumerWidget { leading: const Icon(Icons.image_outlined), title: Text(context.l10n.mobileSettingsPickAnImage), trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) + ? const CupertinoListTileChevron() : null, onTap: () async { final ImagePicker picker = ImagePicker(); diff --git a/lib/src/view/settings/settings_screen.dart b/lib/src/view/settings/settings_screen.dart new file mode 100644 index 000000000..be0e97de0 --- /dev/null +++ b/lib/src/view/settings/settings_screen.dart @@ -0,0 +1,206 @@ +import 'package:app_settings/app_settings.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/l10n/l10n.dart'; +import 'package:lichess_mobile/src/db/database.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; +import 'package:lichess_mobile/src/model/common/preloaded_data.dart'; +import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/l10n.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/home/home_tab_screen.dart'; +import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart'; +import 'package:lichess_mobile/src/view/settings/engine_settings_screen.dart'; +import 'package:lichess_mobile/src/view/settings/http_log_screen.dart'; +import 'package:lichess_mobile/src/view/settings/sound_settings_screen.dart'; +import 'package:lichess_mobile/src/view/settings/theme_settings_screen.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + static Route buildRoute(BuildContext context) { + return buildScreenRoute(context, screen: const SettingsScreen()); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final generalPrefs = ref.watch(generalPreferencesProvider); + final packageInfo = ref.read(preloadedDataProvider).requireValue.packageInfo; + final userSession = ref.watch(authSessionProvider); + final dbSize = ref.watch(getDbSizeInBytesProvider); + + return PlatformScaffold( + appBar: PlatformAppBar(title: Text(context.l10n.settingsSettings)), + body: ListView( + children: [ + ListSection( + hasLeading: true, + children: [ + SettingsListTile( + icon: const Icon(Icons.music_note_outlined), + settingsLabel: Text(context.l10n.sound), + settingsValue: + '${soundThemeL10n(context, generalPrefs.soundTheme)} (${volumeLabel(generalPrefs.masterVolume)})', + onTap: () { + Navigator.of(context).push(SoundSettingsScreen.buildRoute(context)); + }, + ), + Opacity( + opacity: generalPrefs.isForcedDarkMode ? 0.5 : 1.0, + child: SettingsListTile( + icon: const Icon(Icons.brightness_medium_outlined), + settingsLabel: Text(context.l10n.background), + settingsValue: generalPrefs.isForcedDarkMode + ? BackgroundThemeMode.dark.title(context.l10n) + : generalPrefs.themeMode.title(context.l10n), + onTap: generalPrefs.isForcedDarkMode + ? null + : () { + showChoicePicker( + context, + choices: BackgroundThemeMode.values, + selectedItem: generalPrefs.themeMode, + labelBuilder: (t) => Text(t.title(context.l10n)), + onSelectedItemChanged: (BackgroundThemeMode? value) => ref + .read(generalPreferencesProvider.notifier) + .setBackgroundThemeMode(value ?? BackgroundThemeMode.system), + ); + }, + ), + ), + ListTile( + leading: const Icon(Icons.palette_outlined), + title: Text(context.l10n.mobileTheme), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of(context).push(ThemeSettingsScreen.buildRoute(context)); + }, + ), + ListTile( + leading: const Icon(Icons.app_registration), + title: Text(context.l10n.mobileSettingsHomeWidgets), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of( + context, + ).push(HomeTabScreen.buildRoute(context, editModeEnabled: true)); + }, + ), + ListTile( + leading: const Icon(Symbols.chess_pawn), + title: Text(context.l10n.mobileBoardSettings, overflow: TextOverflow.ellipsis), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of(context).push(BoardSettingsScreen.buildRoute(context)); + }, + ), + ListTile( + leading: const Icon(Icons.memory_outlined), + title: const Text('Chess engine'), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of(context).push(EngineSettingsScreen.buildRoute(context)); + }, + ), + SettingsListTile( + icon: const Icon(Icons.language_outlined), + settingsLabel: Text(context.l10n.language), + settingsValue: localeToLocalizedName( + generalPrefs.locale ?? Localizations.localeOf(context), + ), + onTap: () { + if (Theme.of(context).platform == TargetPlatform.android) { + showChoicePicker( + context, + choices: AppLocalizations.supportedLocales, + selectedItem: generalPrefs.locale ?? Localizations.localeOf(context), + labelBuilder: (t) => Text(localeToLocalizedName(t)), + onSelectedItemChanged: (Locale? locale) => + ref.read(generalPreferencesProvider.notifier).setLocale(locale), + ); + } else { + AppSettings.openAppSettings(); + } + }, + ), + ], + ), + ListSection( + hasLeading: true, + children: [ + ListTile( + leading: const Icon(Icons.storage_outlined), + title: const Text('Local database size'), + trailing: dbSize.hasValue ? Text(_getSizeString(dbSize.value)) : null, + ), + ListTile( + leading: const Icon(Icons.http), + title: const Text('HTTP logs'), + onTap: () => Navigator.push(context, HttpLogScreen.buildRoute(context)), + ), + ], + ), + if (userSession != null) + ListSection( + hasLeading: true, + children: [ + if (Theme.of(context).platform == TargetPlatform.iOS) + ListTile( + leading: const Icon(Icons.dangerous_outlined), + title: const Text('Delete your account'), + trailing: const _OpenInNewIcon(), + onTap: () { + launchUrl(lichessUri('/account/delete')); + }, + ) + else + ListTile( + leading: const Icon(Icons.dangerous_outlined), + title: Text(context.l10n.settingsCloseAccount), + trailing: const _OpenInNewIcon(), + onTap: () { + launchUrl(lichessUri('/account/close')); + }, + ), + ], + ), + Padding( + padding: Styles.bodySectionPadding, + child: Text('v${packageInfo.version}', style: TextTheme.of(context).bodySmall), + ), + ], + ), + ); + } + + String _getSizeString(int? bytes) => '${_bytesToMB(bytes ?? 0).toStringAsFixed(2)}MB'; + + double _bytesToMB(int bytes) => bytes * 0.000001; +} + +class _OpenInNewIcon extends StatelessWidget { + const _OpenInNewIcon(); + + @override + Widget build(BuildContext context) { + return const Icon(Icons.open_in_new, size: 18); + } +} diff --git a/lib/src/view/study/study_list_screen.dart b/lib/src/view/study/study_list_screen.dart index 3dc5b37d4..2efd4a0dd 100644 --- a/lib/src/view/study/study_list_screen.dart +++ b/lib/src/view/study/study_list_screen.dart @@ -22,10 +22,12 @@ import 'package:lichess_mobile/src/widgets/user_full_name.dart'; /// A screen that displays a paginated list of studies class StudyListScreen extends ConsumerStatefulWidget { - const StudyListScreen({super.key}); + const StudyListScreen({this.initialCategory, super.key}); - static Route buildRoute(BuildContext context) { - return buildScreenRoute(context, screen: const StudyListScreen()); + final StudyCategory? initialCategory; + + static Route buildRoute(BuildContext context, {StudyCategory? initialCategory}) { + return buildScreenRoute(context, screen: StudyListScreen(initialCategory: initialCategory)); } @override @@ -33,11 +35,13 @@ class StudyListScreen extends ConsumerStatefulWidget { } class _StudyListScreenState extends ConsumerState { - StudyCategory category = StudyCategory.all; + late StudyCategory category; StudyListOrder order = StudyListOrder.hot; String? search; + final currentCategoryKey = GlobalKey(debugLabel: 'studyCurrentCategoryKey'); + final _searchController = TextEditingController(); final _scrollController = ScrollController(keepScrollOffset: true); @@ -50,7 +54,13 @@ class _StudyListScreenState extends ConsumerState { @override void initState() { super.initState(); + category = widget.initialCategory ?? StudyCategory.all; _scrollController.addListener(_scrollListener); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (currentCategoryKey.currentContext != null) { + Scrollable.ensureVisible(currentCategoryKey.currentContext!, alignment: 0.5); + } + }); } @override @@ -159,46 +169,49 @@ class _StudyListScreenState extends ConsumerState { height: 50.0, child: Padding( padding: const EdgeInsets.only(bottom: 6.0), - child: ListView.separated( + child: SingleChildScrollView( scrollDirection: Axis.horizontal, - itemCount: StudyCategory.values.length, padding: const EdgeInsets.symmetric(horizontal: 16.0), - separatorBuilder: (context, index) => const SizedBox(width: 8.0), - itemBuilder: (context, index) { - final cat = StudyCategory.values[index]; - return ChoiceChip( - showCheckmark: false, - label: Text(cat.l10n(context.l10n)), - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)), - selected: category == cat, - onSelected: (selected) { - if (selected) { - setState(() { - category = cat; - if ([ - StudyCategory.mine, - StudyCategory.public, - StudyCategory.private, - ].contains(cat)) { - search = null; - _searchController.value = TextEditingValue( - text: 'owner:${sessionUser.id} ', - ); - } else if (cat == StudyCategory.member) { - search = null; - _searchController.value = TextEditingValue( - text: 'member:${sessionUser.id} ', - ); - } else { - search = null; - _searchController.clear(); - } - }); - } - }, - ); - }, + child: Row( + spacing: 8.0, + children: StudyCategory.values.map((cat) { + return ChoiceChip( + key: cat == category ? currentCategoryKey : null, + showCheckmark: false, + label: Text(cat.l10n(context.l10n)), + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.0), + ), + selected: category == cat, + onSelected: (selected) { + if (selected) { + setState(() { + category = cat; + if ([ + StudyCategory.mine, + StudyCategory.public, + StudyCategory.private, + ].contains(cat)) { + search = null; + _searchController.value = TextEditingValue( + text: 'owner:${sessionUser.id} ', + ); + } else if (cat == StudyCategory.member) { + search = null; + _searchController.value = TextEditingValue( + text: 'member:${sessionUser.id} ', + ); + } else { + search = null; + _searchController.clear(); + } + }); + } + }, + ); + }).toList(), + ), ), ), ), @@ -215,8 +228,9 @@ class _StudyListScreenState extends ConsumerState { : Theme.of(context).platform == TargetPlatform.iOS ? const PlatformDivider(height: 1, cupertinoHasLeading: true) : const PlatformDivider(height: 1, color: Colors.transparent), - itemBuilder: (context, index) => - index == 0 ? searchBar : _StudyListItem(study: studies.studies[index - 1]), + itemBuilder: (context, index) => index == 0 + ? searchBar + : StudyListItem(study: studies.studies[index - 1], flairSize: 30.0), ), AsyncError() => FullScreenRetryRequest(onRetry: () => ref.invalidate(paginatorProvider)), _ => Column( @@ -230,19 +244,18 @@ class _StudyListScreenState extends ConsumerState { } } -class _StudyListItem extends StatelessWidget { - const _StudyListItem({required this.study}); +class StudyListItem extends StatelessWidget { + const StudyListItem({required this.study, this.flairSize, this.titleMaxLines, super.key}); - final StudyPageData study; + final StudyPageItem study; + final double? flairSize; + final int? titleMaxLines; @override Widget build(BuildContext context) { return ListTile( - leading: _StudyFlair(flair: study.flair, size: 30), - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [Text(study.name, overflow: TextOverflow.ellipsis, maxLines: 2)], - ), + leading: StudyFlair(flair: study.flair, size: flairSize ?? 24.0), + title: Text(study.name, overflow: TextOverflow.ellipsis, maxLines: titleMaxLines ?? 2), subtitle: _StudySubtitle(study: study), onTap: () => Navigator.of( context, @@ -265,7 +278,7 @@ class _StudyListItem extends StatelessWidget { class _ContextMenu extends ConsumerWidget { const _ContextMenu({required this.study}); - final StudyPageData study; + final StudyPageItem study; @override Widget build(BuildContext context, WidgetRef ref) { @@ -290,7 +303,7 @@ class _ContextMenu extends ConsumerWidget { class _StudyChapters extends StatelessWidget { const _StudyChapters({required this.study}); - final StudyPageData study; + final StudyPageItem study; @override Widget build(BuildContext context) { @@ -312,7 +325,7 @@ class _StudyChapters extends StatelessWidget { class _StudyMembers extends StatelessWidget { const _StudyMembers({required this.study}); - final StudyPageData study; + final StudyPageItem study; @override Widget build(BuildContext context) { @@ -338,8 +351,8 @@ class _StudyMembers extends StatelessWidget { } } -class _StudyFlair extends StatelessWidget { - const _StudyFlair({required this.flair, required this.size}); +class StudyFlair extends StatelessWidget { + const StudyFlair({required this.flair, required this.size}); final String? flair; @@ -363,7 +376,7 @@ class _StudyFlair extends StatelessWidget { class _StudySubtitle extends StatelessWidget { const _StudySubtitle({required this.study}); - final StudyPageData study; + final StudyPageItem study; @override Widget build(BuildContext context) { diff --git a/lib/src/view/tools/tools_tab_screen.dart b/lib/src/view/tools/tools_tab_screen.dart deleted file mode 100644 index 426bd1eea..000000000 --- a/lib/src/view/tools/tools_tab_screen.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:dartchess/dartchess.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; -import 'package:lichess_mobile/src/model/common/chess.dart'; -import 'package:lichess_mobile/src/network/connectivity.dart'; -import 'package:lichess_mobile/src/styles/lichess_icons.dart'; -import 'package:lichess_mobile/src/styles/styles.dart'; -import 'package:lichess_mobile/src/tab_scaffold.dart'; -import 'package:lichess_mobile/src/utils/l10n_context.dart'; -import 'package:lichess_mobile/src/view/account/account_screen.dart'; -import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; -import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart'; -import 'package:lichess_mobile/src/view/clock/clock_tool_screen.dart'; -import 'package:lichess_mobile/src/view/coordinate_training/coordinate_training_screen.dart'; -import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_screen.dart'; -import 'package:lichess_mobile/src/view/study/study_list_screen.dart'; -import 'package:lichess_mobile/src/view/tools/load_position_screen.dart'; -import 'package:lichess_mobile/src/widgets/list.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; - -class ToolsTabScreen extends ConsumerWidget { - const ToolsTabScreen({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return PopScope( - canPop: false, - onPopInvokedWithResult: (bool didPop, _) { - if (!didPop) { - ref.read(currentBottomTabProvider.notifier).state = BottomTab.home; - } - }, - child: PlatformScaffold( - appBar: PlatformAppBar(leading: const AccountIconButton(), title: Text(context.l10n.tools)), - body: const _Body(), - ), - ); - } -} - -class _ToolsButton extends StatelessWidget { - const _ToolsButton({required this.icon, required this.title, required this.onTap}); - - final IconData icon; - - final String title; - - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - return Opacity( - opacity: onTap == null ? 0.5 : 1.0, - child: ListTile( - leading: Icon(icon, color: ColorScheme.of(context).primary), - title: Text(title, style: Styles.callout), - trailing: Theme.of(context).platform == TargetPlatform.iOS - ? const Icon(Icons.chevron_right) - : null, - onTap: onTap, - ), - ); - } -} - -class _Body extends ConsumerWidget { - const _Body(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isOnline = ref.watch(connectivityChangesProvider).valueOrNull?.isOnline ?? false; - - final content = [ - ListSection( - hasLeading: true, - children: [ - _ToolsButton( - icon: Icons.upload_file, - title: context.l10n.loadPosition, - onTap: () => Navigator.of(context).push(LoadPositionScreen.buildRoute(context)), - ), - _ToolsButton( - icon: Icons.biotech, - title: context.l10n.analysis, - onTap: () => Navigator.of(context, rootNavigator: true).push( - AnalysisScreen.buildRoute( - context, - const AnalysisOptions( - orientation: Side.white, - standalone: (pgn: '', isComputerAnalysisAllowed: true, variant: Variant.standard), - ), - ), - ), - ), - _ToolsButton( - icon: Icons.explore_outlined, - title: context.l10n.openingExplorer, - onTap: isOnline - ? () => Navigator.of(context, rootNavigator: true).push( - OpeningExplorerScreen.buildRoute( - context, - const AnalysisOptions( - orientation: Side.white, - standalone: ( - pgn: '', - isComputerAnalysisAllowed: false, - variant: Variant.standard, - ), - ), - ), - ) - : null, - ), - _ToolsButton( - icon: LichessIcons.study, - title: context.l10n.studyMenu, - onTap: isOnline - ? () => Navigator.of(context).push(StudyListScreen.buildRoute(context)) - : null, - ), - _ToolsButton( - icon: Icons.edit_outlined, - title: context.l10n.boardEditor, - onTap: () => Navigator.of( - context, - rootNavigator: true, - ).push(BoardEditorScreen.buildRoute(context)), - ), - _ToolsButton( - icon: Icons.where_to_vote_outlined, - title: context.l10n.coordinatesCoordinateTraining, - onTap: () => Navigator.of( - context, - rootNavigator: true, - ).push(CoordinateTrainingScreen.buildRoute(context)), - ), - _ToolsButton( - icon: Icons.alarm, - title: context.l10n.clock, - onTap: () => Navigator.of( - context, - rootNavigator: true, - ).push(ClockToolScreen.buildRoute(context)), - ), - ], - ), - ]; - - return ListView(controller: toolsScrollController, children: content); - } -} diff --git a/lib/src/view/user/game_history_screen.dart b/lib/src/view/user/game_history_screen.dart index c876ea0c0..9aa700571 100644 --- a/lib/src/view/user/game_history_screen.dart +++ b/lib/src/view/user/game_history_screen.dart @@ -84,6 +84,7 @@ class GameHistoryScreen extends ConsumerWidget { onPressed: () => showModalBottomSheet( context: context, + useRootNavigator: true, isScrollControlled: true, builder: (_) => _FilterGames( filter: ref.read(gameFilterProvider(filter: gameFilter)), diff --git a/lib/src/view/user/online_bots_screen.dart b/lib/src/view/user/online_bots_screen.dart index 1644dd9d6..9e30dd31a 100644 --- a/lib/src/view/user/online_bots_screen.dart +++ b/lib/src/view/user/online_bots_screen.dart @@ -1,4 +1,5 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; @@ -103,7 +104,7 @@ class _Body extends ConsumerWidget { const Icon(Icons.verified_outlined), const SizedBox(width: 5), ], - const Icon(Icons.chevron_right), + const CupertinoListTileChevron(), ], ) : bot.verified == true diff --git a/lib/src/view/watch/watch_tab_screen.dart b/lib/src/view/watch/watch_tab_screen.dart index 510b64435..35a789c1c 100644 --- a/lib/src/view/watch/watch_tab_screen.dart +++ b/lib/src/view/watch/watch_tab_screen.dart @@ -16,6 +16,7 @@ import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/tab_scaffold.dart'; import 'package:lichess_mobile/src/utils/image.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/view/account/account_screen.dart'; import 'package:lichess_mobile/src/view/broadcast/broadcast_carousel.dart'; import 'package:lichess_mobile/src/view/broadcast/broadcast_list_screen.dart'; @@ -29,18 +30,10 @@ import 'package:lichess_mobile/src/widgets/user_full_name.dart'; const kThumbnailImageSize = 40.0; -const _featuredChannelsSet = ISetConst({ - TvChannel.best, - TvChannel.bullet, - TvChannel.blitz, - TvChannel.rapid, - TvChannel.classical, -}); - final featuredChannelsProvider = FutureProvider.autoDispose>((ref) { return ref.withClient((client) async { final channels = await TvRepository(client).channels(); - return _featuredChannelsSet + return TvChannel.values .map((channel) => MapEntry(channel, channels[channel])) .where((entry) => entry.value != null) .map( @@ -93,6 +86,7 @@ class _WatchScreenState extends ConsumerState { }, child: PlatformScaffold( appBar: PlatformAppBar(leading: const AccountIconButton(), title: Text(context.l10n.watch)), + drawer: const AccountDrawer(), body: isOnline ? OrientationBuilder( builder: (context, orientation) { @@ -162,11 +156,22 @@ class _BodyState extends ConsumerState<_Body> { final broadcastList = ref.watch(broadcastsPaginatorProvider); final featuredChannels = ref.watch(featuredChannelsProvider); final streamers = ref.watch(liveStreamersProvider); + final isTablet = isTabletOrLarger(context); final content = [ if (_worker != null) _BroadcastWidget(broadcastList, _worker!), - _WatchTvWidget(featuredChannels), - _StreamerWidget(streamers), + if (isTablet) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _WatchTvWidget(featuredChannels)), + Expanded(child: _StreamerWidget(streamers)), + ], + ) + else ...[ + _WatchTvWidget(featuredChannels), + _StreamerWidget(streamers), + ], ]; return ListView(controller: watchScrollController, children: content); @@ -227,8 +232,17 @@ class _WatchTvWidget extends ConsumerWidget { const _WatchTvWidget(this.featuredChannels); + static const _handsetFeaturedChannelsSet = ISetConst({ + TvChannel.best, + TvChannel.bullet, + TvChannel.blitz, + TvChannel.rapid, + TvChannel.classical, + }); + @override Widget build(BuildContext context, WidgetRef ref) { + final isTablet = isTabletOrLarger(context); return featuredChannels.when( data: (data) { if (data.isEmpty) { @@ -244,6 +258,10 @@ class _WatchTvWidget extends ConsumerWidget { } }), children: data + .where((snapshot) { + if (isTablet) return true; + return _handsetFeaturedChannelsSet.contains(snapshot.channel); + }) .map((snapshot) { return ListTile( leading: Icon(snapshot.channel.icon), @@ -294,10 +312,10 @@ class _StreamerWidget extends ConsumerWidget { const _StreamerWidget(this.streamers); - static const int numberOfItems = 5; - @override Widget build(BuildContext context, WidgetRef ref) { + final numberOfItems = isTabletOrLarger(context) ? 10 : 5; + return streamers.when( data: (data) { if (data.isEmpty) { diff --git a/lib/src/widgets/list.dart b/lib/src/widgets/list.dart index c79a25952..757b191e0 100644 --- a/lib/src/widgets/list.dart +++ b/lib/src/widgets/list.dart @@ -200,7 +200,7 @@ class ListSectionHeader extends StatelessWidget { return OpacityButton( onPressed: onTap, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.only(bottom: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ diff --git a/lib/src/widgets/platform.dart b/lib/src/widgets/platform.dart index ee28d29c2..23b0f0ffc 100644 --- a/lib/src/widgets/platform.dart +++ b/lib/src/widgets/platform.dart @@ -66,6 +66,7 @@ class PlatformScaffold extends StatelessWidget { this.appBar, this.floatingActionButton, this.persistentFooterButtons, + this.drawer, this.bottomSheet, this.bottomNavigationBar, this.extendBody, @@ -75,6 +76,7 @@ class PlatformScaffold extends StatelessWidget { final Widget? body; final Widget? floatingActionButton; final List? persistentFooterButtons; + final Widget? drawer; final Widget? bottomSheet; final Widget? bottomNavigationBar; final bool? extendBody; @@ -91,6 +93,7 @@ class PlatformScaffold extends StatelessWidget { extendBody: extendBody ?? hasExtendedBodyParentScaffold, appBar: appBar, body: body, + drawer: drawer, persistentFooterButtons: persistentFooterButtons, floatingActionButton: floatingActionButton, bottomSheet: bottomSheet, @@ -108,14 +111,22 @@ class PlatformScaffold extends StatelessWidget { } class PlatformAppBar extends StatelessWidget implements PreferredSizeWidget { - PlatformAppBar({super.key, this.leading, this.title, this.actions, this.bottom, this.centerTitle}) - : preferredSize = _PreferredAppBarSize(kToolbarHeight, bottom?.preferredSize.height); + PlatformAppBar({ + super.key, + this.leading, + this.title, + this.actions, + this.bottom, + this.centerTitle, + this.automaticallyImplyLeading = true, + }) : preferredSize = _PreferredAppBarSize(kToolbarHeight, bottom?.preferredSize.height); final Widget? leading; final Widget? title; final List? actions; final PreferredSizeWidget? bottom; final bool? centerTitle; + final bool automaticallyImplyLeading; @override final Size preferredSize; @@ -129,6 +140,7 @@ class PlatformAppBar extends StatelessWidget implements PreferredSizeWidget { actions: actions, bottom: bottom, centerTitle: centerTitle, + automaticallyImplyLeading: automaticallyImplyLeading, ); return isIOS diff --git a/lib/src/widgets/platform_context_menu_button.dart b/lib/src/widgets/platform_context_menu_button.dart index 01fed01f2..47c6d3e2a 100644 --- a/lib/src/widgets/platform_context_menu_button.dart +++ b/lib/src/widgets/platform_context_menu_button.dart @@ -91,7 +91,7 @@ class ContextMenuIconButton extends StatelessWidget { }, arrowWidth: 0.0, arrowHeight: 0.0, - direction: PopoverDirection.top, + direction: PopoverDirection.bottom, backgroundColor: Colors.transparent, ); }, diff --git a/test/app_test.dart b/test/app_test.dart index 2fc701aaa..6edceae28 100644 --- a/test/app_test.dart +++ b/test/app_test.dart @@ -96,7 +96,7 @@ void main() { expect(find.text('Home'), findsOneWidget); expect(find.text('Puzzles'), findsOneWidget); - expect(find.text('Tools'), findsOneWidget); + expect(find.text('Learn'), findsOneWidget); expect(find.text('Watch'), findsOneWidget); }); diff --git a/test/view/account/account_screen_test.dart b/test/view/account/account_screen_test.dart deleted file mode 100644 index e9fa47e5f..000000000 --- a/test/view/account/account_screen_test.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:http/testing.dart'; -import 'package:lichess_mobile/src/db/database.dart'; -import 'package:lichess_mobile/src/network/http.dart'; -import 'package:lichess_mobile/src/view/account/account_screen.dart'; - -import '../../model/auth/fake_session_storage.dart'; -import '../../test_helpers.dart'; -import '../../test_provider_scope.dart'; - -final client = MockClient((request) { - if (request.method == 'DELETE' && request.url.path == '/api/token') { - return mockResponse('ok', 200); - } - return mockResponse('', 404); -}); - -void main() { - group('AccountScreen', () { - testWidgets('meets accessibility guidelines', (WidgetTester tester) async { - final SemanticsHandle handle = tester.ensureSemantics(); - - final app = await makeTestProviderScopeApp(tester, home: const AccountScreen()); - - await tester.pumpWidget(app); - - await meetsTapTargetGuideline(tester); - - await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); - - await expectLater(tester, meetsGuideline(textContrastGuideline)); - handle.dispose(); - }, variant: kPlatformVariant); - - testWidgets("don't show signOut if no session", (WidgetTester tester) async { - final app = await makeTestProviderScopeApp(tester, home: const AccountScreen()); - - await tester.pumpWidget(app); - - expect(find.text('Sign out'), findsNothing); - }, variant: kPlatformVariant); - - testWidgets('signout', (WidgetTester tester) async { - final app = await makeTestProviderScopeApp( - tester, - home: const AccountScreen(), - userSession: fakeSession, - overrides: [ - lichessClientProvider.overrideWith((ref) => LichessClient(client, ref)), - getDbSizeInBytesProvider.overrideWith((_) => 1000), - ], - ); - - await tester.pumpWidget(app); - - expect(find.text('Sign out'), findsOneWidget); - - await tester.tap(find.widgetWithText(ListTile, 'Sign out'), warnIfMissed: false); - await tester.pumpAndSettle(); - - // confirm - if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) { - await tester.tap(find.widgetWithText(CupertinoActionSheetAction, 'Sign out')); - } else { - await tester.tap(find.text('OK')); - } - await tester.pump(); - - expect(find.byType(CircularProgressIndicator), findsOneWidget); - // wait for sign out future - await tester.pump(const Duration(seconds: 1)); - - expect(find.text('Sign out'), findsNothing); - }, variant: kPlatformVariant); - }); -}