diff --git a/.changes/2338-search-page-includes-events.md b/.changes/2338-search-page-includes-events.md new file mode 100644 index 000000000000..dc2ce0e563a4 --- /dev/null +++ b/.changes/2338-search-page-includes-events.md @@ -0,0 +1,2 @@ +- [New] : Search page now also includes Events Search. +- [New] : Search page now have Quick Filters which make your searching experience event better \ No newline at end of file diff --git a/app/lib/common/widgets/event/event_selector_drawer.dart b/app/lib/common/widgets/event/event_selector_drawer.dart index 2ef529256770..7f07699f6cef 100644 --- a/app/lib/common/widgets/event/event_selector_drawer.dart +++ b/app/lib/common/widgets/event/event_selector_drawer.dart @@ -1,5 +1,4 @@ import 'package:acter/features/events/providers/event_providers.dart'; -import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/widgets/event_item.dart'; import 'package:acter/features/events/widgets/skeletons/event_list_skeleton_widget.dart'; import 'package:atlas_icons/atlas_icons.dart'; @@ -63,8 +62,6 @@ Future selectEventDrawer({ itemBuilder: (context, index) => EventItem( event: calEvents[index], isShowRsvp: false, - eventType: - ref.read(eventTypeProvider(calEvents[index])), onTapEventItem: (event) { Navigator.pop(context, event); }, diff --git a/app/lib/features/events/pages/event_list_page.dart b/app/lib/features/events/pages/event_list_page.dart index ac8ef7bb61ed..388b16516a38 100644 --- a/app/lib/features/events/pages/event_list_page.dart +++ b/app/lib/features/events/pages/event_list_page.dart @@ -1,34 +1,23 @@ -import 'dart:math'; - -import 'package:acter/common/providers/common_providers.dart'; -import 'package:acter/common/providers/space_providers.dart'; -import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; -import 'package:acter/common/toolkit/errors/error_page.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/acter_search_widget.dart'; import 'package:acter/common/widgets/add_button_with_can_permission.dart'; -import 'package:acter/common/widgets/empty_state_widget.dart'; import 'package:acter/common/widgets/space_name_widget.dart'; import 'package:acter/features/events/providers/event_providers.dart'; -import 'package:acter/features/events/providers/event_type_provider.dart'; -import 'package:acter/features/events/widgets/event_item.dart'; -import 'package:acter/features/events/widgets/skeletons/event_list_skeleton_widget.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter/features/events/widgets/event_list_empty_state.dart'; +import 'package:acter/features/events/widgets/event_list_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:go_router/go_router.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('a3::cal_event::list'); class EventListPage extends ConsumerStatefulWidget { final String? spaceId; + final String? searchQuery; const EventListPage({ super.key, this.spaceId, + this.searchQuery, }); @override @@ -36,9 +25,14 @@ class EventListPage extends ConsumerStatefulWidget { } class _EventListPageState extends ConsumerState { - String get searchValue => ref.watch(searchValueProvider); - - EventFilters get eventFilterValue => ref.watch(eventFilterProvider); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + ref.read(eventListSearchTermProvider(widget.spaceId).notifier).state = + widget.searchQuery ?? ''; + }); + } @override Widget build(BuildContext context) { @@ -74,42 +68,34 @@ class _EventListPageState extends ConsumerState { } Widget _buildBody() { - final calEventsLoader = ref.watch( - eventListSearchFilterProvider( - (spaceId: widget.spaceId, searchText: searchValue), - ), - ); - return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ActerSearchWidget( + initialText: widget.searchQuery, onChanged: (value) { - final notifier = ref.read(searchValueProvider.notifier); - notifier.state = value; + ref + .read(eventListSearchTermProvider(widget.spaceId).notifier) + .state = value; + }, + onClear: () { + ref + .read(eventListSearchTermProvider(widget.spaceId).notifier) + .state = ''; }, - onClear: () => ref.read(searchValueProvider.notifier).state = '', ), filterChipsButtons(), Expanded( - child: calEventsLoader.when( - data: (calEvents) => _buildEventList(calEvents), - error: (error, stack) { - _log.severe('Failed to search events in space', error, stack); - return ErrorPage( - background: const EventListSkeleton(), - error: error, - stack: stack, - onRetryTap: () { - ref.invalidate( - eventListSearchFilterProvider( - (spaceId: widget.spaceId, searchText: searchValue), - ), - ); - }, - ); - }, - loading: () => const EventListSkeleton(), + child: EventListWidget( + isShowSpaceName: widget.spaceId == null, + shrinkWrap: false, + listProvider: eventListSearchedAndFilterProvider(widget.spaceId), + emptyStateBuilder: () => EventListEmptyState( + spaceId: widget.spaceId, + isSearchApplied: ref + .read(eventListSearchTermProvider(widget.spaceId)) + .isNotEmpty, + ), ), ), ], @@ -118,6 +104,7 @@ class _EventListPageState extends ConsumerState { Widget filterChipsButtons() { final lang = L10n.of(context); + final currentFilter = ref.watch(eventListFilterProvider(widget.spaceId)); return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Container( @@ -128,103 +115,47 @@ class _EventListPageState extends ConsumerState { child: Wrap( children: [ FilterChip( - selected: eventFilterValue == EventFilters.all, + selected: currentFilter == EventFilters.all, label: Text(lang.all), - onSelected: (value) { - final notifier = ref.read(eventFilterProvider.notifier); - notifier.state = EventFilters.all; - }, + onSelected: (value) => ref + .read(eventListFilterProvider(widget.spaceId).notifier) + .state = EventFilters.all, ), const SizedBox(width: 10), FilterChip( - selected: eventFilterValue == EventFilters.bookmarked, + selected: currentFilter == EventFilters.bookmarked, label: Text(lang.bookmarked), - onSelected: (value) { - final notifier = ref.read(eventFilterProvider.notifier); - notifier.state = EventFilters.bookmarked; - }, + onSelected: (value) => ref + .read(eventListFilterProvider(widget.spaceId).notifier) + .state = EventFilters.bookmarked, ), const SizedBox(width: 10), FilterChip( - selected: eventFilterValue == EventFilters.ongoing, + selected: currentFilter == EventFilters.ongoing, label: Text(lang.happeningNow), - onSelected: (value) { - final notifier = ref.read(eventFilterProvider.notifier); - notifier.state = EventFilters.ongoing; - }, + onSelected: (value) => ref + .read(eventListFilterProvider(widget.spaceId).notifier) + .state = EventFilters.ongoing, ), const SizedBox(width: 10), FilterChip( - selected: eventFilterValue == EventFilters.upcoming, + selected: currentFilter == EventFilters.upcoming, label: Text(lang.upcoming), - onSelected: (value) { - final notifier = ref.read(eventFilterProvider.notifier); - notifier.state = EventFilters.upcoming; - }, + onSelected: (value) => ref + .read(eventListFilterProvider(widget.spaceId).notifier) + .state = EventFilters.upcoming, ), const SizedBox(width: 10), FilterChip( - selected: eventFilterValue == EventFilters.past, + selected: currentFilter == EventFilters.past, label: Text(lang.past), - onSelected: (value) { - final notifier = ref.read(eventFilterProvider.notifier); - notifier.state = EventFilters.past; - }, + onSelected: (value) => ref + .read(eventListFilterProvider(widget.spaceId).notifier) + .state = EventFilters.past, ), ], ), ), ); } - - Widget _buildEventList(List events) { - final size = MediaQuery.of(context).size; - final widthCount = (size.width ~/ 500).toInt(); - const int minCount = 2; - - if (events.isEmpty) return _buildEventsEmptyState(); - - return SingleChildScrollView( - child: StaggeredGrid.count( - crossAxisCount: max(1, min(widthCount, minCount)), - children: [ - for (final event in events) - EventItem( - event: event, - isShowSpaceName: widget.spaceId == null, - eventType: ref.watch(eventTypeProvider(event)), - ), - ], - ), - ); - } - - Widget _buildEventsEmptyState() { - var canAdd = false; - if (searchValue.isEmpty) { - final canPostLoader = - ref.watch(hasSpaceWithPermissionProvider('CanPostEvent')); - if (canPostLoader.valueOrNull == true) canAdd = true; - } - final lang = L10n.of(context); - return Center( - heightFactor: 1, - child: EmptyState( - title: searchValue.isNotEmpty - ? lang.noMatchingEventsFound - : lang.noEventsFound, - subtitle: lang.noEventAvailableDescription, - image: 'assets/images/empty_event.svg', - primaryButton: canAdd - ? ActerPrimaryActionButton( - onPressed: () => context.pushNamed( - Routes.createEvent.name, - queryParameters: {'spaceId': widget.spaceId}, - ), - child: Text(lang.addEvent), - ) - : null, - ), - ); - } } diff --git a/app/lib/features/events/providers/event_providers.dart b/app/lib/features/events/providers/event_providers.dart index 6b868eaab6f8..51a3ca4f4120 100644 --- a/app/lib/features/events/providers/event_providers.dart +++ b/app/lib/features/events/providers/event_providers.dart @@ -4,6 +4,7 @@ import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/actions/sort_event_list.dart'; import 'package:acter/features/events/providers/notifiers/event_notifiers.dart'; import 'package:acter/features/events/providers/notifiers/rsvp_notifier.dart'; +import 'package:acter/features/search/providers/quick_search_providers.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' as ffi; import 'package:riverpod/riverpod.dart'; @@ -21,11 +22,18 @@ final myRsvpStatusProvider = AsyncNotifierProvider.autoDispose //SpaceId == null : GET LIST OF ALL PINs //SpaceId != null : GET LIST OF SPACE PINs -final allEventListProvider = AsyncNotifierProvider.family, String?>( () => EventListNotifier(), ); +final allEventListProvider = + FutureProvider.autoDispose.family, String?>( + (ref, spaceId) async => sortEventListDscTime( + await ref.watch(_allEventListProvider(spaceId).future), + ), +); + //ALL ONGOING EVENTS final bookmarkedEventListProvider = FutureProvider.autoDispose .family, String?>((ref, spaceId) async { @@ -131,74 +139,68 @@ enum EventFilters { past, } -final eventFilterProvider = - StateProvider.autoDispose((ref) => EventFilters.all); - //SEARCH EVENTS -typedef EventListSearchParams = ({String? spaceId, String searchText}); +typedef EventListSearchParams = ({ + String? spaceId, + String searchText, + EventFilters eventFilter +}); -final eventListSearchFilterProvider = FutureProvider.autoDispose - .family, EventListSearchParams>( - (ref, params) async { - //Declare filtered event list - List filteredEventList = []; - - //Filter events based on the selection - EventFilters eventFilter = ref.watch(eventFilterProvider); - switch (eventFilter) { - case EventFilters.bookmarked: - { - List bookmarkedEventList = - await ref.watch(bookmarkedEventListProvider(params.spaceId).future); - filteredEventList = bookmarkedEventList; - } - case EventFilters.ongoing: - { - List ongoingEventList = - await ref.watch(allOngoingEventListProvider(params.spaceId).future); - filteredEventList = ongoingEventList; - } - case EventFilters.upcoming: - { - List upcomingEventList = await ref - .watch(allUpcomingEventListProvider(params.spaceId).future); - filteredEventList = upcomingEventList; - } - case EventFilters.past: - { - List pastEventList = - await ref.watch(allPastEventListProvider(params.spaceId).future); - filteredEventList = pastEventList; - } - default: - { - //Get all events - List ongoingEventList = - await ref.watch(allOngoingEventListProvider(params.spaceId).future); - List upcomingEventList = await ref - .watch(allUpcomingEventListProvider(params.spaceId).future); - List pastEventList = - await ref.watch(allPastEventListProvider(params.spaceId).future); - - //Set all events - filteredEventList.addAll(ongoingEventList); - filteredEventList.addAll(upcomingEventList); - filteredEventList.addAll(pastEventList); - } +List _filterEventBySearchTerm( + String term, + List events, +) { + final cleanedTerm = term.trim().toLowerCase(); + if (cleanedTerm.isEmpty) { + return events; } - //Apply search on filtered event list - List searchedFilteredEventList = []; - if (params.searchText.isNotEmpty) { - for (final event in filteredEventList) { - bool isContainSearchTerm = - event.title().toLowerCase().contains(params.searchText.toLowerCase()); - if (isContainSearchTerm) { - searchedFilteredEventList.add(event); - } - } - return searchedFilteredEventList; - } + return events + .where((e) => e.title().toLowerCase().contains(cleanedTerm)) + .toList(); +} + +final eventListSearchTermProvider = + StateProvider.family((ref, spaceId) => ''); - return filteredEventList; +final eventListFilterProvider = StateProvider.family( + (ref, spaceId) => EventFilters.all, +); + +final eventListSearchedProvider = FutureProvider.autoDispose + .family, String?>((ref, spaceId) async { + final searchTerm = ref.watch(eventListSearchTermProvider(spaceId)); + return _filterEventBySearchTerm( + searchTerm, + await ref.watch(allEventListProvider(spaceId).future), + ); +}); + +final eventListQuickSearchedProvider = + FutureProvider.autoDispose>((ref) async { + final searchTerm = ref.watch(quickSearchValueProvider); + return _filterEventBySearchTerm( + searchTerm, + await ref.watch(allEventListProvider(null).future), + ); +}); + +final eventListSearchedAndFilterProvider = FutureProvider.autoDispose + .family, String?>((ref, spaceId) async { + //Declare filtered event list + final filteredEventList = + switch (ref.watch(eventListFilterProvider(spaceId))) { + EventFilters.bookmarked => + await ref.watch(bookmarkedEventListProvider(spaceId).future), + EventFilters.ongoing => + await ref.watch(allOngoingEventListProvider(spaceId).future), + EventFilters.upcoming => + await ref.watch(allUpcomingEventListProvider(spaceId).future), + EventFilters.past => + await ref.watch(allPastEventListProvider(spaceId).future), + EventFilters.all => await ref.watch(allEventListProvider(spaceId).future), + }; + + final searchTerm = ref.watch(eventListSearchTermProvider(spaceId)); + return _filterEventBySearchTerm(searchTerm, filteredEventList); }); diff --git a/app/lib/features/events/widgets/event_item.dart b/app/lib/features/events/widgets/event_item.dart index 0b82e1dcb7f4..85d273c8895e 100644 --- a/app/lib/features/events/widgets/event_item.dart +++ b/app/lib/features/events/widgets/event_item.dart @@ -3,6 +3,7 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/blinking_text.dart'; import 'package:acter/features/events/providers/event_providers.dart'; +import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/utils/events_utils.dart'; import 'package:acter/features/events/widgets/event_date_widget.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' @@ -15,13 +16,14 @@ import 'package:logging/logging.dart'; final _log = Logger('a3::cal_event::event_item'); -class EventItem extends StatelessWidget { +class EventItem extends ConsumerWidget { + static const eventItemClick = Key('event_item_click'); + final CalendarEvent event; final EdgeInsetsGeometry? margin; final Function(String)? onTapEventItem; final bool isShowRsvp; final bool isShowSpaceName; - final EventFilters eventType; const EventItem({ super.key, @@ -30,12 +32,13 @@ class EventItem extends StatelessWidget { this.onTapEventItem, this.isShowRsvp = true, this.isShowSpaceName = false, - required this.eventType, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final eventType = ref.watch(eventTypeProvider(event)); return InkWell( + key: eventItemClick, onTap: () { final eventId = event.eventId().toString(); onTapEventItem.map( @@ -73,7 +76,7 @@ class EventItem extends StatelessWidget { if (eventType == EventFilters.ongoing) _buildHappeningIndication(context), const SizedBox(width: 10), - if (isShowRsvp) _buildRsvpStatus(context), + if (isShowRsvp) _buildRsvpStatus(context, ref), const SizedBox(width: 10), ], ), @@ -109,31 +112,27 @@ class EventItem extends StatelessWidget { ); } - Widget _buildRsvpStatus(BuildContext context) { + Widget _buildRsvpStatus(BuildContext context, WidgetRef ref) { final lang = L10n.of(context); - return Consumer( - builder: (context, ref, child) { - final eventId = event.eventId().toString(); - final rsvpLoader = ref.watch(myRsvpStatusProvider(eventId)); - return rsvpLoader.when( - data: (status) { - final widget = _getRsvpStatus(context, status); // kebab-case - return widget ?? const SizedBox.shrink(); - }, - error: (e, s) { - _log.severe('Failed to load RSVP status', e, s); - return Chip( - label: Text( - lang.errorLoadingRsvpStatus(e), - softWrap: true, - ), - ); - }, - loading: () => Chip( - label: Text(lang.loadingRsvpStatus), + final eventId = event.eventId().toString(); + final rsvpLoader = ref.watch(myRsvpStatusProvider(eventId)); + return rsvpLoader.when( + data: (status) { + final widget = _getRsvpStatus(context, status); // kebab-case + return widget ?? const SizedBox.shrink(); + }, + error: (e, s) { + _log.severe('Failed to load RSVP status', e, s); + return Chip( + label: Text( + lang.errorLoadingRsvpStatus(e), + softWrap: true, ), ); }, + loading: () => Chip( + label: Text(lang.loadingRsvpStatus), + ), ); } diff --git a/app/lib/features/events/widgets/event_list_empty_state.dart b/app/lib/features/events/widgets/event_list_empty_state.dart new file mode 100644 index 000000000000..48ac19678b93 --- /dev/null +++ b/app/lib/features/events/widgets/event_list_empty_state.dart @@ -0,0 +1,48 @@ +import 'package:acter/common/providers/space_providers.dart'; +import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; +import 'package:acter/common/utils/routes.dart'; +import 'package:acter/common/widgets/empty_state_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; + +class EventListEmptyState extends ConsumerWidget { + final String? spaceId; + final bool isSearchApplied; + + const EventListEmptyState({ + super.key, + this.spaceId, + this.isSearchApplied = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + var canAdd = false; + if (!isSearchApplied) { + final canPostLoader = + ref.watch(hasSpaceWithPermissionProvider('CanPostEvent')); + if (canPostLoader.valueOrNull == true) canAdd = true; + } + return Center( + heightFactor: 1, + child: EmptyState( + title: isSearchApplied + ? L10n.of(context).noMatchingEventsFound + : L10n.of(context).noEventsFound, + subtitle: L10n.of(context).noEventAvailableDescription, + image: 'assets/images/empty_event.svg', + primaryButton: canAdd + ? ActerPrimaryActionButton( + onPressed: () => context.pushNamed( + Routes.createEvent.name, + queryParameters: {'spaceId': spaceId}, + ), + child: Text(L10n.of(context).addEvent), + ) + : null, + ), + ); + } +} diff --git a/app/lib/features/events/widgets/event_list_widget.dart b/app/lib/features/events/widgets/event_list_widget.dart new file mode 100644 index 000000000000..384d4ab2d239 --- /dev/null +++ b/app/lib/features/events/widgets/event_list_widget.dart @@ -0,0 +1,104 @@ +import 'package:acter/common/toolkit/errors/error_page.dart'; +import 'package:acter/features/events/widgets/event_item.dart'; +import 'package:acter/features/events/widgets/skeletons/event_list_skeleton_widget.dart'; +import 'package:acter/features/space/widgets/space_sections/section_header.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::event-list-widget'); + +class EventListWidget extends ConsumerWidget { + final ProviderBase>> listProvider; + final int? limit; + final bool showSectionHeader; + final VoidCallback? onClickSectionHeader; + final bool shrinkWrap; + final bool isShowSpaceName; + final Widget Function()? emptyStateBuilder; + + const EventListWidget({ + super.key, + this.limit, + this.isShowSpaceName = true, + required this.listProvider, + this.showSectionHeader = false, + this.onClickSectionHeader, + this.shrinkWrap = true, + this.emptyStateBuilder, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final calEventsLoader = ref.watch(listProvider); + + return calEventsLoader.when( + data: (eventList) => buildEventSectionUI(context, eventList), + error: (error, stack) => eventListErrorWidget(context, ref, error, stack), + loading: () => const EventListSkeleton(), + skipLoadingOnReload: true, + skipLoadingOnRefresh: true, + ); + } + + Widget eventListErrorWidget( + BuildContext context, + WidgetRef ref, + Object error, + StackTrace stack, + ) { + _log.severe('Failed to load events', error, stack); + return ErrorPage( + background: const EventListSkeleton(), + error: error, + stack: stack, + textBuilder: L10n.of(context).loadingFailed, + onRetryTap: () { + ref.invalidate(listProvider); + }, + ); + } + + Widget buildEventSectionUI( + BuildContext context, + List eventList, + ) { + if (eventList.isEmpty) { + return (emptyStateBuilder ?? () => const SizedBox.shrink())(); + } + + final count = (limit ?? eventList.length).clamp(0, eventList.length); + return showSectionHeader + ? Column( + mainAxisSize: MainAxisSize.min, + children: [ + SectionHeader( + title: L10n.of(context).events, + isShowSeeAllButton: count < eventList.length, + onTapSeeAll: () => onClickSectionHeader == null + ? null + : onClickSectionHeader!(), + ), + eventListUI(eventList, count), + ], + ) + : eventListUI(eventList, count); + } + + Widget eventListUI(List eventList, int count) { + return ListView.builder( + shrinkWrap: shrinkWrap, + itemCount: count, + padding: EdgeInsets.zero, + physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null, + itemBuilder: (context, index) { + return EventItem( + event: eventList[index], + isShowSpaceName: isShowSpaceName, + ); + }, + ); + } +} diff --git a/app/lib/features/home/widgets/my_events.dart b/app/lib/features/home/widgets/my_events.dart index 5c537dade903..6df3c79d3d0e 100644 --- a/app/lib/features/home/widgets/my_events.dart +++ b/app/lib/features/home/widgets/my_events.dart @@ -4,7 +4,6 @@ import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/toolkit/buttons/inline_text_button.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/features/events/providers/event_providers.dart'; -import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/widgets/event_item.dart'; import 'package:acter/features/events/widgets/skeletons/event_list_skeleton_widget.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; @@ -88,7 +87,6 @@ class MyEventsSection extends ConsumerWidget { isShowSpaceName: true, margin: const EdgeInsets.only(bottom: 14), event: events[index], - eventType: ref.watch(eventTypeProvider(events[index])), ), ); } diff --git a/app/lib/features/news/pages/add_news_page.dart b/app/lib/features/news/pages/add_news_page.dart index f632e709ba49..8fad7d2a9913 100644 --- a/app/lib/features/news/pages/add_news_page.dart +++ b/app/lib/features/news/pages/add_news_page.dart @@ -4,7 +4,6 @@ import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/toolkit/buttons/danger_action_button.dart'; import 'package:acter/common/widgets/acter_video_player.dart'; import 'package:acter/common/widgets/html_editor.dart'; -import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/providers/event_providers.dart'; import 'package:acter/features/events/widgets/event_item.dart'; import 'package:acter/features/events/widgets/skeletons/event_item_skeleton_widget.dart'; @@ -281,7 +280,6 @@ class AddNewsState extends ConsumerState { final notifier = ref.read(newsStateProvider.notifier); await notifier.selectEventToShare(context); }, - eventType: ref.watch(eventTypeProvider(calendarEvent)), ), ); }, diff --git a/app/lib/features/news/widgets/news_item_slide/news_slide_actions.dart b/app/lib/features/news/widgets/news_item_slide/news_slide_actions.dart index 60868045ad9c..a90664da4773 100644 --- a/app/lib/features/news/widgets/news_item_slide/news_slide_actions.dart +++ b/app/lib/features/news/widgets/news_item_slide/news_slide_actions.dart @@ -1,7 +1,6 @@ import 'package:acter/common/actions/open_link.dart'; import 'package:acter/common/toolkit/errors/error_dialog.dart'; import 'package:acter/features/events/providers/event_providers.dart'; -import 'package:acter/features/events/providers/event_type_provider.dart'; import 'package:acter/features/events/widgets/event_item.dart'; import 'package:acter/features/events/widgets/skeletons/event_item_skeleton_widget.dart'; import 'package:acter/features/news/model/news_references_model.dart'; @@ -46,10 +45,7 @@ class NewsSlideActions extends ConsumerWidget { final lang = L10n.of(context); final calEventLoader = ref.watch(calendarEventProvider(eventId)); return calEventLoader.when( - data: (calEvent) => EventItem( - event: calEvent, - eventType: ref.watch(eventTypeProvider(calEvent)), - ), + data: (calEvent) => EventItem(event: calEvent), loading: () => const EventItemSkeleton(), error: (e, s) { _log.severe('Failed to load cal event', e, s); diff --git a/app/lib/features/search/model/keys.dart b/app/lib/features/search/model/keys.dart index fb13769eb8da..e40025272e3d 100644 --- a/app/lib/features/search/model/keys.dart +++ b/app/lib/features/search/model/keys.dart @@ -11,3 +11,11 @@ class QuickJumpKeys { static const createPinAction = Key('quick-jump-create-pin'); static const createEventAction = Key('quick-jump-create-event'); } + +//EVENT FILTERS +enum QuickSearchFilters { + all, + spaces, + pins, + events, +} diff --git a/app/lib/features/search/pages/quick_search_page.dart b/app/lib/features/search/pages/quick_search_page.dart index a4aff4d22a42..f3f0ef5725b2 100644 --- a/app/lib/features/search/pages/quick_search_page.dart +++ b/app/lib/features/search/pages/quick_search_page.dart @@ -1,6 +1,9 @@ import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/acter_search_widget.dart'; +import 'package:acter/features/events/providers/event_providers.dart'; +import 'package:acter/features/events/widgets/event_list_widget.dart'; import 'package:acter/features/pins/widgets/pin_list_widget.dart'; +import 'package:acter/features/search/model/keys.dart'; import 'package:acter/features/search/providers/quick_search_providers.dart'; import 'package:acter/features/spaces/widgets/space_list_widget.dart'; import 'package:flutter/material.dart'; @@ -17,6 +20,8 @@ class QuickSearchPage extends ConsumerStatefulWidget { class _QuickSearchPageState extends ConsumerState { String get searchValue => ref.watch(quickSearchValueProvider); + ValueNotifier quickSearchFilters = + ValueNotifier(QuickSearchFilters.all); @override Widget build(BuildContext context) { @@ -47,34 +52,102 @@ class _QuickSearchPageState extends ConsumerState { notifier.state = ''; }, ), - Expanded(child: quickSearchSectionsUI()), + ValueListenableBuilder( + valueListenable: quickSearchFilters, + builder: (context, showHeader, child) => filterChipsButtons(), + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: quickSearchFilters, + builder: (context, showHeader, child) => quickSearchSectionsUI(), + ), + ), ], ); } + Widget filterChipsButtons() { + final lang = L10n.of(context); + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + child: Wrap( + children: [ + FilterChip( + selected: quickSearchFilters.value == QuickSearchFilters.all, + label: Text(lang.all), + onSelected: (value) => + quickSearchFilters.value = QuickSearchFilters.all, + ), + const SizedBox(width: 10), + FilterChip( + selected: quickSearchFilters.value == QuickSearchFilters.spaces, + label: Text(lang.spaces), + onSelected: (value) => + quickSearchFilters.value = QuickSearchFilters.spaces, + ), + const SizedBox(width: 10), + FilterChip( + selected: quickSearchFilters.value == QuickSearchFilters.pins, + label: Text(lang.pins), + onSelected: (value) => + quickSearchFilters.value = QuickSearchFilters.pins, + ), + const SizedBox(width: 10), + FilterChip( + selected: quickSearchFilters.value == QuickSearchFilters.events, + label: Text(lang.events), + onSelected: (value) => + quickSearchFilters.value = QuickSearchFilters.events, + ), + ], + ), + ), + ); + } + Widget quickSearchSectionsUI() { return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SpaceListWidget( - limit: 3, - searchValue: searchValue, - showSectionHeader: true, - onClickSectionHeader: () => context.pushNamed( - Routes.spaces.name, - queryParameters: {'searchQuery': searchValue}, + if (quickSearchFilters.value == QuickSearchFilters.all || + quickSearchFilters.value == QuickSearchFilters.spaces) + SpaceListWidget( + limit: 3, + searchValue: searchValue, + showSectionHeader: true, + onClickSectionHeader: () => context.pushNamed( + Routes.spaces.name, + queryParameters: {'searchQuery': searchValue}, + ), ), - ), - PinListWidget( - limit: 3, - searchValue: searchValue, - showSectionHeader: true, - onClickSectionHeader: () => context.pushNamed( - Routes.pins.name, - queryParameters: {'searchQuery': searchValue}, + if (quickSearchFilters.value == QuickSearchFilters.all || + quickSearchFilters.value == QuickSearchFilters.pins) + PinListWidget( + limit: 3, + searchValue: searchValue, + showSectionHeader: true, + onClickSectionHeader: () => context.pushNamed( + Routes.pins.name, + queryParameters: {'searchQuery': searchValue}, + ), + ), + if (quickSearchFilters.value == QuickSearchFilters.all || + quickSearchFilters.value == QuickSearchFilters.events) + EventListWidget( + limit: 3, + listProvider: eventListQuickSearchedProvider, + showSectionHeader: true, + onClickSectionHeader: () => context.pushNamed( + Routes.calendarEvents.name, + queryParameters: {'searchQuery': searchValue}, + ), ), - ), ], ), ); diff --git a/app/lib/features/space/pages/space_details_page.dart b/app/lib/features/space/pages/space_details_page.dart index b7c275216703..cc1f2b5cc52e 100644 --- a/app/lib/features/space/pages/space_details_page.dart +++ b/app/lib/features/space/pages/space_details_page.dart @@ -3,6 +3,8 @@ import 'package:acter/common/providers/space_providers.dart'; import 'package:acter/common/toolkit/errors/error_page.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/scrollable_list_tab_scroller.dart'; +import 'package:acter/features/events/providers/event_providers.dart'; +import 'package:acter/features/events/widgets/event_list_widget.dart'; import 'package:acter/features/pins/widgets/pin_list_widget.dart'; import 'package:acter/features/space/dialogs/suggested_rooms.dart'; import 'package:acter/features/space/providers/space_navbar_provider.dart'; @@ -11,7 +13,6 @@ import 'package:acter/features/space/widgets/skeletons/space_details_skeletons.d import 'package:acter/features/space/widgets/space_header.dart'; import 'package:acter/features/space/widgets/space_sections/about_section.dart'; import 'package:acter/features/space/widgets/space_sections/chats_section.dart'; -import 'package:acter/features/space/widgets/space_sections/events_section.dart'; import 'package:acter/features/space/widgets/space_sections/members_section.dart'; import 'package:acter/features/space/widgets/space_sections/news_section.dart'; import 'package:acter/features/space/widgets/space_sections/space_actions_section.dart'; @@ -272,7 +273,16 @@ class _SpaceDetailsPageState extends ConsumerState { ), ), TabEntry.tasks => TasksSection(spaceId: widget.spaceId), - TabEntry.events => EventsSection(spaceId: widget.spaceId), + TabEntry.events => EventListWidget( + isShowSpaceName: false, + showSectionHeader: true, + listProvider: allEventListProvider(widget.spaceId), + limit: 3, + onClickSectionHeader: () => context.pushNamed( + Routes.spaceEvents.name, + pathParameters: {'spaceId': widget.spaceId}, + ), + ), TabEntry.chats => ChatsSection(spaceId: widget.spaceId), TabEntry.spaces => SpacesSection(spaceId: widget.spaceId), TabEntry.members => MembersSection(spaceId: widget.spaceId), diff --git a/app/lib/features/space/widgets/space_sections/events_section.dart b/app/lib/features/space/widgets/space_sections/events_section.dart deleted file mode 100644 index 719504e28d92..000000000000 --- a/app/lib/features/space/widgets/space_sections/events_section.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:acter/common/utils/routes.dart'; -import 'package:acter/features/events/providers/event_providers.dart'; -import 'package:acter/features/events/providers/event_type_provider.dart'; -import 'package:acter/features/events/widgets/event_item.dart'; -import 'package:acter/features/space/widgets/space_sections/section_header.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('a3::space::sections::cal_events'); - -class EventsSection extends ConsumerWidget { - final String spaceId; - final int limit; - - const EventsSection({ - super.key, - required this.spaceId, - this.limit = 3, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final lang = L10n.of(context); - final calEventsLoader = ref.watch( - eventListSearchFilterProvider((spaceId: spaceId, searchText: '')), - ); - return calEventsLoader.when( - data: (calEvents) => buildEventsSectionUI(context, ref, calEvents), - error: (e, s) { - _log.severe('Failed to search cal events in space', e, s); - return Center( - child: Text(lang.searchingFailed(e)), - ); - }, - loading: () => Center( - child: Text(lang.loading), - ), - ); - } - - Widget buildEventsSectionUI( - BuildContext context, - WidgetRef ref, - List events, - ) { - final hasMore = events.length > limit; - final count = hasMore ? limit : events.length; - return Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SectionHeader( - title: L10n.of(context).events, - isShowSeeAllButton: hasMore, - onTapSeeAll: () => context.pushNamed( - Routes.spaceEvents.name, - pathParameters: {'spaceId': spaceId}, - ), - ), - eventsListUI(ref, events, count), - ], - ); - } - - Widget eventsListUI(WidgetRef ref, List events, int count) { - return ListView.builder( - shrinkWrap: true, - itemCount: count, - padding: EdgeInsets.zero, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => EventItem( - event: events[index], - eventType: ref.watch(eventTypeProvider(events[index])), - ), - ); - } -} diff --git a/app/lib/router/shell_routers/home_shell_router.dart b/app/lib/router/shell_routers/home_shell_router.dart index 14bb66b3316e..2e09826bab1c 100644 --- a/app/lib/router/shell_routers/home_shell_router.dart +++ b/app/lib/router/shell_routers/home_shell_router.dart @@ -491,9 +491,10 @@ final homeShellRoutes = [ path: Routes.calendarEvents.route, redirect: authGuardRedirect, pageBuilder: (context, state) { + final searchQuery = state.uri.queryParameters['searchQuery']; return NoTransitionPage( key: state.pageKey, - child: const EventListPage(), + child: EventListPage(searchQuery: searchQuery), ); }, ), diff --git a/app/test/common/widgets/acter_search_widget_test.dart b/app/test/common/widgets/acter_search_widget_test.dart new file mode 100644 index 000000000000..6fdb0893890d --- /dev/null +++ b/app/test/common/widgets/acter_search_widget_test.dart @@ -0,0 +1,101 @@ +import 'package:acter/common/widgets/acter_search_widget.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +class MockOnChanged extends Mock { + void call(String value); +} + +class MockOnClear extends Mock { + void call(); +} + +void main() { + late MockOnChanged mockOnChanged; + late MockOnClear mockOnClear; + + setUp(() { + mockOnChanged = MockOnChanged(); + mockOnClear = MockOnClear(); + }); + + Widget createWidgetUnderTest({String? hintText, String? initialText}) { + return MaterialApp( + localizationsDelegates: L10n.localizationsDelegates, + home: Scaffold( + body: ActerSearchWidget( + hintText: hintText, + initialText: initialText, + onChanged: mockOnChanged.call, + onClear: mockOnClear.call, + ), + ), + ); + } + + testWidgets('displays initial text in the search bar', (tester) async { + const initialText = 'initial search'; + + await tester.pumpWidget(createWidgetUnderTest(initialText: initialText)); + + final searchField = find.byKey(ActerSearchWidget.searchBarKey); + expect(searchField, findsOneWidget); + expect(find.text(initialText), findsOneWidget); + }); + + testWidgets('calls onChanged when text is entered', (tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + const inputText = 'new search'; + await tester.enterText( + find.byKey(ActerSearchWidget.searchBarKey), + inputText, + ); + + verify(() => mockOnChanged(inputText)).called(1); + }); + + testWidgets('calls onClear and clears text when clear button is pressed', + (tester) async { + await tester + .pumpWidget(createWidgetUnderTest(initialText: 'text to clear')); + + await tester.enterText( + find.byKey(ActerSearchWidget.searchBarKey), + 'text to clear', + ); + await tester.pump(); + + // Ensure the clear button is displayed + final clearButton = + find.byKey(ActerSearchWidget.clearSearchActionButtonKey); + expect(clearButton, findsOneWidget); + + // Tap the clear button and verify behaviors + await tester.tap(clearButton); + await tester.pump(); + + verify(() => mockOnClear()).called(1); + expect(find.text('text to clear'), findsNothing); + }); + + testWidgets('displays hint text when there is no initial text', + (tester) async { + const hintText = 'Search here...'; + + await tester.pumpWidget(createWidgetUnderTest(hintText: hintText)); + + expect(find.text(hintText), findsOneWidget); + }); + + testWidgets('displays default leading icon if none is provided', + (tester) async { + await tester.pumpWidget(createWidgetUnderTest()); + + final leadingIcon = find.byIcon(Atlas.magnifying_glass); + expect(leadingIcon, findsOneWidget); + }); +} diff --git a/app/test/features/events/error_pages_test.dart b/app/test/features/events/error_pages_test.dart index 8b09e2435a8b..607a18d8af05 100644 --- a/app/test/features/events/error_pages_test.dart +++ b/app/test/features/events/error_pages_test.dart @@ -18,7 +18,7 @@ void main() { bool shouldFail = true; await tester.pumpProviderWidget( overrides: [ - eventListSearchFilterProvider.overrideWith((a, b) { + eventListSearchedAndFilterProvider.overrideWith((a, b) { if (shouldFail) { // toggle failure so the retry works shouldFail = !shouldFail; @@ -40,7 +40,7 @@ void main() { searchValueProvider .overrideWith((_) => 'some string'), // set a search string - eventListSearchFilterProvider.overrideWith((a, b) { + eventListSearchedAndFilterProvider.overrideWith((a, b) { if (shouldFail) { // toggle failure so the retry works shouldFail = !shouldFail; @@ -61,7 +61,7 @@ void main() { overrides: [ roomDisplayNameProvider.overrideWith((a, b) => 'test'), roomMembershipProvider.overrideWith((a, b) => null), - eventListSearchFilterProvider.overrideWith((a, b) { + eventListSearchedAndFilterProvider.overrideWith((a, b) { if (shouldFail) { // toggle failure so the retry works shouldFail = !shouldFail; @@ -86,7 +86,7 @@ void main() { roomMembershipProvider.overrideWith((a, b) => null), searchValueProvider .overrideWith((_) => 'some search'), // set a search string - eventListSearchFilterProvider.overrideWith((a, b) { + eventListSearchedAndFilterProvider.overrideWith((a, b) { if (shouldFail) { // toggle failure so the retry works shouldFail = !shouldFail; diff --git a/app/test/features/events/event_item_test.dart b/app/test/features/events/event_item_test.dart new file mode 100644 index 000000000000..625329d5e212 --- /dev/null +++ b/app/test/features/events/event_item_test.dart @@ -0,0 +1,155 @@ +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/features/bookmarks/providers/bookmarks_provider.dart'; +import 'package:acter/features/datetime/providers/utc_now_provider.dart'; +import 'package:acter/features/events/providers/event_providers.dart'; +import 'package:acter/features/events/providers/event_type_provider.dart'; +import 'package:acter/features/events/utils/events_utils.dart'; +import 'package:acter/features/events/widgets/event_item.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import '../../helpers/mock_event_providers.dart'; +import '../../helpers/test_util.dart'; + +class MockOnTapEventItem extends Mock { + void call(String eventId); +} + +void main() { + late MockEvent mockEvent; + late MockOnTapEventItem mockOnTapEventItem; + late MockUtcNowNotifier mockUtcNowNotifier; + late MockAsyncRsvpStatusNotifier mockAsyncRsvpStatusNotifier; + + setUp(() { + mockEvent = MockEvent(); + mockOnTapEventItem = MockOnTapEventItem(); + mockUtcNowNotifier = MockUtcNowNotifier(); + mockAsyncRsvpStatusNotifier = MockAsyncRsvpStatusNotifier(); + }); + + Future createWidgetUnderTest({ + required WidgetTester tester, + bool isShowRsvp = true, + bool isShowSpaceName = false, + Function(String)? onTapEventItem, + EventFilters eventFilter = EventFilters.upcoming, + }) async { + final mockedNotifier = MockAsyncCalendarEventNotifier(); + await tester.pumpProviderWidget( + overrides: [ + utcNowProvider.overrideWith((ref) => mockUtcNowNotifier), + eventTypeProvider.overrideWith((ref, event) => eventFilter), + calendarEventProvider.overrideWith(() => mockedNotifier), + myRsvpStatusProvider.overrideWith(() => mockAsyncRsvpStatusNotifier), + roomMembershipProvider.overrideWith((a, b) => null), + isBookmarkedProvider.overrideWith((a, b) => false), + roomDisplayNameProvider.overrideWith((a, b) => 'test'), + ], + child: EventItem( + event: mockEvent, + isShowRsvp: isShowRsvp, + isShowSpaceName: isShowSpaceName, + onTapEventItem: onTapEventItem, + ), + ); + } + + testWidgets('displays event title', (tester) async { + await createWidgetUnderTest(tester: tester); + expect(find.text('Fake Event'), findsOneWidget); + }); + + testWidgets('displays "Happening Now" indication for ongoing events', + (tester) async { + await createWidgetUnderTest( + tester: tester, + eventFilter: EventFilters.ongoing, + ); + expect(find.text('Live'), findsOneWidget); + }); + + testWidgets('calls onTapEventItem callback when tapped', (tester) async { + await createWidgetUnderTest( + tester: tester, + onTapEventItem: (mockOnTapEventItem..call('1234')).call, + ); + + await tester.tap(find.byKey(EventItem.eventItemClick)); + await tester.pumpAndSettle(); + + verify(() => mockOnTapEventItem.call('1234')).called(1); + }); + + testWidgets('displays space name when isShowSpaceName is true', + (tester) async { + await createWidgetUnderTest(tester: tester, isShowSpaceName: true); + + expect(find.text('test'), findsOneWidget); + }); + + testWidgets('displays event date and time when isShowSpaceName is false', + (tester) async { + await createWidgetUnderTest(tester: tester, isShowSpaceName: false); + + final expectedDateTime = + '${formatDate(mockEvent)} (${formatTime(mockEvent)})'; + expect(find.text(expectedDateTime), findsOneWidget); + }); + + testWidgets('displays RSVP status icon when RSVP is Yes', (tester) async { + // Arrange: Set up the RSVP status to Yes + + mockAsyncRsvpStatusNotifier = MockAsyncRsvpStatusNotifier(status: 'yes'); + + await createWidgetUnderTest(tester: tester); + + // Act: Trigger a frame + await tester.pumpAndSettle(); + + // Assert: Check if the Yes icon is displayed + expect(find.byIcon(Icons.check_circle), findsOneWidget); + }); + + testWidgets('displays RSVP status icon when RSVP is No', (tester) async { + // Arrange: Set up the RSVP status to No + + mockAsyncRsvpStatusNotifier = MockAsyncRsvpStatusNotifier(status: 'no'); + + await createWidgetUnderTest(tester: tester); + + // Act: Trigger a frame + await tester.pumpAndSettle(); + + // Assert: Check if the No icon is displayed + expect(find.byIcon(Icons.cancel), findsOneWidget); + }); + + testWidgets('displays RSVP status icon when RSVP is Maybe', (tester) async { + // Arrange: Set up the RSVP status to Maybe + + mockAsyncRsvpStatusNotifier = MockAsyncRsvpStatusNotifier(status: 'maybe'); + + await createWidgetUnderTest(tester: tester); + + // Act: Trigger a frame + await tester.pumpAndSettle(); + + // Assert: Check if the Maybe icon is displayed + expect(find.byIcon(Icons.question_mark_rounded), findsOneWidget); + }); + + testWidgets('does not show RSVP status when isShowRsvp is false', + (tester) async { + // Arrange: Prepare the widget with isShowRsvp set to false + await createWidgetUnderTest(tester: tester, isShowRsvp: false); + + // Act: Trigger a frame + await tester.pumpAndSettle(); + + // Assert: Check that no RSVP status icon is displayed + expect(find.byIcon(Icons.check_circle), findsNothing); + expect(find.byIcon(Icons.cancel), findsNothing); + expect(find.byIcon(Icons.question_mark_rounded), findsNothing); + }); +} diff --git a/app/test/features/events/event_list_widget_test.dart b/app/test/features/events/event_list_widget_test.dart new file mode 100644 index 000000000000..1473b00ceaec --- /dev/null +++ b/app/test/features/events/event_list_widget_test.dart @@ -0,0 +1,124 @@ +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/features/bookmarks/providers/bookmarks_provider.dart'; +import 'package:acter/features/datetime/providers/utc_now_provider.dart'; +import 'package:acter/features/events/providers/event_providers.dart'; +import 'package:acter/features/events/providers/event_type_provider.dart'; +import 'package:acter/features/events/widgets/event_list_widget.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; +import '../../helpers/error_helpers.dart'; +import '../../helpers/mock_event_providers.dart'; +import '../../helpers/test_util.dart'; + +void main() { + group('Event List', () { + testWidgets('displays empty state when there are no events', + (tester) async { + //Arrange: + const emptyState = Text('empty state'); + final provider = FutureProvider>((ref) async => []); + + // Build the widget tree with an empty provider + await tester.pumpProviderWidget( + child: EventListWidget( + listProvider: provider, + emptyStateBuilder: () => emptyState, + ), + ); + + // Act + await tester.pumpAndSettle(); // Allow the widget to settle + + // Assert + expect( + find.text('empty state'), + findsOneWidget, + ); // Ensure the empty state widget is displayed + }); + + testWidgets( + 'displays error state when there is issue in loading event list', + (tester) async { + bool shouldFail = true; + + final provider = FutureProvider>((ref) async { + if (shouldFail) { + shouldFail = false; + throw 'Some Error'; + } else { + return []; + } + }); + + // Build the widget tree with the mocked provider + await tester.pumpProviderWidget( + child: EventListWidget( + listProvider: provider, + ), + ); + await tester.ensureErrorPageWithRetryWorks(); + }); + + testWidgets('displays list of event when data is available', + (tester) async { + // Arrange + + const eventFilter = EventFilters.upcoming; + final mockedNotifier = MockAsyncCalendarEventNotifier(); + final mockUtcNowNotifier = MockUtcNowNotifier(); + final mockAsyncRsvpStatusNotifier = MockAsyncRsvpStatusNotifier(); + + final mockEvent1 = MockEvent(fakeEventTitle: 'Fake Event1'); + final mockEvent2 = MockEvent(fakeEventTitle: 'Fake Event2'); + final mockEvent3 = MockEvent(fakeEventTitle: 'Fake Event3'); + + final finalListProvider = FutureProvider>( + (ref) async => [ + mockEvent1, + mockEvent2, + mockEvent3, + ], + ); + + // Build the widget tree with the mocked provider + await tester.pumpProviderWidget( + overrides: [ + utcNowProvider.overrideWith((ref) => mockUtcNowNotifier), + eventTypeProvider.overrideWith((ref, event) => eventFilter), + calendarEventProvider.overrideWith(() => mockedNotifier), + myRsvpStatusProvider.overrideWith(() => mockAsyncRsvpStatusNotifier), + roomMembershipProvider.overrideWith((a, b) => null), + isBookmarkedProvider.overrideWith((a, b) => false), + roomDisplayNameProvider.overrideWith((a, b) => 'test'), + bookmarkedEventListProvider.overrideWith((ref, spaceId) => []), + allEventListProvider.overrideWith((ref, spaceId) => any()), + allOngoingEventListProvider.overrideWith((ref, spaceId) => []), + allUpcomingEventListProvider.overrideWith((ref, spaceId) => []), + allPastEventListProvider.overrideWith((ref, spaceId) => []), + ], + child: EventListWidget( + listProvider: finalListProvider, + ), + ); + // Act + await tester.pumpAndSettle(); // Allow the widget to settle + + // Assert + expect( + find.text('Fake Event1'), + findsOne, + ); + expect( + find.text('Fake Event2'), + findsOne, + ); + expect( + find.text('Fake Event3'), + findsOne, + ); + }); + }); +} diff --git a/app/test/helpers/mock_event_providers.dart b/app/test/helpers/mock_event_providers.dart index fbf453a18ba6..b5ebe5a820ff 100644 --- a/app/test/helpers/mock_event_providers.dart +++ b/app/test/helpers/mock_event_providers.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:acter/features/datetime/providers/notifiers/now_notifier.dart'; import 'package:acter/features/events/providers/notifiers/event_notifiers.dart'; import 'package:acter/features/events/providers/notifiers/rsvp_notifier.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; @@ -29,21 +30,41 @@ class MockAsyncRsvpStatusNotifier extends AutoDisposeFamilyAsyncNotifier with Mock implements AsyncRsvpStatusNotifier { + String? status; + + MockAsyncRsvpStatusNotifier({this.status}); + @override Future build(String arg) async { - return null; + return switch (status) { + 'yes' => RsvpStatusTag.Yes, + 'no' => RsvpStatusTag.No, + 'maybe' => RsvpStatusTag.Maybe, + _ => null, + }; } } class MockEvent extends Fake implements CalendarEvent { + final String fakeEventTitle; + + MockEvent({this.fakeEventTitle = 'Fake Event'}); + + @override + EventId eventId() => MockEventId('eventId'); + @override String roomIdStr() => 'testRoomId'; + @override - String title() => 'Fake Event'; + String title() => fakeEventTitle; + @override TextMessageContent? description() => null; + @override UtcDateTime utcStart() => FakeUtcDateTime(); + @override UtcDateTime utcEnd() => FakeUtcDateTime(); @@ -63,3 +84,13 @@ class FakeUtcDateTime extends Fake implements UtcDateTime { @override int timestampMillis() => 10; } + +class MockUtcNowNotifier extends Mock implements UtcNowNotifier {} + +class MockEventId extends Mock implements EventId { + final String fakeEventId; + + MockEventId(this.fakeEventId); +} + +class MockEventListSearchFilterProvider extends Mock {}