From f01f28a93b9c0023ec723e30b3e959f383358136 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:06:45 +0200 Subject: [PATCH 01/10] new feed scroll --- lib/feed/class/news.dart | 12 +- .../ui/pages/main_page/feed_timeline.dart | 40 -- lib/feed/ui/pages/main_page/main_page.dart | 381 +++++++++--------- .../ui/pages/main_page/main_page_row.dart | 127 ++++++ .../main_page/scroll_with_refresh_button.dart | 151 ------- .../ui/pages/main_page/time_line_item.dart | 152 ------- .../ui/pages/main_page/timeline_item.dart | 141 +++++++ pubspec.yaml | 1 + 8 files changed, 477 insertions(+), 528 deletions(-) delete mode 100644 lib/feed/ui/pages/main_page/feed_timeline.dart create mode 100644 lib/feed/ui/pages/main_page/main_page_row.dart delete mode 100644 lib/feed/ui/pages/main_page/scroll_with_refresh_button.dart delete mode 100644 lib/feed/ui/pages/main_page/time_line_item.dart create mode 100644 lib/feed/ui/pages/main_page/timeline_item.dart 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/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..247eef9987 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,64 +21,127 @@ 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(); } + 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 result; + } + + final now = DateTime.now(); + final newsList = news.value!; + + final pastNews = withDisplayDates( + newsList + .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 = + newsList + .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( + newsList.where((item) => item.start.isAfter(now)).toList() + ..sort((a, b) => a.start.compareTo(b.start)), + ); + + final sortedNews = [...pastNews, ...ongoingNews, ...futureNews]; + 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( + final upcomingIndex = sortedNews.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); + if (upcomingIndex != -1 && itemScrollController.isAttached) { + itemScrollController.scrollTo( + index: upcomingIndex, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + ); } }); + void listener() { + final positions = itemPositionsListener.itemPositions.value; + if (positions.isEmpty) return; + + final firstVisible = positions + .where((p) => p.itemLeadingEdge >= 0) + .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); } return null; }, [news]); + Future handleRefresh() async { + showRefreshButton.value = false; + await onRefresh(); + } + return FeedTemplate( child: Stack( children: [ @@ -95,151 +151,112 @@ 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) {}, ), - ), + ) + : 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..88dc9ddce8 --- /dev/null +++ b/lib/feed/ui/pages/main_page/timeline_item.dart @@ -0,0 +1,141 @@ +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; + + 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 Stack( + children: [ + 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/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 From 7edb266e177a187a3911c07f3b7476e2c1ada972 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:07:22 +0200 Subject: [PATCH 02/10] useless import --- lib/feed/ui/pages/main_page/timeline_item.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/feed/ui/pages/main_page/timeline_item.dart b/lib/feed/ui/pages/main_page/timeline_item.dart index 88dc9ddce8..984ed2a3b3 100644 --- a/lib/feed/ui/pages/main_page/timeline_item.dart +++ b/lib/feed/ui/pages/main_page/timeline_item.dart @@ -8,7 +8,6 @@ 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; From 663d5480470df01186e0e977dfb116e9a69200ee Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:19:00 +0200 Subject: [PATCH 03/10] add navbar after auto scrolling --- lib/feed/ui/pages/main_page/main_page.dart | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/feed/ui/pages/main_page/main_page.dart b/lib/feed/ui/pages/main_page/main_page.dart index 247eef9987..cb6f13e665 100644 --- a/lib/feed/ui/pages/main_page/main_page.dart +++ b/lib/feed/ui/pages/main_page/main_page.dart @@ -88,9 +88,11 @@ class FeedMainPage extends HookConsumerWidget { final sortedNews = [...pastNews, ...ongoingNews, ...futureNews]; + bool ignoreListener = false; + useEffect(() { if (news.hasValue && news.value!.isNotEmpty) { - WidgetsBinding.instance.addPostFrameCallback((_) { + Future.microtask(() async { final now = DateTime.now(); final upcomingIndex = sortedNews.indexWhere( @@ -100,14 +102,22 @@ class FeedMainPage extends HookConsumerWidget { ); if (upcomingIndex != -1 && itemScrollController.isAttached) { - itemScrollController.scrollTo( + ignoreListener = true; + await itemScrollController.scrollTo( index: upcomingIndex, - duration: const Duration(milliseconds: 500), + duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); + navbarVisibilityNotifier.show(); + + // on attend un frame ou un petit délai pour que le listener reprenne + Future.delayed(const Duration(milliseconds: 50), () { + ignoreListener = false; + }); } }); void listener() { + if (ignoreListener) return; final positions = itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; From 7d499fa4a48318705ff01f1cb266004ad902a055 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 15:34:10 +0200 Subject: [PATCH 04/10] remove useless Stack --- .../ui/pages/main_page/timeline_item.dart | 203 +++++++++--------- 1 file changed, 98 insertions(+), 105 deletions(-) diff --git a/lib/feed/ui/pages/main_page/timeline_item.dart b/lib/feed/ui/pages/main_page/timeline_item.dart index 984ed2a3b3..5bbcb459cf 100644 --- a/lib/feed/ui/pages/main_page/timeline_item.dart +++ b/lib/feed/ui/pages/main_page/timeline_item.dart @@ -19,122 +19,115 @@ class TimeLineItem extends ConsumerWidget { final locale = Localizations.localeOf(context); final localizeWithContext = AppLocalizations.of(context)!; - return Stack( - children: [ - Padding( - padding: const EdgeInsets.only(top: 5), - child: Column( - mainAxisSize: MainAxisSize.min, + return Padding( + padding: const EdgeInsets.only(top: 5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, 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, - ), + 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, ), ), ), - Expanded( - child: EventAction( - title: getActionTitle(item, context), - waitingTitle: (timeToGo) => getWaitingTitle( - item, - context, - timeToGo: timeToGo, + 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, ), - 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, ), ), - ], + ], + ), + ), + 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, + ), + ), + ], + ), + ), + ], + ), ); } } From 558bc4b6b29f11eb81b0a73ace7a8c28acceb6be Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 16:07:19 +0200 Subject: [PATCH 05/10] better display navbar after scroll --- lib/feed/ui/pages/main_page/main_page.dart | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/lib/feed/ui/pages/main_page/main_page.dart b/lib/feed/ui/pages/main_page/main_page.dart index cb6f13e665..8ed5eb8b18 100644 --- a/lib/feed/ui/pages/main_page/main_page.dart +++ b/lib/feed/ui/pages/main_page/main_page.dart @@ -88,8 +88,6 @@ class FeedMainPage extends HookConsumerWidget { final sortedNews = [...pastNews, ...ongoingNews, ...futureNews]; - bool ignoreListener = false; - useEffect(() { if (news.hasValue && news.value!.isNotEmpty) { Future.microtask(() async { @@ -102,28 +100,27 @@ class FeedMainPage extends HookConsumerWidget { ); if (upcomingIndex != -1 && itemScrollController.isAttached) { - ignoreListener = true; await itemScrollController.scrollTo( index: upcomingIndex, duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, ); navbarVisibilityNotifier.show(); - - // on attend un frame ou un petit délai pour que le listener reprenne - Future.delayed(const Duration(milliseconds: 50), () { - ignoreListener = false; - }); } }); void listener() { - if (ignoreListener) return; final positions = itemPositionsListener.itemPositions.value; if (positions.isEmpty) return; + final visiblePositions = positions.where( + (p) => p.itemLeadingEdge >= 0 && p.itemLeadingEdge <= 1, + ); - final firstVisible = positions - .where((p) => p.itemLeadingEdge >= 0) - .fold(999999, (prev, e) => e.index < prev ? e.index : prev); + if (visiblePositions.isEmpty) return; + + final firstVisible = visiblePositions.fold( + 999999, + (prev, e) => e.index < prev ? e.index : prev, + ); final lastIndex = lastFirstIndex.value; From 7b1f03bde55178ba25c73f9c84e040babe0e0a33 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:13:07 +0200 Subject: [PATCH 06/10] Go from feed to right advert element --- lib/advert/ui/pages/main_page/main_page.dart | 61 +++++++++++++------- lib/feed/ui/pages/main_page/event_card.dart | 5 +- 2 files changed, 45 insertions(+), 21 deletions(-) diff --git a/lib/advert/ui/pages/main_page/main_page.dart b/lib/advert/ui/pages/main_page/main_page.dart index 6901e97154..b08b5ae2ce 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'; @@ -13,8 +15,8 @@ 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/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,6 +31,38 @@ 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 advertId = pathForwarding.queryParameters?['advertId']; + final adverts = advertList.value!; + 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 itemScrollController = ItemScrollController(); + if (advertIndex != -1) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (itemScrollController.isAttached) { + itemScrollController.scrollTo( + index: advertIndex, + duration: const Duration(milliseconds: 500), + ); + } + }); + return null; + }, []); + } return AdvertTemplate( child: Column( children: [ @@ -71,29 +105,16 @@ class AdvertMainPage extends HookConsumerWidget { 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(), + return RefreshIndicator( onRefresh: () async { await advertListNotifier.loadAdverts(); advertPostersNotifier.resetTData(); }, - child: Column( - children: [ - ...filteredSortedAdvertData.map( - (advert) => AdvertCard(advert: advert), - ), - SizedBox(height: 80), - ], + child: ScrollablePositionedList.builder( + itemCount: filteredSortedAdvertData.length, + itemBuilder: (context, index) => + AdvertCard(advert: filteredSortedAdvertData[index]), + itemScrollController: itemScrollController, ), ); }, 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); } }, From 6cf0bfcac6ccfab764c4e56e563980bf646b19d8 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:21:10 +0200 Subject: [PATCH 07/10] add navbar management --- lib/advert/ui/pages/main_page/main_page.dart | 48 ++++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/lib/advert/ui/pages/main_page/main_page.dart b/lib/advert/ui/pages/main_page/main_page.dart index b08b5ae2ce..c2d94eb533 100644 --- a/lib/advert/ui/pages/main_page/main_page.dart +++ b/lib/advert/ui/pages/main_page/main_page.dart @@ -14,6 +14,7 @@ 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'; @@ -33,6 +34,9 @@ class AdvertMainPage extends HookConsumerWidget { final isAdmin = ref.watch(isAdminProvider); final pathForwarding = ref.watch(pathForwardingProvider); final advertId = pathForwarding.queryParameters?['advertId']; + final navbarVisibilityNotifier = ref.watch( + navbarVisibilityProvider.notifier, + ); final adverts = advertList.value!; final sortedAdvertData = adverts .sortedBy((element) => element.date) @@ -50,17 +54,51 @@ class AdvertMainPage extends HookConsumerWidget { ); final itemScrollController = ItemScrollController(); + final itemPositionsListener = useMemoized( + () => ItemPositionsListener.create(), + ); + final lastFirstIndex = useRef(null); if (advertIndex != -1) { useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { + Future.microtask(() async { if (itemScrollController.isAttached) { - itemScrollController.scrollTo( + await itemScrollController.scrollTo( index: advertIndex, - duration: const Duration(milliseconds: 500), + duration: const Duration(milliseconds: 300), ); + navbarVisibilityNotifier.show(); } }); - return null; + 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 AdvertTemplate( @@ -106,6 +144,7 @@ class AdvertMainPage extends HookConsumerWidget { value: advertList, builder: (context, advertData) { return RefreshIndicator( + color: ColorConstants.main, onRefresh: () async { await advertListNotifier.loadAdverts(); advertPostersNotifier.resetTData(); @@ -115,6 +154,7 @@ class AdvertMainPage extends HookConsumerWidget { itemBuilder: (context, index) => AdvertCard(advert: filteredSortedAdvertData[index]), itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, ), ); }, From 0434d52fb2957b1f1a3b666399dd3ddb5e00e1fa Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:11:14 +0200 Subject: [PATCH 08/10] Edit refresher --- lib/tools/ui/layouts/refresher.dart | 76 ++++++----------------------- 1 file changed, 16 insertions(+), 60 deletions(-) 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(), ), - ), - ], + ], + ), ), - ), - ); + ); + } } From bc8c72bdf747b18b61aeb962bf21b73eeb80ef88 Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:47:27 +0200 Subject: [PATCH 09/10] use hookbuilder to prevent opening error --- lib/advert/ui/pages/main_page/main_page.dart | 229 ++++++++-------- lib/feed/ui/pages/main_page/main_page.dart | 261 ++++++++++--------- 2 files changed, 263 insertions(+), 227 deletions(-) diff --git a/lib/advert/ui/pages/main_page/main_page.dart b/lib/advert/ui/pages/main_page/main_page.dart index c2d94eb533..ab3e8b4221 100644 --- a/lib/advert/ui/pages/main_page/main_page.dart +++ b/lib/advert/ui/pages/main_page/main_page.dart @@ -37,130 +37,141 @@ class AdvertMainPage extends HookConsumerWidget { final navbarVisibilityNotifier = ref.watch( navbarVisibilityProvider.notifier, ); - final adverts = advertList.value!; - 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 itemScrollController = ItemScrollController(); final itemPositionsListener = useMemoized( () => ItemPositionsListener.create(), ); - 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; + return AdvertTemplate( + 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, + ), + ), - final firstVisible = visiblePositions.fold( - 999999, - (prev, e) => e.index < prev ? e.index : prev, - ); + 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, + ), + name: "Admin", + ), + SizedBox(width: 10), + ], + ], + ), - final lastIndex = lastFirstIndex.value; + const SizedBox(height: 20), - if (lastIndex != null) { - if (firstVisible > lastIndex) { - navbarVisibilityNotifier.hide(); - } else if (firstVisible < lastIndex) { - navbarVisibilityNotifier.show(); - } - } + 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(); - lastFirstIndex.value = firstVisible; - } + 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, + ); - itemPositionsListener.itemPositions.addListener(listener); - return () => - itemPositionsListener.itemPositions.removeListener(listener); - }, []); - } - return AdvertTemplate( - child: Column( - children: [ - Row( - children: [ - Expanded( - child: const AssociationBar( - useUserAssociations: false, - multipleSelect: true, - ), - ), + if (visiblePositions.isEmpty) return; - 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, - ), - name: "Admin", - ), - SizedBox(width: 10), - ], - ], - ), + final firstVisible = visiblePositions.fold( + 999999, + (prev, e) => e.index < prev ? e.index : prev, + ); - const SizedBox(height: 20), + final lastIndex = lastFirstIndex.value; - Expanded( - child: AsyncChild( - value: advertList, - builder: (context, advertData) { - return RefreshIndicator( - color: ColorConstants.main, - onRefresh: () async { - await advertListNotifier.loadAdverts(); - advertPostersNotifier.resetTData(); - }, - child: ScrollablePositionedList.builder( - itemCount: filteredSortedAdvertData.length, - itemBuilder: (context, index) => - AdvertCard(advert: filteredSortedAdvertData[index]), - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - ), - ); - }, + 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/ui/pages/main_page/main_page.dart b/lib/feed/ui/pages/main_page/main_page.dart index 8ed5eb8b18..9574a4f2c5 100644 --- a/lib/feed/ui/pages/main_page/main_page.dart +++ b/lib/feed/ui/pages/main_page/main_page.dart @@ -54,95 +54,6 @@ class FeedMainPage extends HookConsumerWidget { } final now = DateTime.now(); - final newsList = news.value!; - - final pastNews = withDisplayDates( - newsList - .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 = - newsList - .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( - newsList.where((item) => item.start.isAfter(now)).toList() - ..sort((a, b) => a.start.compareTo(b.start)), - ); - - final sortedNews = [...pastNews, ...ongoingNews, ...futureNews]; - - useEffect(() { - if (news.hasValue && news.value!.isNotEmpty) { - 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); - } - return null; - }, [news]); Future handleRefresh() async { showRefreshButton.value = false; @@ -174,38 +85,152 @@ class FeedMainPage extends HookConsumerWidget { ), ), ) - : 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); + : 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; } - return LayoutBuilder( - builder: (context, constraints) { - return Stack( - children: [ - Positioned( - left: 20, - top: 0, - bottom: 0, - child: DottedVerticalLine(), - ), - TimeLineItem( - item: sortedNews[index], - ), - ], + } + + 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], + ), + ], + ); + }, ); }, - ); - }, - ), - ), - ], + ), + ), + ], + ); + }, ), ), ), From 96367ed4f660dae2407f42b6f66d386a21107b9e Mon Sep 17 00:00:00 2001 From: Foucauld Bellanger <63885990+Foukki@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:54:34 +0200 Subject: [PATCH 10/10] clear params after consumption --- lib/advert/ui/pages/main_page/main_page.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/advert/ui/pages/main_page/main_page.dart b/lib/advert/ui/pages/main_page/main_page.dart index ab3e8b4221..c6abf24763 100644 --- a/lib/advert/ui/pages/main_page/main_page.dart +++ b/lib/advert/ui/pages/main_page/main_page.dart @@ -33,6 +33,7 @@ class AdvertMainPage extends HookConsumerWidget { 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, @@ -43,6 +44,13 @@ class AdvertMainPage extends HookConsumerWidget { () => ItemPositionsListener.create(), ); + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + pathForwardingNotifier.clearParams(); + }); + return null; + }, const []); + return AdvertTemplate( child: RefreshIndicator( color: ColorConstants.main,