diff --git a/lib/advert/ui/pages/main_page/main_page.dart b/lib/advert/ui/pages/main_page/main_page.dart index 6901e97154..c6abf24763 100644 --- a/lib/advert/ui/pages/main_page/main_page.dart +++ b/lib/advert/ui/pages/main_page/main_page.dart @@ -1,7 +1,9 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:heroicons/heroicons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:titan/admin/providers/is_admin_provider.dart'; import 'package:titan/advert/providers/advert_list_provider.dart'; import 'package:titan/advert/providers/advert_posters_provider.dart'; @@ -12,9 +14,10 @@ import 'package:titan/advert/router.dart'; import 'package:titan/advert/ui/components/association_bar.dart'; import 'package:titan/advert/ui/pages/main_page/advert_card.dart'; import 'package:titan/feed/providers/is_user_a_member_of_an_association.dart'; +import 'package:titan/navigation/providers/navbar_visibility_provider.dart'; import 'package:titan/tools/constants.dart'; +import 'package:titan/tools/providers/path_forwarding_provider.dart'; import 'package:titan/tools/ui/builders/async_child.dart'; -import 'package:titan/tools/ui/layouts/refresher.dart'; import 'package:qlevar_router/qlevar_router.dart'; class AdvertMainPage extends HookConsumerWidget { @@ -29,77 +32,154 @@ class AdvertMainPage extends HookConsumerWidget { final selectedNotifier = ref.watch(selectedAssociationProvider.notifier); final isAdvertAdmin = ref.watch(isUserAMemberOfAnAssociationProvider); final isAdmin = ref.watch(isAdminProvider); + final pathForwarding = ref.watch(pathForwardingProvider); + final pathForwardingNotifier = ref.watch(pathForwardingProvider.notifier); + final advertId = pathForwarding.queryParameters?['advertId']; + final navbarVisibilityNotifier = ref.watch( + navbarVisibilityProvider.notifier, + ); + + final itemScrollController = ItemScrollController(); + final itemPositionsListener = useMemoized( + () => ItemPositionsListener.create(), + ); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + pathForwardingNotifier.clearParams(); + }); + return null; + }, const []); + return AdvertTemplate( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: const AssociationBar( - useUserAssociations: false, - multipleSelect: true, + child: RefreshIndicator( + color: ColorConstants.main, + onRefresh: () async { + await advertListNotifier.loadAdverts(); + advertPostersNotifier.resetTData(); + }, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: const AssociationBar( + useUserAssociations: false, + multipleSelect: true, + ), ), - ), - if (isAdmin || isAdvertAdmin) ...[ - SizedBox(width: 5), - Container( - width: 2, - height: 60, - color: ColorConstants.secondary, - ), - SizedBox(width: 5), - SpecialActionButton( - onTap: () { - selectedNotifier.clearAssociation(); - QR.to(AdvertRouter.root + AdvertRouter.admin); - }, - icon: HeroIcon( - HeroIcons.userGroup, - color: ColorConstants.background, + if (isAdmin || isAdvertAdmin) ...[ + SizedBox(width: 5), + Container( + width: 2, + height: 60, + color: ColorConstants.secondary, ), - name: "Admin", - ), - SizedBox(width: 10), + SizedBox(width: 5), + SpecialActionButton( + onTap: () { + selectedNotifier.clearAssociation(); + QR.to(AdvertRouter.root + AdvertRouter.admin); + }, + icon: HeroIcon( + HeroIcons.userGroup, + color: ColorConstants.background, + ), + name: "Admin", + ), + SizedBox(width: 10), + ], ], - ], - ), + ), - const SizedBox(height: 20), + const SizedBox(height: 20), - Expanded( - child: AsyncChild( - value: advertList, - builder: (context, advertData) { - final sortedAdvertData = advertData - .sortedBy((element) => element.date) - .reversed; - final filteredSortedAdvertData = sortedAdvertData.where( - (advert) => - selected - .where((e) => advert.associationId == e.id) - .isNotEmpty || - selected.isEmpty, - ); - return Refresher( - controller: ScrollController(), - onRefresh: () async { - await advertListNotifier.loadAdverts(); - advertPostersNotifier.resetTData(); - }, - child: Column( - children: [ - ...filteredSortedAdvertData.map( - (advert) => AdvertCard(advert: advert), - ), - SizedBox(height: 80), - ], - ), - ); - }, + Expanded( + child: AsyncChild( + value: advertList, + builder: (context, adverts) { + return HookBuilder( + builder: (context) { + final sortedAdvertData = adverts + .sortedBy((element) => element.date) + .reversed; + final filteredSortedAdvertData = sortedAdvertData + .where( + (advert) => + selected + .where((e) => advert.associationId == e.id) + .isNotEmpty || + selected.isEmpty, + ) + .toList(); + + final advertIndex = filteredSortedAdvertData.indexWhere( + (advert) => advert.id == advertId, + ); + final lastFirstIndex = useRef(null); + if (advertIndex != -1) { + useEffect(() { + Future.microtask(() async { + if (itemScrollController.isAttached) { + await itemScrollController.scrollTo( + index: advertIndex, + duration: const Duration(milliseconds: 300), + ); + navbarVisibilityNotifier.show(); + } + }); + void listener() { + final positions = + itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + final visiblePositions = positions.where( + (p) => + p.itemLeadingEdge >= 0 && + p.itemLeadingEdge <= 1, + ); + + if (visiblePositions.isEmpty) return; + + final firstVisible = visiblePositions.fold( + 999999, + (prev, e) => e.index < prev ? e.index : prev, + ); + + final lastIndex = lastFirstIndex.value; + + if (lastIndex != null) { + if (firstVisible > lastIndex) { + navbarVisibilityNotifier.hide(); + } else if (firstVisible < lastIndex) { + navbarVisibilityNotifier.show(); + } + } + + lastFirstIndex.value = firstVisible; + } + + itemPositionsListener.itemPositions.addListener( + listener, + ); + return () => itemPositionsListener.itemPositions + .removeListener(listener); + }, []); + } + return ScrollablePositionedList.builder( + itemCount: filteredSortedAdvertData.length, + itemBuilder: (context, index) => + AdvertCard(advert: filteredSortedAdvertData[index]), + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); + }, + ); + }, + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/feed/class/news.dart b/lib/feed/class/news.dart index a7d8176007..e1a7a11832 100644 --- a/lib/feed/class/news.dart +++ b/lib/feed/class/news.dart @@ -12,6 +12,7 @@ class News { final String module; final String moduleObjectId; final NewsStatus status; + final DateTime? displayDate; const News({ required this.id, @@ -24,6 +25,7 @@ class News { required this.module, required this.moduleObjectId, required this.status, + this.displayDate, }); News.fromJson(Map json) @@ -38,7 +40,8 @@ class News { : null, module = json['module'], moduleObjectId = json['module_object_id'], - status = stringToNewsStatus(json['status']); + status = stringToNewsStatus(json['status']), + displayDate = null; Map toJson() { return { @@ -68,6 +71,7 @@ class News { String? module, String? moduleObjectId, NewsStatus? status, + DateTime? displayDate, }) { return News( id: id ?? this.id, @@ -80,12 +84,13 @@ class News { module: module ?? this.module, moduleObjectId: moduleObjectId ?? this.moduleObjectId, status: status ?? this.status, + displayDate: displayDate ?? this.displayDate, ); } @override String toString() { - return 'News(id: $id, title: $title, start: $start, end: $end, entity: $entity, location: $location, actionStart: $actionStart, module: $module, moduleObjectId: $moduleObjectId, status: $status)'; + return 'News(id: $id, title: $title, start: $start, end: $end, entity: $entity, location: $location, actionStart: $actionStart, module: $module, moduleObjectId: $moduleObjectId, status: $status, displayDate: $displayDate)'; } News.empty() @@ -98,5 +103,6 @@ class News { actionStart = null, module = '', moduleObjectId = '', - status = NewsStatus.waitingApproval; + status = NewsStatus.waitingApproval, + displayDate = null; } diff --git a/lib/feed/ui/pages/main_page/event_card.dart b/lib/feed/ui/pages/main_page/event_card.dart index 586470c624..d6e8990e2b 100644 --- a/lib/feed/ui/pages/main_page/event_card.dart +++ b/lib/feed/ui/pages/main_page/event_card.dart @@ -29,7 +29,10 @@ class EventCard extends ConsumerWidget { return GestureDetector( onTap: () { if (item.module == "advert") { - pathForwardingNotifier.forward(AdvertRouter.root); + pathForwardingNotifier.forward( + AdvertRouter.root, + queryParameters: {'advertId': item.moduleObjectId}, + ); QR.to(AdvertRouter.root); } }, diff --git a/lib/feed/ui/pages/main_page/feed_timeline.dart b/lib/feed/ui/pages/main_page/feed_timeline.dart deleted file mode 100644 index 2db88064d5..0000000000 --- a/lib/feed/ui/pages/main_page/feed_timeline.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:titan/feed/class/news.dart'; -import 'package:titan/feed/ui/pages/main_page/time_line_item.dart'; - -class FeedTimeline extends StatelessWidget { - final List items; - final Function(News item)? onItemTap; - final bool isAdmin; - - const FeedTimeline({ - super.key, - required this.items, - this.onItemTap, - required this.isAdmin, - }); - - @override - Widget build(BuildContext context) { - items.sort((a, b) { - if (a.start == b.start) { - if (a.end == null && b.end == null) return 0; - if (a.end == null) return -1; - if (b.end == null) return 1; - return a.end!.compareTo(b.end!); - } - return a.start.compareTo(b.start); - }); - return Column( - children: [ - ...items.map( - (item) => TimelineItem( - item: item, - onTap: onItemTap != null ? () => onItemTap!(item) : null, - ), - ), - SizedBox(height: 80), - ], - ); - } -} diff --git a/lib/feed/ui/pages/main_page/main_page.dart b/lib/feed/ui/pages/main_page/main_page.dart index 3077d34d8e..9574a4f2c5 100644 --- a/lib/feed/ui/pages/main_page/main_page.dart +++ b/lib/feed/ui/pages/main_page/main_page.dart @@ -2,24 +2,17 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:heroicons/heroicons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:qlevar_router/qlevar_router.dart'; -import 'package:titan/admin/providers/my_association_list_provider.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:titan/feed/class/news.dart'; -import 'package:titan/feed/providers/association_event_list_provider.dart'; -import 'package:titan/feed/providers/is_feed_admin_provider.dart'; -import 'package:titan/feed/providers/is_user_a_member_of_an_association.dart'; import 'package:titan/feed/providers/news_list_provider.dart'; -import 'package:titan/feed/router.dart'; import 'package:titan/feed/ui/feed.dart'; -import 'package:titan/feed/ui/pages/main_page/feed_timeline.dart'; -import 'package:titan/feed/ui/pages/main_page/filter_news.dart'; -import 'package:titan/feed/ui/pages/main_page/scroll_with_refresh_button.dart'; +import 'package:titan/feed/ui/pages/main_page/dotted_vertical_line.dart'; +import 'package:titan/feed/ui/pages/main_page/main_page_row.dart'; +import 'package:titan/feed/ui/pages/main_page/timeline_item.dart'; import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/navigation/providers/navbar_visibility_provider.dart'; import 'package:titan/tools/constants.dart'; import 'package:titan/tools/ui/builders/async_child.dart'; -import 'package:titan/tools/ui/styleguide/bottom_modal_template.dart'; -import 'package:titan/tools/ui/styleguide/button.dart'; -import 'package:titan/tools/ui/styleguide/icon_button.dart'; class FeedMainPage extends HookConsumerWidget { const FeedMainPage({super.key}); @@ -28,63 +21,44 @@ class FeedMainPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final news = ref.watch(newsListProvider); final newsListNotifier = ref.watch(newsListProvider.notifier); - final isUserAMemberOfAnAssociation = ref.watch( - isUserAMemberOfAnAssociationProvider, + final navbarVisibilityNotifier = ref.watch( + navbarVisibilityProvider.notifier, ); - final isFeedAdmin = ref.watch(isFeedAdminProvider); - final scrollController = useScrollController(); - final associationEventsListNotifier = ref.watch( - associationEventsListProvider.notifier, - ); - final myAssociations = ref.watch(myAssociationListProvider); - + final showRefreshButton = useState(false); final localizeWithContext = AppLocalizations.of(context)!; + final itemScrollController = useMemoized(() => ItemScrollController()); + final itemPositionsListener = useMemoized( + () => ItemPositionsListener.create(), + ); + final lastFirstIndex = useRef(null); + Future onRefresh() async { await newsListNotifier.loadNewsList(); } - useEffect(() { - if (news.hasValue && news.value!.isNotEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { - final now = DateTime.now(); - final newsList = news.value!; - - newsList.sort((a, b) { - if (a.start == b.start) { - if (a.end == null && b.end == null) return 0; - if (a.end == null) return -1; - if (b.end == null) return 1; - return a.end!.compareTo(b.end!); - } - return a.start.compareTo(b.start); - }); - - final upcomingIndex = newsList.indexWhere( - (item) => - item.start.isAfter(now) || - (item.end != null && item.end!.isAfter(now)), - ); - - if (upcomingIndex != -1 && scrollController.hasClients) { - double scrollPosition = 0.0; - for (int i = 0; i < upcomingIndex; i++) { - final currentItem = newsList[i]; - - final itemHeight = - (currentItem.actionStart != null || - isUserAMemberOfAnAssociation) - ? 200.0 - : 160.0; - scrollPosition += itemHeight; - } - - scrollController.jumpTo(scrollPosition); - } - }); + List withDisplayDates(List news) { + final result = []; + + DateTime? lastStart; + for (final item in news) { + if (lastStart == null || item.start != lastStart) { + result.add(item.copyWith(displayDate: item.start)); + lastStart = item.start; + } else { + result.add(item.copyWith(displayDate: null)); + } } - return null; - }, [news]); + + return result; + } + + final now = DateTime.now(); + + Future handleRefresh() async { + showRefreshButton.value = false; + await onRefresh(); + } return FeedTemplate( child: Stack( @@ -95,151 +69,226 @@ class FeedMainPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 5), - - Row( - children: [ - Text( - localizeWithContext.feedNews, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: ColorConstants.title, - ), - ), - Spacer(), - IconButton( - icon: HeroIcon( - HeroIcons.adjustmentsHorizontal, - color: ColorConstants.tertiary, - size: 20, - ), - onPressed: () async { - final syncNews = newsListNotifier.allNews.maybeWhen( - orElse: () => [], - data: (loaded) => loaded, - ); - final entities = syncNews - .map((e) => e.entity) - .toSet() - .toList(); - final modules = syncNews - .map((e) => e.module) - .toSet() - .toList(); - await showCustomBottomModal( - modal: FilterNewsModal( - entities: entities, - modules: modules, - ), - context: context, - ref: ref, - ); - }, - splashRadius: 20, - ), - if (isUserAMemberOfAnAssociation || isFeedAdmin) - CustomIconButton( - icon: HeroIcon( - !isFeedAdmin && isUserAMemberOfAnAssociation - ? HeroIcons.pencil - : HeroIcons.userGroup, - color: ColorConstants.background, - ), - onPressed: () { - if (isFeedAdmin && !isUserAMemberOfAnAssociation) { - QR.to(FeedRouter.root + FeedRouter.eventHandling); - } else { - showCustomBottomModal( - modal: BottomModalTemplate( - title: localizeWithContext.feedAdmin, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Button( - text: - localizeWithContext.feedCreateAnEvent, - onPressed: () { - Navigator.of(context).pop(); - QR.to( - FeedRouter.root + - FeedRouter.addEditEvent, - ); - }, - ), - const SizedBox(height: 20), - Button( - text: localizeWithContext - .feedManageAssociationEvents, - onPressed: () { - Navigator.of(context).pop(); - associationEventsListNotifier - .loadAssociationEventList( - myAssociations.first.id, - ); - QR.to( - FeedRouter.root + - FeedRouter.associationEvents, - ); - }, - ), - const SizedBox(height: 20), - if (isFeedAdmin) - Button( - text: localizeWithContext - .feedManageRequests, - onPressed: () { - Navigator.of(context).pop(); - newsListNotifier.loadNewsList(); - QR.to( - FeedRouter.root + - FeedRouter.eventHandling, - ); - }, - ), - ], - ), - ), - context: context, - ref: ref, - ); - } - }, - ), - ], - ), - + MainPageRow(), const SizedBox(height: 10), Expanded( - child: SingleChildScrollView( - controller: scrollController, - physics: const BouncingScrollPhysics(), - child: AsyncChild( - value: news, - builder: (context, news) => news.isEmpty - ? Center( - child: Text( - localizeWithContext.feedNoNewsAvailable, - style: TextStyle( - fontSize: 16, - color: ColorConstants.tertiary, - ), + child: AsyncChild( + value: news, + builder: (context, news) => news.isEmpty + ? Center( + child: Text( + localizeWithContext.feedNoNewsAvailable, + style: TextStyle( + fontSize: 16, + color: ColorConstants.tertiary, ), - ) - : FeedTimeline( - isAdmin: isFeedAdmin, - items: news, - onItemTap: (item) {}, ), - ), + ) + : HookBuilder( + builder: (context) { + final pastNews = withDisplayDates( + news + .where( + (item) => + item.end != null && + item.end!.isBefore(now) || + item.end == null && + item.start.isBefore(now), + ) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)), + ); + + final ongoingNews = + news + .where( + (item) => + item.start.isBefore(now) && + (item.end != null && + item.end!.isAfter(now)), + ) + .toList() + ..sort( + (a, b) => a.start.compareTo(b.start), + ); + + if (ongoingNews.isNotEmpty) { + ongoingNews[0] = ongoingNews[0].copyWith( + displayDate: now, + ); + } + + final futureNews = withDisplayDates( + news + .where((item) => item.start.isAfter(now)) + .toList() + ..sort((a, b) => a.start.compareTo(b.start)), + ); + + final sortedNews = [ + ...pastNews, + ...ongoingNews, + ...futureNews, + ]; + + useEffect(() { + Future.microtask(() async { + final now = DateTime.now(); + + final upcomingIndex = sortedNews.indexWhere( + (item) => + item.start.isAfter(now) || + (item.end != null && + item.end!.isAfter(now)), + ); + + if (upcomingIndex != -1 && + itemScrollController.isAttached) { + await itemScrollController.scrollTo( + index: upcomingIndex, + duration: const Duration( + milliseconds: 300, + ), + curve: Curves.easeInOut, + ); + navbarVisibilityNotifier.show(); + } + }); + void listener() { + final positions = + itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + final visiblePositions = positions.where( + (p) => + p.itemLeadingEdge >= 0 && + p.itemLeadingEdge <= 1, + ); + + if (visiblePositions.isEmpty) return; + + final firstVisible = visiblePositions + .fold( + 999999, + (prev, e) => + e.index < prev ? e.index : prev, + ); + + final lastIndex = lastFirstIndex.value; + + if (lastIndex != null) { + if (firstVisible > lastIndex) { + navbarVisibilityNotifier.hide(); + showRefreshButton.value = false; + } else if (firstVisible < lastIndex) { + navbarVisibilityNotifier.show(); + showRefreshButton.value = true; + } + } + + lastFirstIndex.value = firstVisible; + } + + itemPositionsListener.itemPositions.addListener( + listener, + ); + return () => itemPositionsListener.itemPositions + .removeListener(listener); + }, [news]); + return Column( + children: [ + Expanded( + child: ScrollablePositionedList.builder( + itemCount: news.length + 1, + itemScrollController: + itemScrollController, + itemPositionsListener: + itemPositionsListener, + itemBuilder: (context, index) { + if (index == news.length) { + return const SizedBox(height: 80); + } + return LayoutBuilder( + builder: (context, constraints) { + return Stack( + children: [ + Positioned( + left: 20, + top: 0, + bottom: 0, + child: DottedVerticalLine(), + ), + TimeLineItem( + item: sortedNews[index], + ), + ], + ); + }, + ); + }, + ), + ), + ], + ); + }, + ), ), ), ], ), ), - ScrollWithRefreshButton( - controller: scrollController, - onRefresh: onRefresh, + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + top: showRefreshButton.value ? 10 : -10, + left: 0, + right: 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + opacity: showRefreshButton.value ? 1.0 : 0.0, + child: Center( + child: GestureDetector( + onTap: handleRefresh, + child: Container( + decoration: BoxDecoration( + color: ColorConstants.main, + borderRadius: BorderRadius.circular(25), + boxShadow: [ + BoxShadow( + color: ColorConstants.onMain.withValues(alpha: 0.4), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ], + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + HeroIcon( + HeroIcons.arrowPath, + size: 16, + color: ColorConstants.background, + ), + const SizedBox(width: 8), + Text( + localizeWithContext.feedRefresh, + style: TextStyle( + color: ColorConstants.background, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ), ), ], ), diff --git a/lib/feed/ui/pages/main_page/main_page_row.dart b/lib/feed/ui/pages/main_page/main_page_row.dart new file mode 100644 index 0000000000..3b10f987f5 --- /dev/null +++ b/lib/feed/ui/pages/main_page/main_page_row.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:heroicons/heroicons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:qlevar_router/qlevar_router.dart'; +import 'package:titan/admin/providers/my_association_list_provider.dart'; +import 'package:titan/feed/class/news.dart'; +import 'package:titan/feed/providers/association_event_list_provider.dart'; +import 'package:titan/feed/providers/is_feed_admin_provider.dart'; +import 'package:titan/feed/providers/is_user_a_member_of_an_association.dart'; +import 'package:titan/feed/providers/news_list_provider.dart'; +import 'package:titan/feed/router.dart'; +import 'package:titan/feed/ui/pages/main_page/filter_news.dart'; +import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/tools/constants.dart'; +import 'package:titan/tools/ui/styleguide/bottom_modal_template.dart'; +import 'package:titan/tools/ui/styleguide/button.dart'; +import 'package:titan/tools/ui/styleguide/icon_button.dart'; + +class MainPageRow extends HookConsumerWidget { + const MainPageRow({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final localizeWithContext = AppLocalizations.of(context)!; + final newsListNotifier = ref.watch(newsListProvider.notifier); + final isUserAMemberOfAnAssociation = ref.watch( + isUserAMemberOfAnAssociationProvider, + ); + final isFeedAdmin = ref.watch(isFeedAdminProvider); + final associationEventsListNotifier = ref.watch( + associationEventsListProvider.notifier, + ); + final myAssociations = ref.watch(myAssociationListProvider); + return Row( + children: [ + Text( + localizeWithContext.feedNews, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: ColorConstants.title, + ), + ), + Spacer(), + IconButton( + icon: HeroIcon( + HeroIcons.adjustmentsHorizontal, + color: ColorConstants.tertiary, + size: 20, + ), + onPressed: () async { + final syncNews = newsListNotifier.allNews.maybeWhen( + orElse: () => [], + data: (loaded) => loaded, + ); + final entities = syncNews.map((e) => e.entity).toSet().toList(); + final modules = syncNews.map((e) => e.module).toSet().toList(); + await showCustomBottomModal( + modal: FilterNewsModal(entities: entities, modules: modules), + context: context, + ref: ref, + ); + }, + splashRadius: 20, + ), + if (isUserAMemberOfAnAssociation || isFeedAdmin) + CustomIconButton( + icon: HeroIcon( + !isFeedAdmin && isUserAMemberOfAnAssociation + ? HeroIcons.pencil + : HeroIcons.userGroup, + color: ColorConstants.background, + ), + onPressed: () { + if (isFeedAdmin && !isUserAMemberOfAnAssociation) { + QR.to(FeedRouter.root + FeedRouter.eventHandling); + } else { + showCustomBottomModal( + modal: BottomModalTemplate( + title: localizeWithContext.feedAdmin, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Button( + text: localizeWithContext.feedCreateAnEvent, + onPressed: () { + Navigator.of(context).pop(); + QR.to(FeedRouter.root + FeedRouter.addEditEvent); + }, + ), + const SizedBox(height: 20), + Button( + text: localizeWithContext.feedManageAssociationEvents, + onPressed: () { + Navigator.of(context).pop(); + associationEventsListNotifier + .loadAssociationEventList( + myAssociations.first.id, + ); + QR.to( + FeedRouter.root + FeedRouter.associationEvents, + ); + }, + ), + const SizedBox(height: 20), + if (isFeedAdmin) + Button( + text: localizeWithContext.feedManageRequests, + onPressed: () { + Navigator.of(context).pop(); + newsListNotifier.loadNewsList(); + QR.to(FeedRouter.root + FeedRouter.eventHandling); + }, + ), + ], + ), + ), + context: context, + ref: ref, + ); + } + }, + ), + ], + ); + } +} diff --git a/lib/feed/ui/pages/main_page/scroll_with_refresh_button.dart b/lib/feed/ui/pages/main_page/scroll_with_refresh_button.dart deleted file mode 100644 index 5086312d92..0000000000 --- a/lib/feed/ui/pages/main_page/scroll_with_refresh_button.dart +++ /dev/null @@ -1,151 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:heroicons/heroicons.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:titan/l10n/app_localizations.dart'; -import 'package:titan/navigation/providers/navbar_visibility_provider.dart'; -import 'package:titan/tools/constants.dart'; - -class ScrollWithRefreshButton extends HookConsumerWidget { - final ScrollController controller; - final Future Function() onRefresh; - - const ScrollWithRefreshButton({ - super.key, - required this.controller, - required this.onRefresh, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final showRefreshButton = useState(false); - final lastScrollPosition = useState(0.0); - final hasScrolledEnough = useState(false); - - final lastUserScrollTime = useState(DateTime.now()); - final consecutiveUpwardScrolls = useState(0); - - final localizeWithContext = AppLocalizations.of(context)!; - - useEffect(() { - void scrollListener() { - if (!controller.hasClients) return; - - final navbarVisibilityNotifier = ref.read( - navbarVisibilityProvider.notifier, - ); - final position = controller.position; - final currentScrollPosition = position.pixels; - final scrollDirection = - currentScrollPosition - lastScrollPosition.value; - final maxScrollExtent = position.maxScrollExtent; - - if (currentScrollPosition <= 0) { - navbarVisibilityNotifier.show(); - } else if (currentScrollPosition >= maxScrollExtent) { - } else if (scrollDirection > 0) { - navbarVisibilityNotifier.hide(); - } else if (scrollDirection < 0) { - navbarVisibilityNotifier.show(); - } - - final now = DateTime.now(); - - if (scrollDirection.abs() < 3) return; - - final isAtTop = currentScrollPosition <= position.minScrollExtent; - final isAtBottom = currentScrollPosition >= position.maxScrollExtent; - final isInBounceZone = isAtTop || isAtBottom; - - if (currentScrollPosition > 200 && !hasScrolledEnough.value) { - hasScrolledEnough.value = true; - } - - if (scrollDirection < -15) { - final timeSinceLastScroll = now - .difference(lastUserScrollTime.value) - .inMilliseconds; - - if (!isInBounceZone && timeSinceLastScroll > 50) { - consecutiveUpwardScrolls.value++; - lastUserScrollTime.value = now; - - if (hasScrolledEnough.value && - consecutiveUpwardScrolls.value >= 2 && - !showRefreshButton.value) { - showRefreshButton.value = true; - } - } - } else if (scrollDirection > 5) { - consecutiveUpwardScrolls.value = 0; - lastUserScrollTime.value = now; - - if (showRefreshButton.value && currentScrollPosition > 50) { - showRefreshButton.value = false; - } - } - - lastScrollPosition.value = currentScrollPosition; - } - - controller.addListener(scrollListener); - return () => controller.removeListener(scrollListener); - }, []); - - Future handleRefresh() async { - showRefreshButton.value = false; - await onRefresh(); - } - - return AnimatedPositioned( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - top: showRefreshButton.value ? 10 : -10, - left: 0, - right: 0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - opacity: showRefreshButton.value ? 1.0 : 0.0, - child: Center( - child: GestureDetector( - onTap: handleRefresh, - child: Container( - decoration: BoxDecoration( - color: ColorConstants.main, - borderRadius: BorderRadius.circular(25), - boxShadow: [ - BoxShadow( - color: ColorConstants.onMain.withValues(alpha: 0.4), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ], - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - HeroIcon( - HeroIcons.arrowPath, - size: 16, - color: ColorConstants.background, - ), - const SizedBox(width: 8), - Text( - localizeWithContext.feedRefresh, - style: TextStyle( - color: ColorConstants.background, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/feed/ui/pages/main_page/time_line_item.dart b/lib/feed/ui/pages/main_page/time_line_item.dart deleted file mode 100644 index 95a786046c..0000000000 --- a/lib/feed/ui/pages/main_page/time_line_item.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:intl/intl.dart'; -import 'package:titan/feed/class/news.dart'; -import 'package:titan/feed/tools/news_helper.dart'; -import 'package:titan/feed/ui/pages/main_page/event_action.dart'; -import 'package:titan/feed/ui/pages/main_page/event_card.dart'; -import 'package:titan/feed/ui/widgets/event_card_text_content.dart'; -import 'package:titan/l10n/app_localizations.dart'; -import 'package:titan/tools/constants.dart'; -import 'package:titan/feed/ui/pages/main_page/dotted_vertical_line.dart'; - -class TimelineItem extends ConsumerWidget { - final News item; - final VoidCallback? onTap; - - const TimelineItem({super.key, required this.item, this.onTap}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final locale = Localizations.localeOf(context); - final localizeWithContext = AppLocalizations.of(context)!; - - return LayoutBuilder( - builder: (context, constraints) { - final eventCardWidth = constraints.maxWidth - 70; - final eventCardHeight = eventCardWidth / (851 / 315); - - final baseHeight = 30 + eventCardHeight + 20; - - final totalHeight = item.actionStart != null - ? baseHeight + 40 - : baseHeight; - - return SizedBox( - height: totalHeight, - child: Stack( - children: [ - Padding( - padding: const EdgeInsets.only(left: 20), - child: DottedVerticalLine(), - ), - Padding( - padding: const EdgeInsets.only(top: 5), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.only(left: 10, right: 30), - color: ColorConstants.background, - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - DateFormat.d( - locale.toString(), - ).format(item.start), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: ColorConstants.main, - ), - ), - Text( - DateFormat.MMM( - locale.toString(), - ).format(item.start).toUpperCase(), - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: ColorConstants.onTertiary, - ), - ), - ], - ), - ), - Expanded( - child: GestureDetector( - onTap: onTap, - child: EventCard(item: item), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.only(top: 5, left: 45), - child: EventCardTextContent( - item: item, - localizeWithContext: localizeWithContext, - ), - ), - if (item.actionStart != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.only( - left: 11, - right: 37, - top: 3, - ), - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: ColorConstants.background, - border: Border.all( - color: ColorConstants.secondary, - width: 2, - ), - ), - ), - ), - Expanded( - child: EventAction( - title: getActionTitle(item, context), - waitingTitle: (timeToGo) => getWaitingTitle( - item, - context, - timeToGo: timeToGo, - ), - subtitle: getActionSubtitle(item, context), - onActionPressed: () => - getActionButtonAction(item, context, ref), - actionEnableButtonText: - getActionEnableButtonText(item, context), - actionValidatedButtonText: - getActionValidatedButtonText(item, context), - isActionValidated: false, - eventEnd: item.end, - timeOpening: item.actionStart, - ), - ), - ], - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/lib/feed/ui/pages/main_page/timeline_item.dart b/lib/feed/ui/pages/main_page/timeline_item.dart new file mode 100644 index 0000000000..5bbcb459cf --- /dev/null +++ b/lib/feed/ui/pages/main_page/timeline_item.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:titan/feed/class/news.dart'; +import 'package:titan/feed/tools/news_helper.dart'; +import 'package:titan/feed/ui/pages/main_page/event_action.dart'; +import 'package:titan/feed/ui/pages/main_page/event_card.dart'; +import 'package:titan/feed/ui/widgets/event_card_text_content.dart'; +import 'package:titan/l10n/app_localizations.dart'; +import 'package:titan/tools/constants.dart'; + +class TimeLineItem extends ConsumerWidget { + final News item; + + const TimeLineItem({super.key, required this.item}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = Localizations.localeOf(context); + final localizeWithContext = AppLocalizations.of(context)!; + + return Padding( + padding: const EdgeInsets.only(top: 5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only(right: 10), + width: 55, + height: 60, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (item.displayDate != null) + Container( + color: ColorConstants.background, + child: Text( + DateFormat.d(locale.toString()).format(item.start), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: ColorConstants.main, + ), + ), + ), + if (item.displayDate != null) + Container( + color: ColorConstants.background, + child: Text( + DateFormat.MMM( + locale.toString(), + ).format(item.start).toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: ColorConstants.onTertiary, + ), + ), + ), + ], + ), + ), + Expanded( + child: GestureDetector( + onTap: () {}, + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: EventCard(item: item), + ), + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 5, left: 45), + child: EventCardTextContent( + item: item, + localizeWithContext: localizeWithContext, + ), + ), + if (item.actionStart != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(left: 11, right: 37, top: 3), + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorConstants.background, + border: Border.all( + color: ColorConstants.secondary, + width: 2, + ), + ), + ), + ), + Expanded( + child: EventAction( + title: getActionTitle(item, context), + waitingTitle: (timeToGo) => + getWaitingTitle(item, context, timeToGo: timeToGo), + subtitle: getActionSubtitle(item, context), + onActionPressed: () => + getActionButtonAction(item, context, ref), + actionEnableButtonText: getActionEnableButtonText( + item, + context, + ), + actionValidatedButtonText: getActionValidatedButtonText( + item, + context, + ), + isActionValidated: false, + eventEnd: item.end, + timeOpening: item.actionStart, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/tools/ui/layouts/refresher.dart b/lib/tools/ui/layouts/refresher.dart index 76db521a99..e3c64b9b41 100644 --- a/lib/tools/ui/layouts/refresher.dart +++ b/lib/tools/ui/layouts/refresher.dart @@ -1,11 +1,7 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart' show kIsWeb; - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:titan/navigation/ui/scroll_to_hide_navbar.dart'; -import 'package:titan/tools/token_expire_wrapper.dart'; +import 'package:titan/tools/constants.dart'; class Refresher extends HookConsumerWidget { final Widget child; @@ -20,64 +16,24 @@ class Refresher extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - if (kIsWeb) { - return ScrollToHideNavbar( - controller: controller, - child: SingleChildScrollView( - controller: controller, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - child: child, - ), - ); - } - return Platform.isAndroid ? buildAndroidList(ref) : buildIOSList(ref); - } - - Widget buildAndroidList(WidgetRef ref) => LayoutBuilder( - builder: (context, constraints) => RefreshIndicator( - onRefresh: () async { - tokenExpireWrapper(ref, onRefresh); - }, + return RefreshIndicator( + color: ColorConstants.main, + onRefresh: onRefresh, child: ScrollToHideNavbar( controller: controller, - child: SingleChildScrollView( + child: CustomScrollView( controller: controller, - physics: const AlwaysScrollableScrollPhysics( - parent: BouncingScrollPhysics(), - ), - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: child, - ), - ), - ), - ), - ); - Widget buildIOSList(WidgetRef ref) => LayoutBuilder( - builder: (context, constraints) => ScrollToHideNavbar( - controller: controller, - child: CustomScrollView( - controller: controller, - shrinkWrap: false, - physics: const BouncingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - CupertinoSliverRefreshControl( - onRefresh: () async { - tokenExpireWrapper(ref, onRefresh); - }, - ), - SliverToBoxAdapter( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: child, + shrinkWrap: false, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverList(delegate: SliverChildListDelegate([child])), + const SliverFillRemaining( + hasScrollBody: false, + child: SizedBox.shrink(), ), - ), - ], + ], + ), ), - ), - ); + ); + } } diff --git a/pubspec.yaml b/pubspec.yaml index f6e5adc159..4c2d48db7a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: qlevar_router: ^1.9.0 qr_flutter: ^4.1.0 rxdart: ^0.28.0 + scrollable_positioned_list: ^0.3.8 shared_preferences: ^2.5.1 smooth_page_indicator: ^1.0.0+2 syncfusion_flutter_calendar: ^29.1.38