diff --git a/app/lib/features/events/widgets/event_list_widget.dart b/app/lib/features/events/widgets/event_list_widget.dart index 384d4ab2d239..a21c9e1e8d0e 100644 --- a/app/lib/features/events/widgets/event_list_widget.dart +++ b/app/lib/features/events/widgets/event_list_widget.dart @@ -15,6 +15,9 @@ class EventListWidget extends ConsumerWidget { final int? limit; final bool showSectionHeader; final VoidCallback? onClickSectionHeader; + final String? sectionHeaderTitle; + final bool? isShowSeeAllButton; + final bool showSectionBg; final bool shrinkWrap; final bool isShowSpaceName; final Widget Function()? emptyStateBuilder; @@ -26,6 +29,9 @@ class EventListWidget extends ConsumerWidget { required this.listProvider, this.showSectionHeader = false, this.onClickSectionHeader, + this.sectionHeaderTitle, + this.isShowSeeAllButton, + this.showSectionBg = true, this.shrinkWrap = true, this.emptyStateBuilder, }); @@ -75,8 +81,10 @@ class EventListWidget extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ SectionHeader( - title: L10n.of(context).events, - isShowSeeAllButton: count < eventList.length, + title: sectionHeaderTitle ?? L10n.of(context).events, + isShowSeeAllButton: + isShowSeeAllButton ?? count < eventList.length, + showSectionBg: showSectionBg, onTapSeeAll: () => onClickSectionHeader == null ? null : onClickSectionHeader!(), diff --git a/app/lib/features/home/pages/dashboard.dart b/app/lib/features/home/pages/dashboard.dart index 0e799e80a082..5a6a0b09b9c0 100644 --- a/app/lib/features/home/pages/dashboard.dart +++ b/app/lib/features/home/pages/dashboard.dart @@ -2,13 +2,13 @@ import 'dart:io'; import 'package:acter/common/providers/space_providers.dart'; import 'package:acter/common/themes/app_theme.dart'; -import 'package:acter/common/themes/colors/color_scheme.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:acter/common/widgets/user_avatar.dart'; import 'package:acter/features/events/providers/event_providers.dart'; import 'package:acter/features/home/providers/client_providers.dart'; +import 'package:acter/features/home/widgets/features_nav_widget.dart'; import 'package:acter/features/home/widgets/in_dashboard.dart'; import 'package:acter/features/home/widgets/my_events.dart'; import 'package:acter/features/home/widgets/my_spaces_section.dart'; @@ -16,7 +16,6 @@ import 'package:acter/features/home/widgets/my_tasks.dart'; import 'package:acter/features/main/providers/main_providers.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk.dart'; -import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -30,7 +29,6 @@ class Dashboard extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(alwaysClientProvider); - final hasSpaces = ref.watch(hasSpacesProvider); return InDashboard( child: SafeArea( bottom: false, @@ -41,34 +39,7 @@ class Dashboard extends ConsumerWidget { floatingActionButtonAnimator: FloatingActionButtonAnimator.scaling, appBar: _buildDashboardAppBar(context, client), floatingActionButton: manageQuickAddButton(context, ref), - body: Padding( - padding: const EdgeInsets.only( - top: 20, - left: 20, - right: 20, - ), - child: SingleChildScrollView( - child: hasSpaces - ? Column( - children: [ - featuresNav(context), - const SizedBox(height: 20), - const MyEventsSection( - eventFilters: EventFilters.ongoing, - ), - const SizedBox(height: 12), - const MyTasksSection(limit: 5), - const SizedBox(height: 20), - const MyEventsSection( - limit: 3, - eventFilters: EventFilters.upcoming, - ), - const MySpacesSection(limit: 5), - ], - ) - : emptyState(context), - ), - ), + body: _buildDashboardBodyUI(context, ref), ), ), ); @@ -106,92 +77,6 @@ class Dashboard extends ConsumerWidget { ); } - Widget featuresNav(BuildContext context) { - final lang = L10n.of(context); - return Column( - children: [ - const SizedBox(height: 20), - Row( - children: [ - featuresNavItem( - context: context, - title: lang.pins, - iconData: Atlas.pin, - color: pinFeatureColor, - onTap: () => context.pushNamed(Routes.pins.name), - ), - const SizedBox(width: 20), - featuresNavItem( - context: context, - title: lang.events, - iconData: Atlas.calendar_dots, - color: eventFeatureColor, - onTap: () => context.pushNamed(Routes.calendarEvents.name), - ), - ], - ), - const SizedBox(height: 20), - Row( - children: [ - featuresNavItem( - context: context, - title: lang.tasks, - iconData: Atlas.list, - color: taskFeatureColor, - onTap: () => context.pushNamed(Routes.tasks.name), - ), - const SizedBox(width: 20), - featuresNavItem( - context: context, - title: lang.boosts, - iconData: Atlas.megaphone_thin, - color: boastFeatureColor, - onTap: () => context.pushNamed(Routes.updateList.name), - ), - ], - ), - ], - ); - } - - Widget featuresNavItem({ - required BuildContext context, - required String title, - required IconData iconData, - required Color color, - required Function()? onTap, - }) { - return Expanded( - child: InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.all(Radius.circular(16)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: color, - borderRadius: const BorderRadius.all(Radius.circular(100)), - ), - child: Icon( - iconData, - size: 16, - ), - ), - const SizedBox(width: 8), - Text(title), - ], - ), - ), - ), - ); - } - SlotLayout manageQuickAddButton(BuildContext context, WidgetRef ref) { return SlotLayout( config: { @@ -223,6 +108,27 @@ class Dashboard extends ConsumerWidget { ); } + Widget _buildDashboardBodyUI(BuildContext context, WidgetRef ref) { + final hasSpaces = ref.watch(hasSpacesProvider); + return SingleChildScrollView( + child: hasSpaces + ? const Column( + children: [ + FeaturesNavWidget(), + SizedBox(height: 12), + MyEventsSection(eventFilters: EventFilters.ongoing), + MyTasksSection(limit: 5), + MyEventsSection( + limit: 3, + eventFilters: EventFilters.upcoming, + ), + MySpacesSection(limit: 5), + ], + ) + : emptyState(context), + ); + } + Widget emptyState(BuildContext context) { final lang = L10n.of(context); return Center( diff --git a/app/lib/features/home/widgets/features_nav_widget.dart b/app/lib/features/home/widgets/features_nav_widget.dart new file mode 100644 index 000000000000..9d1a33d0c2f5 --- /dev/null +++ b/app/lib/features/home/widgets/features_nav_widget.dart @@ -0,0 +1,100 @@ +import 'package:acter/common/themes/colors/color_scheme.dart'; +import 'package:acter/common/utils/routes.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; + +class FeaturesNavWidget extends StatelessWidget { + const FeaturesNavWidget({super.key}); + + @override + Widget build(BuildContext context) { + final lang = L10n.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + const SizedBox(height: 20), + Row( + children: [ + featuresNavItem( + context: context, + title: lang.pins, + iconData: Atlas.pin, + color: pinFeatureColor, + onTap: () => context.pushNamed(Routes.pins.name), + ), + const SizedBox(width: 20), + featuresNavItem( + context: context, + title: lang.events, + iconData: Atlas.calendar_dots, + color: eventFeatureColor, + onTap: () => context.pushNamed(Routes.calendarEvents.name), + ), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + featuresNavItem( + context: context, + title: lang.tasks, + iconData: Atlas.list, + color: taskFeatureColor, + onTap: () => context.pushNamed(Routes.tasks.name), + ), + const SizedBox(width: 20), + featuresNavItem( + context: context, + title: lang.boosts, + iconData: Atlas.megaphone_thin, + color: boastFeatureColor, + onTap: () => context.pushNamed(Routes.updateList.name), + ), + ], + ), + ], + ), + ); + } + + Widget featuresNavItem({ + required BuildContext context, + required String title, + required IconData iconData, + required Color color, + required Function()? onTap, + }) { + return Expanded( + child: InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(16)), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: color, + borderRadius: const BorderRadius.all(Radius.circular(100)), + ), + child: Icon( + iconData, + size: 16, + ), + ), + const SizedBox(width: 8), + Text(title), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/home/widgets/my_events.dart b/app/lib/features/home/widgets/my_events.dart index 6df3c79d3d0e..553cdd9237a6 100644 --- a/app/lib/features/home/widgets/my_events.dart +++ b/app/lib/features/home/widgets/my_events.dart @@ -1,19 +1,10 @@ -import 'dart:math'; - -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/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_widget.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::home::my_events'); class MyEventsSection extends ConsumerWidget { final int? limit; @@ -28,66 +19,26 @@ class MyEventsSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final lang = L10n.of(context); - //Get my events data - final calEventsLoader = switch (eventFilters) { - EventFilters.ongoing => ref.watch(myOngoingEventListProvider(null)), - _ => ref.watch(myUpcomingEventListProvider(null)), + + //Event Provider + final eventListProvider = switch (eventFilters) { + EventFilters.ongoing => myOngoingEventListProvider(null), + _ => myUpcomingEventListProvider(null), }; + //Section Title Data final sectionTitle = switch (eventFilters) { EventFilters.ongoing => lang.happeningNow, _ => lang.myUpcomingEvents, }; - return calEventsLoader.when( - data: (calEvents) { - if (calEvents.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - sectionHeader(context, sectionTitle), - eventListUI(context, ref, calEvents), - ], - ); - }, - error: (e, s) { - _log.severe('Failed to load cal events', e, s); - return Text(lang.loadingEventsFailed(e)); - }, - loading: () => const EventListSkeleton(), - ); - } - - Widget sectionHeader(BuildContext context, String sectionTitle) { - return Row( - children: [ - Text( - sectionTitle, - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - ActerInlineTextButton( - onPressed: () => context.pushNamed(Routes.calendarEvents.name), - child: Text(L10n.of(context).seeAll), - ), - ], - ); - } - - Widget eventListUI( - BuildContext context, - WidgetRef ref, - List events, - ) { - final count = limit.map((val) => min(val, events.length)) ?? events.length; - return ListView.builder( - shrinkWrap: true, - itemCount: count, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => EventItem( - isShowSpaceName: true, - margin: const EdgeInsets.only(bottom: 14), - event: events[index], - ), + return EventListWidget( + showSectionHeader: true, + sectionHeaderTitle: sectionTitle, + showSectionBg: false, + isShowSeeAllButton: true, + limit: limit, + listProvider: eventListProvider, + onClickSectionHeader: () => context.pushNamed(Routes.calendarEvents.name), ); } } diff --git a/app/lib/features/home/widgets/my_spaces_section.dart b/app/lib/features/home/widgets/my_spaces_section.dart index a77528a41b24..04ba078d71a8 100644 --- a/app/lib/features/home/widgets/my_spaces_section.dart +++ b/app/lib/features/home/widgets/my_spaces_section.dart @@ -3,13 +3,10 @@ import 'dart:math'; import 'package:acter/common/extensions/options.dart'; import 'package:acter/common/providers/space_providers.dart'; import 'package:acter/common/themes/app_theme.dart'; -import 'package:acter/common/toolkit/buttons/inline_text_button.dart'; import 'package:acter/common/toolkit/buttons/primary_action_button.dart'; import 'package:acter/common/tutorial_dialogs/space_overview_tutorials/create_or_join_space_tutorials.dart'; import 'package:acter/common/utils/routes.dart'; -import 'package:acter/common/widgets/room/room_card.dart'; -import 'package:acter/features/home/data/keys.dart'; -import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; +import 'package:acter/features/spaces/widgets/space_list_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -25,115 +22,39 @@ class MySpacesSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final bookmarkedSpaces = ref.watch(bookmarkedSpacesProvider); - final spaces = ref.watch(spacesProvider); + //Common variable declaration final lang = L10n.of(context); - final textTheme = Theme.of(context).textTheme; - if (bookmarkedSpaces.isNotEmpty) { - return _RenderSpacesSection( - spaces: bookmarkedSpaces, - limit: bookmarkedSpaces.length, - showAll: true, - showAllCounter: spaces.length, - title: Text( - lang.bookmarkedSpaces, - style: textTheme.titleSmall, - ), - ); - } - // fallback - if (spaces.isEmpty) { - return const _NoSpacesWidget(); - } + //Get spaces List Data + final allSpacesList = ref.watch(spacesProvider); + final bookmarkedSpacesList = ref.watch(bookmarkedSpacesProvider); - final count = limit.map((val) => min(val, spaces.length)) ?? spaces.length; - return _RenderSpacesSection( - spaces: spaces, - limit: count, - showAll: count != spaces.length, - showActions: count == spaces.length, - showAllCounter: spaces.length, - title: InkWell( - key: DashboardKeys.widgetMySpacesHeader, - onTap: () => context.pushNamed(Routes.spaces.name), - child: Text( - lang.mySpaces, - style: textTheme.titleSmall, - ), - ), - ); - } -} - -class _RenderSpacesSection extends ConsumerWidget { - final int limit; - final List spaces; - final Widget title; - final bool showAll; - final bool showActions; - final int showAllCounter; + //Empty State + if (allSpacesList.isEmpty) return const _NoSpacesWidget(); - const _RenderSpacesSection({ - required this.spaces, - required this.limit, - required this.title, - this.showActions = false, - this.showAll = false, - this.showAllCounter = 0, - }); + //Bookmarked Spaces : If available + if (bookmarkedSpacesList.isNotEmpty) { + return SpaceListWidget( + spaceListProvider: bookmarkedSpacesProvider, + showSectionHeader: true, + isShowSeeAllButton: true, + showSectionBg: false, + sectionHeaderTitle: lang.bookmarkedSpaces, + onClickSectionHeader: () => context.pushNamed(Routes.spaces.name), + ); + } - @override - Widget build(BuildContext context, WidgetRef ref) { - final lang = L10n.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - title, - const Spacer(), - if (showAll) - ActerInlineTextButton( - onPressed: () => context.pushNamed(Routes.spaces.name), - child: Text(lang.seeAll), - ), - ], - ), - const SizedBox(height: 8), - ListView.builder( - shrinkWrap: true, - itemCount: limit, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => RoomCard( - roomId: spaces[index].getRoomIdStr(), - margin: const EdgeInsets.only(bottom: 14), - ), - ), - if (showActions) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - OutlinedButton( - onPressed: () => context.pushNamed(Routes.createSpace.name), - child: Text(lang.createSpace), - ), - const SizedBox(height: 10), - ActerPrimaryActionButton( - onPressed: () { - context.pushNamed(Routes.searchPublicDirectory.name); - }, - child: Text(lang.joinSpace), - ), - ], - ), - ), - ], + //All Spaces : If bookmark list is empty + final count = limit.map((val) => min(val, allSpacesList.length)) ?? + allSpacesList.length; + return SpaceListWidget( + spaceListProvider: spacesProvider, + showSectionHeader: true, + sectionHeaderTitle: lang.mySpaces, + limit: limit, + showSectionBg: false, + isShowSeeAllButton: count != allSpacesList.length, + onClickSectionHeader: () => context.pushNamed(Routes.spaces.name), ); } } @@ -157,61 +78,34 @@ class _NoSpacesWidgetState extends ConsumerState<_NoSpacesWidget> { final lang = L10n.of(context); final textTheme = Theme.of(context).textTheme; return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 15), Text( lang.youAreCurrentlyNotConnectedToAnySpaces, style: textTheme.bodyMedium, + textAlign: TextAlign.center, ), - const SizedBox(height: 30), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: lang.create, - style: textTheme.bodyMedium?.copyWith( - decoration: TextDecoration.underline, - fontWeight: FontWeight.bold, - ), - ), - TextSpan(text: lang.or), - TextSpan( - text: lang.join, - style: textTheme.bodyMedium?.copyWith( - decoration: TextDecoration.underline, - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: ' ', - style: textTheme.bodyMedium, - ), - TextSpan( - text: lang.spaceShortDescription, - style: textTheme.bodyMedium, - ), - ], - ), - softWrap: true, - ), - SizedBox(height: MediaQuery.of(context).size.height * 0.15), - Center( - child: OutlinedButton.icon( - key: createNewSpaceKey, - icon: const Icon(Icons.chevron_right_outlined), - onPressed: () => context.pushNamed(Routes.createSpace.name), - label: Text(lang.createNewSpace), - ), + const SizedBox(height: 6), + Text( + lang.spaceShortDescription, + style: textTheme.labelLarge, + textAlign: TextAlign.center, ), const SizedBox(height: 36), - Center( - child: ActerPrimaryActionButton( - key: joinExistingSpaceKey, - onPressed: () { - context.pushNamed(Routes.searchPublicDirectory.name); - }, - child: Text(lang.joinExistingSpace), - ), + OutlinedButton.icon( + key: createNewSpaceKey, + onPressed: () => context.pushNamed(Routes.createSpace.name), + label: Text(lang.createNewSpace), + ), + const SizedBox(height: 16), + ActerPrimaryActionButton( + key: joinExistingSpaceKey, + onPressed: () { + context.pushNamed(Routes.searchPublicDirectory.name); + }, + child: Text(lang.joinExistingSpace), ), ], ); diff --git a/app/lib/features/home/widgets/my_tasks.dart b/app/lib/features/home/widgets/my_tasks.dart index 92fe38e2dd64..fd3cb1d9bd0a 100644 --- a/app/lib/features/home/widgets/my_tasks.dart +++ b/app/lib/features/home/widgets/my_tasks.dart @@ -1,6 +1,6 @@ -import 'package:acter/common/toolkit/buttons/inline_text_button.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/features/home/providers/task_providers.dart'; +import 'package:acter/features/space/widgets/space_sections/section_header.dart'; import 'package:acter/features/tasks/widgets/task_item.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; @@ -29,8 +29,14 @@ class MyTasksSection extends ConsumerWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - myTaskHeader(context), + SectionHeader( + title: lang.myTasks, + showSectionBg: false, + isShowSeeAllButton: true, + onTapSeeAll: () => context.pushNamed(Routes.tasks.name), + ), ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 20), shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), separatorBuilder: (context, index) => const Divider( @@ -57,21 +63,4 @@ class MyTasksSection extends ConsumerWidget { loading: () => Text(lang.loading), ); } - - Widget myTaskHeader(BuildContext context) { - final lang = L10n.of(context); - return Row( - children: [ - Text( - lang.myTasks, - style: Theme.of(context).textTheme.titleSmall, - ), - const Spacer(), - ActerInlineTextButton( - onPressed: () => context.pushNamed(Routes.tasks.name), - child: Text(lang.seeAll), - ), - ], - ); - } } diff --git a/app/lib/features/search/pages/quick_search_page.dart b/app/lib/features/search/pages/quick_search_page.dart index 0540104e94fa..6e34b7a40889 100644 --- a/app/lib/features/search/pages/quick_search_page.dart +++ b/app/lib/features/search/pages/quick_search_page.dart @@ -6,6 +6,7 @@ import 'package:acter/features/pins/providers/pins_provider.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/providers/space_list_provider.dart'; import 'package:acter/features/spaces/widgets/space_list_widget.dart'; import 'package:acter/features/tasks/providers/tasklists_providers.dart'; import 'package:acter/features/tasks/widgets/task_list_widget.dart'; @@ -128,8 +129,8 @@ class _QuickSearchPageState extends ConsumerState { if (quickSearchFilters.value == QuickSearchFilters.all || quickSearchFilters.value == QuickSearchFilters.spaces) SpaceListWidget( + spaceListProvider: spaceListQuickSearchedProvider, limit: 3, - searchValue: searchValue, showSectionHeader: true, onClickSectionHeader: () => context.pushNamed( Routes.spaces.name, diff --git a/app/lib/features/space/widgets/space_sections/section_header.dart b/app/lib/features/space/widgets/space_sections/section_header.dart index a8a3dfdfbfe7..24c2e891ab65 100644 --- a/app/lib/features/space/widgets/space_sections/section_header.dart +++ b/app/lib/features/space/widgets/space_sections/section_header.dart @@ -4,6 +4,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; class SectionHeader extends StatelessWidget { final String title; + final bool showSectionBg; final bool isShowSeeAllButton; final VoidCallback? onTapSeeAll; @@ -11,6 +12,7 @@ class SectionHeader extends StatelessWidget { super.key, required this.title, this.onTapSeeAll, + this.showSectionBg = true, this.isShowSeeAllButton = false, }); @@ -21,32 +23,39 @@ class SectionHeader extends StatelessWidget { Widget sectionHeaderUI(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final titleMediumTextStyle = + Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).primaryColor, + ); + final titleSmallTextStyle = Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontWeight: FontWeight.normal); return GestureDetector( onTap: onTapSeeAll, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14), - margin: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - colorScheme.surface.withOpacity(0.9), - colorScheme.surface.withOpacity(0.3), - colorScheme.secondaryContainer.withOpacity(0.1), - ], - begin: Alignment.topLeft, - end: Alignment.topRight, - stops: const [0.0, 0.5, 1.0], - tileMode: TileMode.mirror, - ), - ), + margin: showSectionBg ? const EdgeInsets.symmetric(vertical: 12) : null, + decoration: showSectionBg + ? BoxDecoration( + gradient: LinearGradient( + colors: [ + colorScheme.surface.withOpacity(0.9), + colorScheme.surface.withOpacity(0.3), + colorScheme.secondaryContainer.withOpacity(0.1), + ], + begin: Alignment.topLeft, + end: Alignment.topRight, + stops: const [0.0, 0.5, 1.0], + tileMode: TileMode.mirror, + ), + ) + : null, child: Row( children: [ Text( title, - style: Theme.of(context) - .textTheme - .titleMedium - ?.copyWith(color: Theme.of(context).primaryColor), + style: showSectionBg ? titleMediumTextStyle : titleSmallTextStyle, ), const Spacer(), isShowSeeAllButton diff --git a/app/lib/features/spaces/pages/space_list_page.dart b/app/lib/features/spaces/pages/space_list_page.dart index 471ef4cb8c7e..722f2b369b60 100644 --- a/app/lib/features/spaces/pages/space_list_page.dart +++ b/app/lib/features/spaces/pages/space_list_page.dart @@ -1,8 +1,7 @@ -import 'package:acter/common/extensions/options.dart'; -import 'package:acter/common/providers/common_providers.dart'; import 'package:acter/common/utils/routes.dart'; import 'package:acter/common/widgets/acter_search_widget.dart'; import 'package:acter/features/spaces/model/keys.dart'; +import 'package:acter/features/spaces/providers/space_list_provider.dart'; import 'package:acter/features/spaces/widgets/space_list_empty_state.dart'; import 'package:acter/features/spaces/widgets/space_list_widget.dart'; import 'package:atlas_icons/atlas_icons.dart'; @@ -24,15 +23,14 @@ class SpaceListPage extends ConsumerStatefulWidget { } class _AllPinsPageConsumerState extends ConsumerState { - String get searchValue => ref.watch(searchValueProvider); + String get searchValue => ref.watch(spaceListSearchTermProvider); @override void initState() { super.initState(); - widget.searchQuery.map((query) { - WidgetsBinding.instance.addPostFrameCallback((Duration duration) { - ref.read(searchValueProvider.notifier).state = query; - }); + WidgetsBinding.instance.addPostFrameCallback((Duration duration) { + ref.read(spaceListSearchTermProvider.notifier).state = + widget.searchQuery ?? ''; }); } @@ -92,16 +90,16 @@ class _AllPinsPageConsumerState extends ConsumerState { ActerSearchWidget( initialText: widget.searchQuery, onChanged: (value) { - ref.read(searchValueProvider.notifier).state = value; + ref.read(spaceListSearchTermProvider.notifier).state = value; }, onClear: () { - ref.read(searchValueProvider.notifier).state = ''; + ref.read(spaceListSearchTermProvider.notifier).state = ''; }, ), Expanded( child: SpaceListWidget( + spaceListProvider: spaceListSearchProvider, shrinkWrap: false, - searchValue: searchValue, emptyState: SpaceListEmptyState(searchValue: searchValue), ), ), diff --git a/app/lib/features/spaces/providers/space_list_provider.dart b/app/lib/features/spaces/providers/space_list_provider.dart index 09f1e13cb95b..e5b556910e81 100644 --- a/app/lib/features/spaces/providers/space_list_provider.dart +++ b/app/lib/features/spaces/providers/space_list_provider.dart @@ -1,8 +1,12 @@ import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/providers/space_providers.dart'; +import 'package:acter/features/search/providers/quick_search_providers.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +//Search Value provider for space list +final spaceListSearchTermProvider = StateProvider((ref) => ''); + final subSpacesListProvider = FutureProvider.family, String>((ref, spaceId) async { List subSpacesList = []; @@ -22,21 +26,40 @@ final subSpacesListProvider = return subSpacesList; }); -final spaceListSearchProvider = FutureProvider.autoDispose - .family, String>((ref, searchText) async { +final allSpaceListWithBookmarkFirstProvider = + Provider.autoDispose>((ref) { final bookmarkedSpaceList = ref.watch(bookmarkedSpacesProvider); final othersSpaceList = ref.watch(unbookmarkedSpacesProvider); final spaceList = bookmarkedSpaceList.followedBy(othersSpaceList); + return spaceList.toList(); +}); + +List _filterByTerm(Ref ref, List spaceList, String searchValue) => + spaceList.where((space) { + final roomId = space.getRoomIdStr(); + final spaceInfo = ref.watch(roomAvatarInfoProvider(roomId)); + final spaceName = spaceInfo.displayName ?? roomId; + return spaceName.toLowerCase().contains(searchValue); + }).toList(); + +final spaceListSearchProvider = Provider.autoDispose>((ref) { + final spaceList = ref.watch(allSpaceListWithBookmarkFirstProvider); + final searchTerm = + ref.watch(spaceListSearchTermProvider).trim().toLowerCase(); + + //Return all spaces if search is empty + final searchValue = searchTerm.trim().toLowerCase(); + if (searchValue.isEmpty) return spaceList; + return _filterByTerm(ref, spaceList, searchValue); +}); + +//Space list for quick search value provider +final spaceListQuickSearchedProvider = Provider.autoDispose>((ref) { + final spaceList = ref.watch(allSpaceListWithBookmarkFirstProvider); + final searchTerm = ref.watch(quickSearchValueProvider).trim().toLowerCase(); //Return all spaces if search is empty - final searchValue = searchText.trim().toLowerCase(); - if (searchValue.isEmpty) return spaceList.toList(); - - //Return all spaces with search criteria - return spaceList.where((space) { - final roomId = space.getRoomIdStr(); - final spaceInfo = ref.watch(roomAvatarInfoProvider(roomId)); - final spaceName = spaceInfo.displayName ?? roomId; - return spaceName.toLowerCase().contains(searchValue); - }).toList(); + final searchValue = searchTerm.trim().toLowerCase(); + if (searchValue.isEmpty) return spaceList; + return _filterByTerm(ref, spaceList, searchValue); }); diff --git a/app/lib/features/spaces/widgets/space_list_widget.dart b/app/lib/features/spaces/widgets/space_list_widget.dart index 861fbee88f93..6cd32ea659d6 100644 --- a/app/lib/features/spaces/widgets/space_list_widget.dart +++ b/app/lib/features/spaces/widgets/space_list_widget.dart @@ -1,65 +1,38 @@ import 'package:acter/common/extensions/options.dart'; -import 'package:acter/common/toolkit/errors/error_page.dart'; import 'package:acter/common/widgets/room/room_card.dart'; import 'package:acter/features/space/widgets/space_sections/section_header.dart'; -import 'package:acter/features/spaces/providers/space_list_provider.dart'; -import 'package:acter/features/spaces/widgets/space_list_skeleton.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::space-list-widget'); class SpaceListWidget extends ConsumerWidget { - final String? searchValue; + final ProviderBase> spaceListProvider; final int? limit; final bool showSectionHeader; final VoidCallback? onClickSectionHeader; + final String? sectionHeaderTitle; + final bool? isShowSeeAllButton; + final bool showSectionBg; final bool shrinkWrap; final Widget emptyState; const SpaceListWidget({ super.key, + required this.spaceListProvider, this.limit, - this.searchValue, this.showSectionHeader = false, this.onClickSectionHeader, + this.sectionHeaderTitle, + this.isShowSeeAllButton, + this.showSectionBg = true, this.shrinkWrap = true, this.emptyState = const SizedBox.shrink(), }); @override Widget build(BuildContext context, WidgetRef ref) { - final spaceLoader = ref.watch(spaceListSearchProvider(searchValue ?? '')); - return spaceLoader.when( - data: (spaceList) => buildSpaceSectionUI(context, spaceList), - error: (error, stack) => spaceListErrorWidget(context, ref, error, stack), - loading: () => const SpaceListSkeleton(), - skipLoadingOnReload: true, - ); - } - - Widget spaceListErrorWidget( - BuildContext context, - WidgetRef ref, - Object error, - StackTrace stack, - ) { - _log.severe('Failed to load spaces', error, stack); - return ErrorPage( - background: const SpaceListSkeleton(), - error: error, - stack: stack, - textBuilder: L10n.of(context).loadingFailed, - onRetryTap: () { - ref.invalidate(spaceListSearchProvider(searchValue ?? '')); - }, - ); - } - - Widget buildSpaceSectionUI(BuildContext context, List spaceList) { + final spaceList = ref.watch(spaceListProvider); if (spaceList.isEmpty) return emptyState; final count = (limit ?? spaceList.length).clamp(0, spaceList.length); @@ -68,8 +41,10 @@ class SpaceListWidget extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ SectionHeader( - title: L10n.of(context).spaces, - isShowSeeAllButton: count < spaceList.length, + showSectionBg: showSectionBg, + title: sectionHeaderTitle ?? L10n.of(context).spaces, + isShowSeeAllButton: + isShowSeeAllButton ?? count < spaceList.length, onTapSeeAll: onClickSectionHeader.map((cb) => () => cb()), ), spaceListUI(spaceList, count), diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index a844d3657ebd..0b2978e0dc61 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -357,8 +357,8 @@ "@deleteNewsDraftTitle": {}, "deleteNewsDraftText": "Are you sure you want to delete this draft? This can’t be undone.", "@deleteNewsDraftText": {}, - "deleteDraftBtn" : "Delete draft", - "@deleteDraftBtn" : {}, + "deleteDraftBtn": "Delete draft", + "@deleteDraftBtn": {}, "deletingPushTarget": "Deleting push target", "@deletingPushTarget": {}, "deletionFailed": "Deletion failed: {error}", @@ -1422,6 +1422,7 @@ "youAreBothIn": "you are both in ", "@youAreBothIn": {}, "youAreCurrentlyNotConnectedToAnySpaces": "You are currently not connected to any spaces", + "spaceShortDescription": "Create or Join a space, to start organizing and collaborating!", "@youAreCurrentlyNotConnectedToAnySpaces": {}, "youAreDoneWithAllYourTasks": "you are done with all your tasks!", "@youAreDoneWithAllYourTasks": {}, @@ -1771,8 +1772,6 @@ "@changeThePowerFromTo": {}, "createOrJoinSpaceDescription": "Create or join a space, to start organizing and collaborating!", "@createOrJoinSpaceDescription": {}, - "spaceShortDescription": "a space, to start organizing and collaborating!", - "@spaceShortDescription": {}, "introPageDescriptionPre": "Acter is more than just an app.\nIt’s", "@introPageDescriptionPre": {}, "introPageDescriptionHl": " community of change makers.", diff --git a/app/test/features/home/my_spaces_section_test.dart b/app/test/features/home/my_spaces_section_test.dart new file mode 100644 index 000000000000..2453042ef2d0 --- /dev/null +++ b/app/test/features/home/my_spaces_section_test.dart @@ -0,0 +1,22 @@ +import 'package:acter/common/providers/space_providers.dart'; +import 'package:acter/common/tutorial_dialogs/space_overview_tutorials/create_or_join_space_tutorials.dart'; +import 'package:acter/features/home/widgets/my_spaces_section.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/mock_space_providers.dart'; +import '../../helpers/test_util.dart'; + +void main() { + group('Home: Spaces Section Tests', () { + testWidgets('Empty renders fine', (tester) async { + await tester.pumpProviderWidget( + overrides: [ + spacesProvider.overrideWith((a) => MockSpaceListNotifiers([])), + bookmarkedSpacesProvider.overrideWith((a) => []), + ], + child: const MySpacesSection(), + ); + expect(find.byKey(joinExistingSpaceKey), findsOneWidget); + }); + }); +} diff --git a/app/test/features/spaces/search_provider_test.dart b/app/test/features/spaces/search_provider_test.dart new file mode 100644 index 000000000000..4c29f3b1c255 --- /dev/null +++ b/app/test/features/spaces/search_provider_test.dart @@ -0,0 +1,100 @@ +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/providers/space_providers.dart'; +import 'package:acter/features/search/providers/quick_search_providers.dart'; +import 'package:acter/features/spaces/providers/space_list_provider.dart'; +import 'package:acter_avatar/acter_avatar.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import '../../helpers/mock_space_providers.dart'; + +void main() { + group('Spaces Search Provider Test', () { + test( + 'Display Name update triggers proper update of search values on quick search', + () async { + final spaces = [ + MockSpace(id: 'a'), + MockSpace(id: 'b'), + MockSpace(id: 'c'), + ]; + final Map spaceInfos = { + 'a': const AvatarInfo(uniqueId: 'a', displayName: 'abc'), + 'b': const AvatarInfo( + uniqueId: 'b', + displayName: null, + ), // not yet loaded + 'c': const AvatarInfo(uniqueId: 'c', displayName: null), + }; + final container = ProviderContainer( + overrides: [ + spacesProvider.overrideWith((a) => MockSpaceListNotifiers(spaces)), + roomAvatarInfoProvider.overrideWith( + () => MockRoomAvatarInfoNotifier(items: spaceInfos), + ), + bookmarkedSpacesProvider.overrideWith((a) => []), + ], + ); + + final all = container.read(spaceListQuickSearchedProvider); + expect(all.length, 3); + + // add a search term + container.read(quickSearchValueProvider.notifier).state = 'a'; + + final onlyOne = container.read(spaceListQuickSearchedProvider); + expect(onlyOne.length, 1); + + // update a space info + spaceInfos['b'] = const AvatarInfo(uniqueId: 'b', displayName: 'abc'); + // we only refresh the inner provider + container.refresh(roomAvatarInfoProvider('b')); + + final itsTwo = container.read(spaceListQuickSearchedProvider); + expect(itsTwo.length, 2); + }); + + test( + 'Display Name update triggers proper update of search values on list search', + () async { + final spaces = [ + MockSpace(id: 'a'), + MockSpace(id: 'b'), + MockSpace(id: 'c'), + ]; + final Map spaceInfos = { + 'a': const AvatarInfo(uniqueId: 'a', displayName: 'abc'), + 'b': const AvatarInfo( + uniqueId: 'b', + displayName: null, + ), // not yet loaded + 'c': const AvatarInfo(uniqueId: 'c', displayName: null), + }; + final container = ProviderContainer( + overrides: [ + spacesProvider.overrideWith((a) => MockSpaceListNotifiers(spaces)), + roomAvatarInfoProvider.overrideWith( + () => MockRoomAvatarInfoNotifier(items: spaceInfos), + ), + bookmarkedSpacesProvider.overrideWith((a) => []), + ], + ); + + final all = container.read(spaceListSearchProvider); + expect(all.length, 3); + + // add a search term + container.read(spaceListSearchTermProvider.notifier).state = 'a'; + + final onlyOne = container.read(spaceListSearchProvider); + expect(onlyOne.length, 1); + + // update a space info + spaceInfos['b'] = const AvatarInfo(uniqueId: 'b', displayName: 'abc'); + // we only refresh the inner provider + container.refresh(roomAvatarInfoProvider('b')); + + final itsTwo = container.read(spaceListSearchProvider); + expect(itsTwo.length, 2); + }); + }); +} diff --git a/app/test/helpers/mock_space_providers.dart b/app/test/helpers/mock_space_providers.dart index c8994ac950c0..cfbe781e44d0 100644 --- a/app/test/helpers/mock_space_providers.dart +++ b/app/test/helpers/mock_space_providers.dart @@ -8,8 +8,12 @@ import 'package:mocktail/mocktail.dart'; class MockRoomAvatarInfoNotifier extends FamilyNotifier with Mock implements RoomAvatarInfoNotifier { + final Map items; + + MockRoomAvatarInfoNotifier({this.items = const {}}); + @override - AvatarInfo build(arg) => AvatarInfo(uniqueId: arg); + AvatarInfo build(arg) => items[arg] ?? AvatarInfo(uniqueId: arg); } class RetryMockAsyncSpaceNotifier extends FamilyAsyncNotifier @@ -28,7 +32,21 @@ class RetryMockAsyncSpaceNotifier extends FamilyAsyncNotifier } } -class MockSpace extends Fake implements Space {} +class MockSpace extends Fake implements Space { + final String id; + final bool bookmarked; + + MockSpace({ + this.id = 'id', + this.bookmarked = false, + }); + + @override + String getRoomIdStr() => id; + + @override + bool isBookmarked() => bookmarked; +} class MockSpaceHierarchyRoomInfo extends Fake implements SpaceHierarchyRoomInfo { @@ -61,3 +79,9 @@ class MockSpaceHierarchyRoomInfo extends Fake @override bool suggested() => isSuggested; } + +class MockSpaceListNotifiers extends StateNotifier> + with Mock + implements SpaceListNotifier { + MockSpaceListNotifiers(super.spaces); +}