New learn tab and revamp menus (#1860)

This commit is contained in:
Vincent Velociter
2025-06-14 12:20:40 +02:00
committed by GitHub
parent ff11e52d9c
commit fe43f9c80b
29 changed files with 1079 additions and 736 deletions
Binary file not shown.
+15 -15
View File
@@ -440,20 +440,6 @@
"crown"
]
},
{
"uid": "1274d7838088971ac536aa8fd4d4c970",
"css": "lichess",
"code": 59419,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M779.1 10C708.5 18.4 650.1 28 593.4 69.7 110.7 35.7-13.8 370.7 13.6 587.3 69.9 1004.3 638.4 1115.7 826.6 834 677.7 987.9 441.1 1009 260.1 895.7 79.2 782.3-10.1 547.8 93.7 351.3 197.5 154.8 379.9 90.7 580.6 128.1 629.2 99.7 685.1 63.7 733.7 64.4L699.8 161.6 954.9 589C946.1 702 845.7 711.2 845.7 711.2 834.2 681.8 813 652.4 748.8 590.5 684.7 528.6 399.5 386.9 433.1 266.3 393.1 405.7 639.3 549.4 713.9 619.6 788.5 689.8 822.4 740.4 829.8 754.7 829.8 754.7 1017.6 704.6 986.5 576.2L748 143.4Z",
"width": 1000
},
"search": [
"lichess"
]
},
{
"uid": "6f50db2f2fedbef2169c25553039e9f7",
"css": "tags",
@@ -565,6 +551,20 @@
"search": [
"body-cut"
]
},
{
"uid": "7777f0a33fc6c5c9f6e2c293dcb55c59",
"css": "logo_lichess",
"code": 59419,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M458.1 232.8Q455.7 245.1 455.7 250.5 455.7 306.1 618.2 422.4 780.6 538.7 813.6 604 873.4 587.8 881.1 520.4L659.9 149.7Q655.7 142.7 678.7 76.8 650.7 84.7 579.1 126.5 573.5 129.7 551.7 126.2 513 120 477.2 120 312.2 120 216.6 229.5 121.1 339 121.1 463.3 121.1 610 234.2 720.7 347.3 831.5 506.9 831.5 586.5 831.5 651.7 802.2 716.9 772.8 750.4 743.5 783.8 714.2 786.4 714.2 732.3 859.9 501.7 886.2 321.8 886.2 186.5 767.2 51.2 648.3 51.2 464.1 51.2 289.5 179.1 157.2 307 25 576.4 42 602.7 23.7 647.8 8.1T725.7-9.1 762.3 7.5Q762.3 9.6 736 122.2L942.4 496.7Q964.6 537 923.9 597.5T793.7 679.1Q784 681.7 764.7 655.5 708.2 579.2 617.7 509.3 419.1 356.1 419.1 260.2 419.1 220.5 435.8 215.3 458.1 208.3 458.1 232.8Z",
"width": 1020
},
"search": [
"logo_lichess"
]
}
]
}
}
@@ -1,16 +0,0 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/model/relation/relation_repository.dart';
import 'package:lichess_mobile/src/model/user/user.dart';
import 'package:lichess_mobile/src/network/http.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'relation_repository_providers.g.dart';
@riverpod
Future<IList<User>> following(Ref ref) {
return ref.withClientCacheFor(
(client) => RelationRepository(client).getFollowing(),
const Duration(hours: 1),
);
}
+5 -5
View File
@@ -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<StudyMember> members,
required IList<String> chapters,
required String? flair,
}) = _StudyPageData;
}) = _StudyPageItem;
factory StudyPageData.fromJson(Map<String, Object?> json) => _$StudyPageDataFromJson(json);
factory StudyPageItem.fromJson(Map<String, Object?> json) => _$StudyPageItemFromJson(json);
}
@Freezed(fromJson: true)
@@ -6,7 +6,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'study_list_paginator.g.dart';
typedef StudyList = ({IList<StudyPageData> studies, int? nextPage});
typedef StudyList = ({IList<StudyPageItem> studies, int? nextPage});
/// Gets a list of studies from the paginated API.
@riverpod
+1 -1
View File
@@ -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(),
);
},
+4 -4
View File
@@ -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);
+46 -21
View File
@@ -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<GlobalKey<NavigatorState>>((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<ScrollController>((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<NavigatorState>(debugLabel: 'home');
final puzzlesNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'puzzles');
final toolsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'tools');
final learnNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'learn');
final watchNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'watch');
final moreNavigatorKey = GlobalKey<NavigatorState>(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<PageRoute<void>> rootNavPageRouteObserver = RouteObserver<PageRoute<void>>();
@@ -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');
+3
View File
@@ -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,
+256 -339
View File
@@ -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<AccountIconButton> {
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<dynamic> 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<AccountDrawer> createState() => _AccountDrawerState();
}
class _AccountDrawerState extends ConsumerState<AccountDrawer> {
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<Locale>(
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<void> 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'));
},
),
],
),
],
),
);
}
}
+2 -1
View File
@@ -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,
+11 -3
View File
@@ -172,12 +172,17 @@ class _HomeScreenState extends ConsumerState<HomeTabScreen> {
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<HomeTabScreen> {
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 ...[
+189
View File
@@ -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<IList<StudyPageItem>>((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<int>((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<int>((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,
),
],
),
],
],
);
}
}
+186
View File
@@ -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),
),
],
);
}
}
+4 -3
View File
@@ -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,
);
+26 -18
View File
@@ -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<User>, IList<OnlineFriend>)>((ref) async {
final following = await ref.watch(followingProvider.future);
final onlines = await ref.watch(onlineFriendsProvider.future);
return (following, onlines);
});
final _followingStatusesProvider = FutureProvider.autoDispose<(IList<User>, IList<UserStatus>)>((
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<FriendScreen> createState() => _FollowingScreenState();
ConsumerState<FriendScreen> createState() => _FriendScreenState();
}
class _FollowingScreenState extends ConsumerState<FriendScreen> with TickerProviderStateMixin {
class _FriendScreenState extends ConsumerState<FriendScreen> with TickerProviderStateMixin {
late final TabController _tabController;
@override
@@ -56,7 +60,7 @@ class _FollowingScreenState extends ConsumerState<FriendScreen> 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<FriendScreen> with TickerProvi
bottom: TabBar(
controller: _tabController,
tabs: <Widget>[
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<OnlineFriend> followingOnlines) {
return followingOnlines.any((v) => v.user.id == user.id);
bool _isOnline(User user, IList<UserStatus> statuses) {
return statuses.firstWhere((status) => status.id == user.id).online ?? false;
}
}
@@ -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();
+206
View File
@@ -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<dynamic> 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<Locale>(
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);
}
}
+71 -58
View File
@@ -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<dynamic> buildRoute(BuildContext context) {
return buildScreenRoute(context, screen: const StudyListScreen());
final StudyCategory? initialCategory;
static Route<dynamic> 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<StudyListScreen> {
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<StudyListScreen> {
@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<StudyListScreen> {
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<StudyListScreen> {
: 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<StudyListScreen> {
}
}
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) {
-152
View File
@@ -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);
}
}
@@ -84,6 +84,7 @@ class GameHistoryScreen extends ConsumerWidget {
onPressed: () =>
showModalBottomSheet<GameFilterState>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (_) => _FilterGames(
filter: ref.read(gameFilterProvider(filter: gameFilter)),
+2 -1
View File
@@ -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
+31 -13
View File
@@ -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<IList<TvGameSnapshot>>((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<WatchTabScreen> {
},
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) {
+1 -1
View File
@@ -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: [
+14 -2
View File
@@ -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<Widget>? 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<Widget>? 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
@@ -91,7 +91,7 @@ class ContextMenuIconButton extends StatelessWidget {
},
arrowWidth: 0.0,
arrowHeight: 0.0,
direction: PopoverDirection.top,
direction: PopoverDirection.bottom,
backgroundColor: Colors.transparent,
);
},
+1 -1
View File
@@ -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);
});
@@ -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);
});
}