diff --git a/designer_v2/lib/common_views/search.dart b/designer_v2/lib/common_views/search.dart new file mode 100644 index 000000000..813b4876c --- /dev/null +++ b/designer_v2/lib/common_views/search.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:studyu_designer_v2/theme.dart'; + +class Search extends StatefulWidget { + final Function(String) onQueryChanged; + final SearchController? searchController; + final String? hintText; + final String? initialText; + + const Search({ + super.key, + required this.onQueryChanged, + this.searchController, + this.hintText, + this.initialText, + }); + + @override + SearchState createState() => SearchState(); +} + +class SearchState extends State { + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + widget.searchController?.setText = setText; + _searchController = TextEditingController(text: widget.initialText); + _searchController.addListener(_onSearchPressed); + } + + @override + void didUpdateWidget(Search oldWidget) { + super.didUpdateWidget(oldWidget); + } + + void _onSearchPressed() { + String query = _searchController.text.toLowerCase(); + widget.onQueryChanged(query); + } + + void setText(String text) { + setState(() { + _searchController.text = text; + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: 400.0, + child: SearchBar( + hintText: widget.hintText ?? "Search", + controller: _searchController, + leading: const Icon(Icons.search), + backgroundColor: MaterialStateProperty.resolveWith((states) { + return ThemeConfig.sidesheetBackgroundColor(theme).withOpacity(0.5); + }), + ) + ); + } + + @override + void dispose() { + super.dispose(); + _searchController.removeListener(_onSearchPressed); + _searchController.dispose(); + } +} + +class SearchController { + late void Function(String text) setText; + +} diff --git a/designer_v2/lib/features/dashboard/dashboard_controller.dart b/designer_v2/lib/features/dashboard/dashboard_controller.dart index 3e5472e7b..78cbe2f89 100644 --- a/designer_v2/lib/features/dashboard/dashboard_controller.dart +++ b/designer_v2/lib/features/dashboard/dashboard_controller.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:studyu_core/core.dart'; +import 'package:studyu_designer_v2/common_views/search.dart'; import 'package:studyu_designer_v2/domain/study.dart'; import 'package:studyu_designer_v2/features/dashboard/studies_filter.dart'; import 'package:studyu_designer_v2/features/study/study_actions.dart'; @@ -27,6 +28,8 @@ class DashboardController extends StateNotifier implements IMode /// A subscription for synchronizing state between the repository and the controller StreamSubscription>>? _studiesSubscription; + final SearchController searchController = SearchController(); + DashboardController({ required this.studyRepository, required this.authRepository, @@ -51,6 +54,10 @@ class DashboardController extends StateNotifier implements IMode }); } + setSearchText(String? text) { + searchController.setText(text ?? state.query); + } + setStudiesFilter(StudiesFilter? filter) { state = state.copyWith(studiesFilter: () => filter ?? DashboardState.defaultFilter); } @@ -63,14 +70,6 @@ class DashboardController extends StateNotifier implements IMode router.dispatch(RoutingIntents.studyNew); } - String? search(String query) { - if (query.isEmpty) { - return null; - } else { - return query.toLowerCase(); - } - } - Future pinStudy(String modelId) async { // todo move to userRepository [updatePreferences] final newPinnedStudies = Set.from(userRepository.user.preferences.pinnedStudies); @@ -89,6 +88,12 @@ class DashboardController extends StateNotifier implements IMode sortStudies(); } + void filterStudies(String? query) async { + state = state.copyWith( + query: query, + ); + } + void sortStudies() async { final studies = state.sort(pinnedStudies: userRepository.user.preferences.pinnedStudies); state = state.copyWith( diff --git a/designer_v2/lib/features/dashboard/dashboard_page.dart b/designer_v2/lib/features/dashboard/dashboard_page.dart index f3cb86a7a..c8d1461ce 100644 --- a/designer_v2/lib/features/dashboard/dashboard_page.dart +++ b/designer_v2/lib/features/dashboard/dashboard_page.dart @@ -4,6 +4,7 @@ import 'package:studyu_core/core.dart'; import 'package:studyu_designer_v2/common_views/async_value_widget.dart'; import 'package:studyu_designer_v2/common_views/empty_body.dart'; import 'package:studyu_designer_v2/common_views/primary_button.dart'; +import 'package:studyu_designer_v2/common_views/search.dart'; import 'package:studyu_designer_v2/features/dashboard/dashboard_controller.dart'; import 'package:studyu_designer_v2/features/dashboard/dashboard_scaffold.dart'; import 'package:studyu_designer_v2/features/dashboard/dashboard_state.dart'; @@ -11,7 +12,6 @@ import 'package:studyu_designer_v2/features/dashboard/studies_filter.dart'; import 'package:studyu_designer_v2/features/dashboard/studies_table.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; import 'package:studyu_designer_v2/repositories/user_repository.dart'; -import 'package:studyu_designer_v2/theme.dart'; import 'package:studyu_designer_v2/utils/performance.dart'; class DashboardScreen extends ConsumerStatefulWidget { @@ -26,9 +26,6 @@ class DashboardScreen extends ConsumerStatefulWidget { class _DashboardScreenState extends ConsumerState { late final DashboardController controller; late final DashboardState state; - late final Future preferences; - final searchController = TextEditingController(); - String? searchQuery; @override void initState() { @@ -36,7 +33,6 @@ class _DashboardScreenState extends ConsumerState { controller = ref.read(dashboardControllerProvider.notifier); state = ref.read(dashboardControllerProvider); runAsync(() => controller.setStudiesFilter(widget.filter)); - searchController.addListener(searchListener); } @override @@ -47,18 +43,6 @@ class _DashboardScreenState extends ConsumerState { } } - @override - void dispose() { - super.dispose(); - searchController.removeListener(searchListener); - } - - void searchListener() { - setState(() { - searchQuery = controller.search(searchController.text); - }); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -81,34 +65,28 @@ class _DashboardScreenState extends ConsumerState { const SizedBox(width: 28.0), SelectableText(state.visibleListTitle, style: theme.textTheme.headlineMedium), const Spacer(), - SizedBox( - width: 400.0, - child: SearchBar( + Search( + searchController: controller.searchController, hintText: tr.search, - controller: searchController, - leading: const Icon(Icons.search), - backgroundColor: MaterialStateProperty.resolveWith((states) { - return ThemeConfig.sidesheetBackgroundColor(theme).withOpacity(0.5); - }), - ), + onQueryChanged: (query) => controller.filterStudies(query) ), ], ), const SizedBox(height: 24.0), // spacing between body elements FutureBuilder( - future: userRepo.fetchUser(), + future: userRepo.fetchUser(), // todo cache this with ModelRepository builder: (context, snapshot) { if (snapshot.hasData) { return AsyncValueWidget>( - value: state.visibleStudies(searchQuery, snapshot.data!.preferences.pinnedStudies), + value: state.visibleStudies(snapshot.data!.preferences.pinnedStudies, state.query), data: (visibleStudies) => StudiesTable( studies: visibleStudies, pinnedStudies: snapshot.data!.preferences.pinnedStudies, - dashboardProvider: ref.read(dashboardControllerProvider.notifier), + dashboardController: ref.read(dashboardControllerProvider.notifier), onSelect: controller.onSelectStudy, getActions: controller.availableActions, emptyWidget: (widget.filter == null || widget.filter == StudiesFilter.owned) - ? (searchQuery != null && searchQuery!.isNotEmpty) + ? (state.query.isNotEmpty) ? Padding( padding: const EdgeInsets.only(top: 24.0), child: EmptyBody( @@ -128,7 +106,8 @@ class _DashboardScreenState extends ConsumerState { ), ) : const SizedBox.shrink(), - )); + ) + ); } return const SizedBox.shrink(); }), diff --git a/designer_v2/lib/features/dashboard/dashboard_state.dart b/designer_v2/lib/features/dashboard/dashboard_state.dart index 07416d9eb..075ab7720 100644 --- a/designer_v2/lib/features/dashboard/dashboard_state.dart +++ b/designer_v2/lib/features/dashboard/dashboard_state.dart @@ -11,6 +11,7 @@ class DashboardState extends Equatable { const DashboardState({ this.studies = const AsyncValue.loading(), this.studiesFilter = defaultFilter, + this.query = '', required this.currentUser, }); @@ -25,15 +26,18 @@ class DashboardState extends Equatable { /// Currently authenticated user (used for filtering studies) final User currentUser; + final String query; + /// The currently visible list of studies as by the selected filter /// /// Wrapped in an [AsyncValue] that mirrors the [studies]' async states, /// but resolves to a different subset of studies based on the [studiesFilter] - AsyncValue> visibleStudies(String? query, Set pinnedStudies) { + AsyncValue> visibleStudies(Set pinnedStudies, String query) { return studies.when( data: (studies) { List updatedStudies = - studiesFilter.apply(unfilteredStudies: studies, user: currentUser, query: query).toList(); + studiesFilter.apply(studies: studies, user: currentUser).toList(); + updatedStudies = filter(studiesToFilter: updatedStudies); updatedStudies = sort(pinnedStudies: pinnedStudies, studiesToSort: updatedStudies); return AsyncValue.data(updatedStudies); }, @@ -42,6 +46,14 @@ class DashboardState extends Equatable { ); } + List filter({List? studiesToFilter}) { + final filteredStudies = studiesToFilter ?? studies.value!; + if (query.isNotEmpty) { + return filteredStudies.where((s) => s.title!.toLowerCase().contains(query)).toList(); + } + return filteredStudies; + } + List sort({required Set pinnedStudies, List? studiesToSort}) { final sortedStudies = studiesToSort ?? studies.value!; sortedStudies.sort((study, other) => study.title!.compareTo(other.title!)); @@ -66,12 +78,13 @@ class DashboardState extends Equatable { AsyncValue> Function()? studies, StudiesFilter Function()? studiesFilter, User Function()? currentUser, - AsyncValue> Function()? pinnedStudies, + String? query, }) { return DashboardState( studies: studies != null ? studies() : this.studies, studiesFilter: studiesFilter != null ? studiesFilter() : this.studiesFilter, currentUser: currentUser != null ? currentUser() : this.currentUser, + query: query ?? this.query, ); } diff --git a/designer_v2/lib/features/dashboard/studies_filter.dart b/designer_v2/lib/features/dashboard/studies_filter.dart index cd07ec4a4..78d0ae8d5 100644 --- a/designer_v2/lib/features/dashboard/studies_filter.dart +++ b/designer_v2/lib/features/dashboard/studies_filter.dart @@ -5,11 +5,7 @@ import 'package:supabase_flutter/supabase_flutter.dart'; enum StudiesFilter with GoRouteParamEnum { all, owned, shared, public } extension StudiesFilterByUser on StudiesFilter { - Iterable apply({required Iterable unfilteredStudies, required User user, String? query}) { - Iterable studies = unfilteredStudies; - if (query != null && query.isNotEmpty) { - studies = unfilteredStudies.where((s) => s.title!.toLowerCase().contains(query)); - } + Iterable apply({required Iterable studies, required User user}) { switch (this) { case StudiesFilter.all: return studies; diff --git a/designer_v2/lib/features/dashboard/studies_table.dart b/designer_v2/lib/features/dashboard/studies_table.dart index 0723f5dcb..543f9253b 100644 --- a/designer_v2/lib/features/dashboard/studies_table.dart +++ b/designer_v2/lib/features/dashboard/studies_table.dart @@ -19,7 +19,7 @@ class StudiesTable extends StatelessWidget { required this.getActions, required this.emptyWidget, required this.pinnedStudies, - required this.dashboardProvider, + required this.dashboardController, Key? key, }) : super(key: key); @@ -28,7 +28,7 @@ class StudiesTable extends StatelessWidget { final ActionsProviderFor getActions; final Widget emptyWidget; final Iterable pinnedStudies; - final DashboardController dashboardProvider; + final DashboardController dashboardController; @override Widget build(BuildContext context) { @@ -37,7 +37,8 @@ class StudiesTable extends StatelessWidget { tr.studies_list_header_participants_active, tr.studies_list_header_participants_completed, ]; - final int maxLength = headers.fold(0, (max, element) => max > element.length ? max : element.length); + final int maxLength = headers.fold( + 0, (max, element) => max > element.length ? max : element.length); final double statsCellWidth = maxLength * 11; return StandardTable( @@ -49,17 +50,20 @@ class StudiesTable extends StatelessWidget { ), StandardTableColumn( label: tr.studies_list_header_title, - columnWidth: const MaxColumnWidth(FixedColumnWidth(200), FlexColumnWidth(2.4)), + columnWidth: + const MaxColumnWidth(FixedColumnWidth(200), FlexColumnWidth(2.4)), sortable: true, ), StandardTableColumn( label: tr.studies_list_header_status, - columnWidth: const MaxColumnWidth(FixedColumnWidth(90), IntrinsicColumnWidth()), + columnWidth: const MaxColumnWidth( + FixedColumnWidth(90), IntrinsicColumnWidth()), sortable: true, ), StandardTableColumn( label: tr.studies_list_header_participation, - columnWidth: const MaxColumnWidth(FixedColumnWidth(130), IntrinsicColumnWidth()), + columnWidth: const MaxColumnWidth( + FixedColumnWidth(130), IntrinsicColumnWidth()), sortable: true, ), StandardTableColumn( @@ -69,17 +73,20 @@ class StudiesTable extends StatelessWidget { ), StandardTableColumn( label: tr.studies_list_header_participants_enrolled, - columnWidth: MaxColumnWidth(FixedColumnWidth(statsCellWidth), const IntrinsicColumnWidth()), + columnWidth: MaxColumnWidth( + FixedColumnWidth(statsCellWidth), const IntrinsicColumnWidth()), sortable: true, ), StandardTableColumn( label: tr.studies_list_header_participants_active, - columnWidth: MaxColumnWidth(FixedColumnWidth(statsCellWidth), const IntrinsicColumnWidth()), + columnWidth: MaxColumnWidth( + FixedColumnWidth(statsCellWidth), const IntrinsicColumnWidth()), sortable: true, ), StandardTableColumn( label: tr.studies_list_header_participants_completed, - columnWidth: MaxColumnWidth(FixedColumnWidth(statsCellWidth), const IntrinsicColumnWidth()), + columnWidth: MaxColumnWidth( + FixedColumnWidth(statsCellWidth), const IntrinsicColumnWidth()), sortable: true, ), ], @@ -108,16 +115,19 @@ class StudiesTable extends StatelessWidget { (Study a, Study b) => 0, // do not sort pin icon (Study a, Study b) => a.title!.compareTo(b.title!), (Study a, Study b) => a.status.index.compareTo(b.status.index), - (Study a, Study b) => a.participation.index.compareTo(b.participation.index), + (Study a, Study b) => + a.participation.index.compareTo(b.participation.index), (Study a, Study b) => a.createdAt!.compareTo(b.createdAt!), (Study a, Study b) => a.participantCount.compareTo(b.participantCount), - (Study a, Study b) => a.activeSubjectCount.compareTo(b.activeSubjectCount), + (Study a, Study b) => + a.activeSubjectCount.compareTo(b.activeSubjectCount), (Study a, Study b) => a.endedCount.compareTo(b.endedCount), ]; return predicates; } - List _buildRow(BuildContext context, Study item, int rowIdx, Set states) { + List _buildRow( + BuildContext context, Study item, int rowIdx, Set states) { final theme = Theme.of(context); TextStyle? mutedTextStyleIfZero(int value) { @@ -148,8 +158,8 @@ class StudiesTable extends StatelessWidget { return [ MouseEventsRegion( onTap: () => pinnedStudies.contains(item.id) - ? dashboardProvider.pinOffStudy(item.id) - : dashboardProvider.pinStudy(item.id), + ? dashboardController.pinOffStudy(item.id) + : dashboardController.pinStudy(item.id), builder: (context, mouseEventState) { return SizedBox.expand( child: Container( @@ -169,13 +179,23 @@ class StudiesTable extends StatelessWidget { // todo either use [StudyTagBadge] here or use [studybadge.Badge] at [StudyDesignInfoFormView] // try to style [StudyTagBadge] the same as Badge here because of delete function. // then replace this badge here with [StudyTagBadge] - return studybadge.Badge( - label: item.studyTags.elementAt(index).name, - type: studybadge.BadgeType.outlineFill, - icon: null, - color: item.studyTags.elementAt(index).color != null - ? Color(int.parse(item.studyTags.elementAt(index).color!)) - : Colors.grey, + return MouseEventsRegion( + //onTap: () => dashboardController.filterTag.add(item.studyTags.elementAt(index)), + onTap: () { + dashboardController.setSearchText(item.studyTags.elementAt(index).name); + dashboardController.filterStudies(item.studyTags.elementAt(index).name); + }, + builder: (context, mouseEventState) { + return studybadge.Badge( + label: item.studyTags.elementAt(index).name, + type: studybadge.BadgeType.outlineFill, + icon: null, + color: item.studyTags.elementAt(index).color != null + ? Color(int.parse( + item.studyTags.elementAt(index).color!)) + : Colors.grey, + ); + }, ); }), ), @@ -189,9 +209,12 @@ class StudiesTable extends StatelessWidget { participation: item.participation, ), Text(item.createdAt?.toTimeAgoString() ?? ''), - Text(item.participantCount.toString(), style: mutedTextStyleIfZero(item.participantCount)), - Text(item.activeSubjectCount.toString(), style: mutedTextStyleIfZero(item.activeSubjectCount)), - Text(item.endedCount.toString(), style: mutedTextStyleIfZero(item.endedCount)), + Text(item.participantCount.toString(), + style: mutedTextStyleIfZero(item.participantCount)), + Text(item.activeSubjectCount.toString(), + style: mutedTextStyleIfZero(item.activeSubjectCount)), + Text(item.endedCount.toString(), + style: mutedTextStyleIfZero(item.endedCount)), ]; } } diff --git a/designer_v2/lib/repositories/user_repository.dart b/designer_v2/lib/repositories/user_repository.dart index b2ab48303..85a3a6c3f 100644 --- a/designer_v2/lib/repositories/user_repository.dart +++ b/designer_v2/lib/repositories/user_repository.dart @@ -3,7 +3,7 @@ import 'package:studyu_core/core.dart'; import 'package:studyu_designer_v2/repositories/api_client.dart'; import 'package:studyu_designer_v2/repositories/auth_repository.dart'; -// todo may or may not implements ModelRepository +// todo implements ModelRepository abstract class IUserRepository { StudyUUser get user; Future fetchUser();