From bb4de06df8e408d3665d3ecbef2f563bcdbff06c Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 29 May 2025 18:26:11 +0200 Subject: [PATCH 1/3] Improve cupertino context menu icon button style --- lib/src/view/analysis/analysis_screen.dart | 6 +- .../view/broadcast/broadcast_carousel.dart | 14 +- lib/src/view/game/archived_game_screen.dart | 3 +- lib/src/view/game/game_common_widgets.dart | 13 +- lib/src/view/game/game_screen.dart | 5 +- .../view/settings/toggle_sound_button.dart | 2 +- lib/src/view/user/game_history_screen.dart | 7 +- .../widgets/platform_context_menu_button.dart | 246 +++++++++++++++++- 8 files changed, 259 insertions(+), 37 deletions(-) diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index 2a5777a3ac..ee95408e41 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -114,11 +114,7 @@ class _AnalysisScreenState extends ConsumerState<_AnalysisScreen> ); case AsyncError(:final error, :final stackTrace): _logger.severe('Cannot load analysis: $error', stackTrace); - return FullScreenRetryRequest( - onRetry: () { - ref.invalidate(ctrlProvider); - }, - ); + return FullScreenRetryRequest(onRetry: () => ref.invalidate(ctrlProvider)); case _: return Scaffold( resizeToAvoidBottomInset: false, diff --git a/lib/src/view/broadcast/broadcast_carousel.dart b/lib/src/view/broadcast/broadcast_carousel.dart index f7c0708c45..56075e252a 100644 --- a/lib/src/view/broadcast/broadcast_carousel.dart +++ b/lib/src/view/broadcast/broadcast_carousel.dart @@ -18,7 +18,6 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/view/broadcast/broadcast_round_screen.dart'; import 'package:lichess_mobile/src/view/broadcast/broadcast_share_menu.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/platform_context_menu_button.dart'; const kDefaultBroadcastImage = AssetImage('assets/images/broadcast_image.png'); @@ -420,12 +419,13 @@ class _BroadcastCardContent extends StatelessWidget { ), const Spacer(), ], - ContextMenuButton( + ContextMenuIconButton( + consumeOutsideTap: true, semanticsLabel: context.l10n.menu, icon: Icon(Icons.more_horiz, color: titleColor?.withValues(alpha: 0.5)), actions: [ ContextMenuAction( - icon: const Icon(Icons.info), + icon: Icons.info, label: context.l10n.broadcastOverview, onPressed: () { Navigator.of(context, rootNavigator: true).push( @@ -438,7 +438,7 @@ class _BroadcastCardContent extends StatelessWidget { }, ), ContextMenuAction( - icon: const Icon(LichessIcons.chess_board), + icon: LichessIcons.chess_board, label: context.l10n.broadcastBoards, onPressed: () { Navigator.of(context, rootNavigator: true).push( @@ -451,7 +451,7 @@ class _BroadcastCardContent extends StatelessWidget { }, ), ContextMenuAction( - icon: const Icon(Icons.people), + icon: Icons.people, label: context.l10n.players, onPressed: () { Navigator.of(context, rootNavigator: true).push( @@ -464,7 +464,9 @@ class _BroadcastCardContent extends StatelessWidget { }, ), ContextMenuAction( - icon: const PlatformShareIcon(), + icon: Theme.of(context).platform == TargetPlatform.iOS + ? Icons.ios_share_outlined + : Icons.share_outlined, label: context.l10n.studyShareAndExport, onPressed: () { showBroadcastShareMenu(context, broadcast); diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart index 3fbba16421..e05cbdb0aa 100644 --- a/lib/src/view/game/archived_game_screen.dart +++ b/lib/src/view/game/archived_game_screen.dart @@ -202,7 +202,8 @@ class _BodyState extends ConsumerState<_Body> { if (widget.gameData == null && widget.error == null) const PlatformAppBarLoadingIndicator(), if (widget.gameData != null) - ContextMenuButton( + ContextMenuIconButton( + consumeOutsideTap: true, icon: const Icon(Icons.more_horiz), semanticsLabel: context.l10n.menu, actions: [ diff --git a/lib/src/view/game/game_common_widgets.dart b/lib/src/view/game/game_common_widgets.dart index 24ce5eb007..403142f91c 100644 --- a/lib/src/view/game/game_common_widgets.dart +++ b/lib/src/view/game/game_common_widgets.dart @@ -12,7 +12,6 @@ import 'package:lichess_mobile/src/utils/share.dart'; import 'package:lichess_mobile/src/view/game/archived_game_screen.dart'; import 'package:lichess_mobile/src/view/game/game_screen.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/platform_context_menu_button.dart'; import 'package:share_plus/share_plus.dart'; @@ -95,7 +94,7 @@ class _GameBookmarkContextMenuActionState extends ConsumerState makeFinishedGameShareContextMenuActions( }) { return [ ContextMenuAction( - icon: const PlatformShareIcon(), + icon: Theme.of(context).platform == TargetPlatform.iOS + ? Icons.ios_share_outlined + : Icons.share_outlined, label: context.l10n.mobileShareGameURL, onPressed: () { launchShareDialog(context, ShareParams(uri: lichessUri('/$gameId/${orientation.name}'))); }, ), ContextMenuAction( - icon: const Icon(Icons.gif_outlined), + icon: Icons.gif_outlined, label: context.l10n.gameAsGIF, onPressed: () async { try { @@ -173,7 +174,7 @@ List makeFinishedGameShareContextMenuActions( }, ), ContextMenuAction( - icon: const Icon(Icons.text_snippet_outlined), + icon: Icons.text_snippet_outlined, label: 'PGN: ${context.l10n.downloadAnnotated}', onPressed: () async { try { @@ -189,7 +190,7 @@ List makeFinishedGameShareContextMenuActions( }, ), ContextMenuAction( - icon: const Icon(Icons.description_outlined), + icon: Icons.description_outlined, label: 'PGN: ${context.l10n.downloadRaw}', onPressed: () async { try { diff --git a/lib/src/view/game/game_screen.dart b/lib/src/view/game/game_screen.dart index 43fbe11c83..7e49bd2f7e 100644 --- a/lib/src/view/game/game_screen.dart +++ b/lib/src/view/game/game_screen.dart @@ -277,13 +277,12 @@ class _GameMenu extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final isBookmarkedAsync = ref.watch(isGameBookmarkedProvider(gameId)); - return ContextMenuButton( - consumeOutsideTap: false, + return ContextMenuIconButton( icon: const Icon(Icons.more_horiz), semanticsLabel: context.l10n.menu, actions: [ ContextMenuAction( - icon: const Icon(Icons.settings), + icon: Icons.settings, label: context.l10n.settingsSettings, onPressed: () => showModalBottomSheet( context: context, diff --git a/lib/src/view/settings/toggle_sound_button.dart b/lib/src/view/settings/toggle_sound_button.dart index 2b10520536..4b74ec83c4 100644 --- a/lib/src/view/settings/toggle_sound_button.dart +++ b/lib/src/view/settings/toggle_sound_button.dart @@ -36,7 +36,7 @@ class ToggleSoundContextMenuAction extends ConsumerWidget { return ContextMenuAction( dismissOnPress: false, - icon: Icon(isSoundEnabled ? Icons.volume_up : Icons.volume_off), + icon: isSoundEnabled ? Icons.volume_up : Icons.volume_off, label: context.l10n.sound, onPressed: () { ref.read(generalPreferencesProvider.notifier).toggleSoundEnabled(); diff --git a/lib/src/view/user/game_history_screen.dart b/lib/src/view/user/game_history_screen.dart index 64e68c0575..25e0ca4387 100644 --- a/lib/src/view/user/game_history_screen.dart +++ b/lib/src/view/user/game_history_screen.dart @@ -96,12 +96,13 @@ class GameHistoryScreen extends ConsumerWidget { }), ); - final displayModeButton = ContextMenuButton( + final displayModeButton = ContextMenuIconButton( + consumeOutsideTap: true, icon: const Icon(Icons.more_horiz), semanticsLabel: context.l10n.menu, actions: [ ContextMenuAction( - icon: const Icon(Icons.ballot_outlined), + icon: Icons.ballot_outlined, label: 'Detailed view', onPressed: () { ref @@ -110,7 +111,7 @@ class GameHistoryScreen extends ConsumerWidget { }, ), ContextMenuAction( - icon: const Icon(Icons.list_outlined), + icon: Icons.list_outlined, label: 'Compact view', onPressed: () { ref diff --git a/lib/src/widgets/platform_context_menu_button.dart b/lib/src/widgets/platform_context_menu_button.dart index da33503994..7dd8b11756 100644 --- a/lib/src/widgets/platform_context_menu_button.dart +++ b/lib/src/widgets/platform_context_menu_button.dart @@ -1,25 +1,100 @@ +import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:popover/popover.dart'; -/// A platform agnostic context menu button. +const Color _kBorderColor = CupertinoDynamicColor.withBrightness( + color: Color(0xFFA9A9AF), + darkColor: Color(0xFF57585A), +); + +/// A platform agnostic context menu icon button. /// /// Typically used in the [AppBar] to show a context menu. -class ContextMenuButton extends StatelessWidget { - const ContextMenuButton({ +class ContextMenuIconButton extends StatelessWidget { + const ContextMenuIconButton({ required this.icon, required this.semanticsLabel, required this.actions, - this.consumeOutsideTap = true, + this.consumeOutsideTap = false, super.key, }); final Widget icon; final String semanticsLabel; final List actions; + + /// Whether to consume taps outside the menu to close it (only on Android). final bool consumeOutsideTap; @override Widget build(BuildContext context) { + if (Theme.of(context).platform == TargetPlatform.iOS) { + return SemanticIconButton( + onPressed: () { + showPopover( + context: context, + barrierColor: Colors.transparent, + shadow: const [], + bodyBuilder: (context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.8, + maxWidth: MediaQuery.sizeOf(context).width * 0.6, + ), + child: IntrinsicHeight( + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(13.0)), + child: ColoredBox( + color: ColorScheme.of(context).surfaceContainer, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: CupertinoScrollbar( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + actions.first, + for (final Widget action in actions.skip(1)) + DecoratedBox( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: CupertinoDynamicColor.resolve( + _kBorderColor, + context, + ), + width: 0.4, + ), + ), + ), + position: DecorationPosition.foreground, + child: action, + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ); + }, + arrowWidth: 0.0, + arrowHeight: 0.0, + direction: PopoverDirection.top, + backgroundColor: Colors.transparent, + ); + }, + semanticsLabel: semanticsLabel, + icon: icon, + ); + } + return MenuAnchor( crossAxisUnconstrained: false, consumeOutsideTap: consumeOutsideTap, @@ -48,13 +123,13 @@ class ContextMenuButton extends StatelessWidget { class ContextMenuAction extends StatelessWidget { const ContextMenuAction({ - required this.icon, + this.icon, required this.label, required this.onPressed, this.dismissOnPress = true, }); - final Widget icon; + final IconData? icon; final String label; final VoidCallback? onPressed; @@ -65,12 +140,159 @@ class ContextMenuAction extends StatelessWidget { @override Widget build(BuildContext context) { - return MenuItemButton( - closeOnActivate: dismissOnPress, - leadingIcon: icon, - semanticsLabel: label, - onPressed: onPressed, - child: Text(label), + return Theme.of(context).platform == TargetPlatform.iOS + ? _CupertinoContextMenuAction( + trailingIcon: icon, + onPressed: () { + if (dismissOnPress) { + Navigator.of(context).pop(); + } + onPressed?.call(); + }, + // trailingIcon: icon, + child: Text(label), + ) + : MenuItemButton( + closeOnActivate: dismissOnPress, + leadingIcon: Icon(icon), + semanticsLabel: label, + onPressed: onPressed, + child: Text(label), + ); + } +} + +// -- +// Copied from: https://github.com/flutter/flutter/blob/stable/packages/flutter/lib/src/cupertino/context_menu_action.dart +// and adapted to use with lichess colors. + +/// A button in a _ContextMenuSheet. +/// +/// A typical use case is to pass a [Text] as the [child] here, but be sure to +/// use [TextOverflow.ellipsis] for the [Text.overflow] field if the text may be +/// long, as without it the text will wrap to the next line. +class _CupertinoContextMenuAction extends StatefulWidget { + /// Construct a _CupertinoContextMenuAction. + const _CupertinoContextMenuAction({ + // ignore: unused_element_parameter + super.key, + required this.child, + // ignore: unused_element_parameter + this.isDefaultAction = false, + // ignore: unused_element_parameter + this.isDestructiveAction = false, + this.onPressed, + this.trailingIcon, + }); + + /// The widget that will be placed inside the action. + final Widget child; + + /// Indicates whether this action should receive the style of an emphasized, + /// default action. + final bool isDefaultAction; + + /// Indicates whether this action should receive the style of a destructive + /// action. + final bool isDestructiveAction; + + /// Called when the action is pressed. + final VoidCallback? onPressed; + + /// An optional icon to display to the right of the child. + /// + /// Will be colored in the same way as the [TextStyle] used for [child] (for + /// example, if using [isDestructiveAction]). + final IconData? trailingIcon; + + @override + State<_CupertinoContextMenuAction> createState() => _CupertinoContextMenuActionState(); +} + +class _CupertinoContextMenuActionState extends State<_CupertinoContextMenuAction> { + static const double _kButtonHeight = 43; + static const TextStyle _kActionSheetActionStyle = TextStyle( + fontFamily: 'CupertinoSystemText', + inherit: false, + fontSize: 16.0, + fontWeight: FontWeight.w400, + color: CupertinoColors.black, + textBaseline: TextBaseline.alphabetic, + ); + + final GlobalKey _globalKey = GlobalKey(); + bool _isPressed = false; + + void onTapDown(TapDownDetails details) { + setState(() { + _isPressed = true; + }); + } + + void onTapUp(TapUpDetails details) { + setState(() { + _isPressed = false; + }); + } + + void onTapCancel() { + setState(() { + _isPressed = false; + }); + } + + TextStyle get _textStyle { + if (widget.isDefaultAction) { + return _kActionSheetActionStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + fontWeight: FontWeight.w600, + ); + } + if (widget.isDestructiveAction) { + return _kActionSheetActionStyle.copyWith(color: CupertinoColors.destructiveRed); + } + return _kActionSheetActionStyle.copyWith( + color: CupertinoDynamicColor.resolve(CupertinoColors.label, context), + ); + } + + @override + Widget build(BuildContext context) { + return MouseRegion( + cursor: MouseCursor.defer, + child: GestureDetector( + key: _globalKey, + onTapDown: onTapDown, + onTapUp: onTapUp, + onTapCancel: onTapCancel, + onTap: widget.onPressed, + behavior: HitTestBehavior.opaque, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: _kButtonHeight), + child: Semantics( + button: true, + child: ColoredBox( + color: _isPressed + ? ColorScheme.of(context).surfaceContainerHighest + : ColorScheme.of(context).surfaceContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(15.5, 8.0, 17.5, 8.0), + child: DefaultTextStyle( + style: _textStyle, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: widget.child), + if (widget.trailingIcon != null) + Icon(widget.trailingIcon, color: _textStyle.color, size: 21.0), + ], + ), + ), + ), + ), + ), + ), + ), ); } } From 6d9077e19c3d0964d738b53292310a689b1b9a8e Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Thu, 29 May 2025 18:27:02 +0200 Subject: [PATCH 2/3] Add study categories chip list Closes #1815 --- lib/src/model/study/study_filter.dart | 25 --- lib/src/model/study/study_list_paginator.dart | 8 +- lib/src/view/study/study_list_screen.dart | 204 ++++++++++-------- 3 files changed, 115 insertions(+), 122 deletions(-) diff --git a/lib/src/model/study/study_filter.dart b/lib/src/model/study/study_filter.dart index 9939fba8c1..1a759a712c 100644 --- a/lib/src/model/study/study_filter.dart +++ b/lib/src/model/study/study_filter.dart @@ -1,9 +1,4 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'study_filter.freezed.dart'; -part 'study_filter.g.dart'; enum StudyCategory { all, @@ -38,23 +33,3 @@ enum StudyListOrder { StudyListOrder.popular => l10n.studyMostPopular, }; } - -@riverpod -class StudyFilter extends _$StudyFilter { - @override - StudyFilterState build() => const StudyFilterState(); - - void setCategory(StudyCategory category) => state = state.copyWith(category: category); - - void setOrder(StudyListOrder order) => state = state.copyWith(order: order); -} - -@freezed -sealed class StudyFilterState with _$StudyFilterState { - const StudyFilterState._(); - - const factory StudyFilterState({ - @Default(StudyCategory.all) StudyCategory category, - @Default(StudyListOrder.hot) StudyListOrder order, - }) = _StudyFilterState; -} diff --git a/lib/src/model/study/study_list_paginator.dart b/lib/src/model/study/study_list_paginator.dart index 76332d4ada..29c856c69e 100644 --- a/lib/src/model/study/study_list_paginator.dart +++ b/lib/src/model/study/study_list_paginator.dart @@ -12,7 +12,11 @@ typedef StudyList = ({IList studies, int? nextPage}); @riverpod class StudyListPaginator extends _$StudyListPaginator { @override - Future build({required StudyFilterState filter, String? search}) { + Future build({ + required StudyCategory category, + required StudyListOrder order, + String? search, + }) { return _nextPage(); } @@ -33,7 +37,7 @@ class StudyListPaginator extends _$StudyListPaginator { final repo = ref.read(studyRepositoryProvider); return search == null - ? repo.getStudies(category: filter.category, order: filter.order, page: nextPage) + ? repo.getStudies(category: category, order: order, page: nextPage) : repo.searchStudies(query: search!, page: nextPage); } } diff --git a/lib/src/view/study/study_list_screen.dart b/lib/src/view/study/study_list_screen.dart index de3914745b..f7d071c2e7 100644 --- a/lib/src/view/study/study_list_screen.dart +++ b/lib/src/view/study/study_list_screen.dart @@ -13,18 +13,15 @@ import 'package:lichess_mobile/src/utils/lichess_assets.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/study/study_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; -import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/filter.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/platform_context_menu_button.dart'; import 'package:lichess_mobile/src/widgets/platform_search_bar.dart'; import 'package:lichess_mobile/src/widgets/user_full_name.dart'; -import 'package:logging/logging.dart'; - -final _logger = Logger('StudyListScreen'); /// A screen that displays a paginated list of studies -class StudyListScreen extends ConsumerWidget { +class StudyListScreen extends ConsumerStatefulWidget { const StudyListScreen({super.key}); static Route buildRoute(BuildContext context) { @@ -32,94 +29,23 @@ class StudyListScreen extends ConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) { - final isLoggedIn = ref.watch(authSessionProvider)?.user.id != null; - - final filter = ref.watch(studyFilterProvider); - final title = Text(isLoggedIn ? filter.category.l10n(context.l10n) : context.l10n.studyMenu); - - return PlatformScaffold( - appBar: PlatformAppBar( - title: title, - actions: [ - SemanticIconButton( - icon: const Icon(Icons.filter_list), - // TODO: translate - semanticsLabel: 'Filter studies', - onPressed: () => showModalBottomSheet( - context: context, - useRootNavigator: true, - isScrollControlled: true, - showDragHandle: true, - builder: (_) => _StudyFilterSheet(isLoggedIn: isLoggedIn), - ), - ), - ], - ), - body: _Body(filter: filter), - ); - } -} - -class _StudyFilterSheet extends ConsumerWidget { - const _StudyFilterSheet({required this.isLoggedIn}); - - final bool isLoggedIn; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final filter = ref.watch(studyFilterProvider); - - return BottomSheetScrollableContainer( - padding: const EdgeInsets.all(16.0), - children: [ - // If we're not logged in, the only category available is "All" - if (isLoggedIn) ...[ - Filter( - filterType: FilterType.singleChoice, - choices: StudyCategory.values, - choiceSelected: (choice) => filter.category == choice, - choiceLabel: (category) => Text(category.l10n(context.l10n)), - onSelected: (value, selected) => - ref.read(studyFilterProvider.notifier).setCategory(value), - ), - const PlatformDivider(thickness: 1, indent: 0), - const SizedBox(height: 10.0), - ], - Filter( - // TODO mobile l10n - filterName: 'Sort by', - filterType: FilterType.singleChoice, - choices: StudyListOrder.values, - choiceSelected: (choice) => filter.order == choice, - choiceLabel: (order) => Text(order.l10n(context.l10n)), - onSelected: (value, selected) => ref.read(studyFilterProvider.notifier).setOrder(value), - ), - ], - ); - } + ConsumerState createState() => _StudyListScreenState(); } -class _Body extends ConsumerStatefulWidget { - const _Body({required this.filter}); +class _StudyListScreenState extends ConsumerState { + StudyCategory category = StudyCategory.all; + StudyListOrder order = StudyListOrder.hot; - final StudyFilterState filter; - - @override - ConsumerState<_Body> createState() => _BodyState(); -} - -class _BodyState extends ConsumerState<_Body> { String? search; - final _searchController = SearchController(); + final _searchController = TextEditingController(); final _scrollController = ScrollController(keepScrollOffset: true); bool requestedNextPage = false; StudyListPaginatorProvider get paginatorProvider => - StudyListPaginatorProvider(filter: widget.filter, search: search); + StudyListPaginatorProvider(category: category, order: order, search: search); @override void initState() { @@ -152,6 +78,8 @@ class _BodyState extends ConsumerState<_Body> { @override Widget build(BuildContext context) { + final sessionUser = ref.watch(authSessionProvider)?.user; + ref.listen(paginatorProvider, (prev, next) { if (prev?.value?.nextPage != next.value?.nextPage) { WidgetsBinding.instance.addPostFrameCallback((_) { @@ -183,9 +111,100 @@ class _BodyState extends ConsumerState<_Body> { ), ); - return studiesAsync.when( - data: (studies) { - return ListView.separated( + return PlatformScaffold( + appBar: PlatformAppBar( + title: Text(sessionUser != null ? context.l10n.studyMenu : context.l10n.studyAllStudies), + actions: [ + ContextMenuIconButton( + icon: const Icon(Icons.sort_outlined), + semanticsLabel: 'Sort studies', + actions: [ + ContextMenuAction( + icon: order == StudyListOrder.hot ? Icons.check : null, + label: context.l10n.studyHot, + onPressed: () => setState(() { + order = StudyListOrder.hot; + }), + ), + ContextMenuAction( + icon: order == StudyListOrder.newest ? Icons.check : null, + label: context.l10n.studyDateAddedNewest, + onPressed: () => setState(() { + order = StudyListOrder.newest; + }), + ), + ContextMenuAction( + icon: order == StudyListOrder.updated ? Icons.check : null, + label: context.l10n.studyRecentlyUpdated, + onPressed: () => setState(() { + order = StudyListOrder.updated; + }), + ), + ContextMenuAction( + icon: order == StudyListOrder.popular ? Icons.check : null, + label: context.l10n.studyMostPopular, + onPressed: () => setState(() { + order = StudyListOrder.popular; + }), + ), + ], + ), + ], + bottom: sessionUser != null + ? PreferredSize( + preferredSize: const Size.fromHeight(50.0), + child: SizedBox( + height: 50.0, + child: Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: ListView.separated( + 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(); + } + }); + } + }, + ); + }, + ), + ), + ), + ) + : null, + ), + body: switch (studiesAsync) { + AsyncData(value: final studies) => ListView.separated( shrinkWrap: true, itemCount: studies.studies.length + 1, controller: _scrollController, @@ -196,19 +215,14 @@ class _BodyState extends ConsumerState<_Body> { : const PlatformDivider(height: 1, color: Colors.transparent), itemBuilder: (context, index) => index == 0 ? searchBar : _StudyListItem(study: studies.studies[index - 1]), - ); - }, - loading: () { - return Column( + ), + AsyncError() => FullScreenRetryRequest(onRetry: () => ref.invalidate(paginatorProvider)), + _ => Column( children: [ searchBar, const Expanded(child: Center(child: CircularProgressIndicator.adaptive())), ], - ); - }, - error: (error, stack) { - _logger.severe('Error loading studies', error, stack); - return Center(child: Text(context.l10n.studyMenu)); + ), }, ); } From 6a05764cb7414aa560392332ac8301c2720d2e77 Mon Sep 17 00:00:00 2001 From: Vincent Velociter Date: Fri, 30 May 2025 10:00:46 +0200 Subject: [PATCH 3/3] Add a space after the search term --- lib/src/view/study/study_list_screen.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/view/study/study_list_screen.dart b/lib/src/view/study/study_list_screen.dart index f7d071c2e7..cf8bd91fb9 100644 --- a/lib/src/view/study/study_list_screen.dart +++ b/lib/src/view/study/study_list_screen.dart @@ -181,12 +181,12 @@ class _StudyListScreenState extends ConsumerState { ].contains(cat)) { search = null; _searchController.value = TextEditingValue( - text: 'owner:${sessionUser.id}', + text: 'owner:${sessionUser.id} ', ); } else if (cat == StudyCategory.member) { search = null; _searchController.value = TextEditingValue( - text: 'member:${sessionUser.id}', + text: 'member:${sessionUser.id} ', ); } else { search = null;