diff --git a/lib/domain/app_state/room/timeline_search_event_state.dart b/lib/domain/app_state/room/timeline_search_event_state.dart index 8f5c63bfbe..bc35d37e81 100644 --- a/lib/domain/app_state/room/timeline_search_event_state.dart +++ b/lib/domain/app_state/room/timeline_search_event_state.dart @@ -14,6 +14,11 @@ class TimelineSearchEventSuccess extends Success { @override List get props => [events]; + + @override + String toString() { + return "TimelineSearchEventSuccess ${events.length} ${events.map((event) => event.eventId)}"; + } } class TimelineSearchEventFailure extends Failure { diff --git a/lib/domain/usecase/room/timeline_search_event_interactor.dart b/lib/domain/usecase/room/timeline_search_event_interactor.dart index c208132ce6..4aa0ebf542 100644 --- a/lib/domain/usecase/room/timeline_search_event_interactor.dart +++ b/lib/domain/usecase/room/timeline_search_event_interactor.dart @@ -14,7 +14,7 @@ class TimelineSearchEventInteractor { String? sinceEventId, }) async* { try { - yield* timeline + final events = await timeline .searchEvent( searchFunc: searchFunc, requestHistoryCount: requestHistoryCount, @@ -22,16 +22,10 @@ class TimelineSearchEventInteractor { limit: limit, sinceEventId: sinceEventId, ) - .map(_convertEventToSuccess); + .last; + yield Right(TimelineSearchEventSuccess(events: events)); } catch (e) { yield Left(TimelineSearchEventFailure(exception: e)); } } - - Either _convertEventToSuccess(events) { - Logs().v( - 'TimelineSearchEventInteractor::events ${events.length} ${events.map((event) => event.eventId)}', - ); - return Right(TimelineSearchEventSuccess(events: events)); - } } diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index c62b66eb99..845b1a504e 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -42,8 +42,6 @@ class ChatDetails extends StatefulWidget { } class ChatDetailsController extends State { - static const _mediaFetchLimit = 20; - static const _linksFetchLimit = 20; final invitationSelectionMobileAndTabletKey = const Key('InvitationSelectionMobileAndTabletKey'); @@ -453,7 +451,6 @@ class ChatDetailsController extends State { ChatDetailsPageModel( page: ChatDetailsPage.media, child: ChatDetailsMediaPage( - eventsListController: mediaListController, getTimeline: getTimeline, cacheMap: _mediaCacheMap, ), @@ -465,7 +462,6 @@ class ChatDetailsController extends State { ChatDetailsPageModel( page: ChatDetailsPage.links, child: ChatDetailsLinksPage( - eventsListController: linksListController, getTimeline: getTimeline, ), ), diff --git a/lib/pages/chat_details/chat_details_page_view/links/chat_details_links_page.dart b/lib/pages/chat_details/chat_details_page_view/links/chat_details_links_page.dart index 5f82c92e73..38d632eac4 100644 --- a/lib/pages/chat_details/chat_details_page_view/links/chat_details_links_page.dart +++ b/lib/pages/chat_details/chat_details_page_view/links/chat_details_links_page.dart @@ -1,34 +1,34 @@ import 'package:fluffychat/app_state/success.dart'; import 'package:fluffychat/domain/app_state/room/timeline_search_event_state.dart'; import 'package:fluffychat/pages/chat_details/chat_details_page_view/links/chat_details_links_style.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/string_extension.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_builder.dart'; import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart'; class ChatDetailsLinksPage extends StatelessWidget { - final SameTypeEventsListController eventsListController; + static const _linksFetchLimit = 20; final Future Function() getTimeline; const ChatDetailsLinksPage({ Key? key, - required this.eventsListController, required this.getTimeline, }) : super(key: key); @override Widget build(BuildContext context) { return SameTypeEventsListBuilder( - controller: eventsListController, getTimeline: getTimeline, + searchFunc: (event) => event.isContainsLink, + limit: _linksFetchLimit, builder: (context, eventsState) { final events = eventsState .getSuccessOrNull() ?.events ?? []; - return ListView.separated( + return SliverList.separated( itemCount: events.length, itemBuilder: (context, index) { final body = events[index].body; diff --git a/lib/pages/chat_details/chat_details_page_view/media/chat_details_media_page.dart b/lib/pages/chat_details/chat_details_page_view/media/chat_details_media_page.dart index 0eaed644bb..46d72d4356 100644 --- a/lib/pages/chat_details/chat_details_page_view/media/chat_details_media_page.dart +++ b/lib/pages/chat_details/chat_details_page_view/media/chat_details_media_page.dart @@ -2,7 +2,6 @@ import 'package:fluffychat/app_state/success.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/domain/app_state/room/timeline_search_event_state.dart'; import 'package:fluffychat/pages/chat_details/chat_details_page_view/media/chat_details_media_style.dart'; -import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_builder.dart'; import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; @@ -11,12 +10,11 @@ import 'package:flutter_blurhash/flutter_blurhash.dart'; import 'package:matrix/matrix.dart'; class ChatDetailsMediaPage extends StatelessWidget { - final SameTypeEventsListController eventsListController; + static const _mediaFetchLimit = 20; final Future Function() getTimeline; final Map? cacheMap; const ChatDetailsMediaPage({ Key? key, - required this.eventsListController, required this.getTimeline, this.cacheMap, }) : super(key: key); @@ -24,56 +22,53 @@ class ChatDetailsMediaPage extends StatelessWidget { @override Widget build(BuildContext context) { return SameTypeEventsListBuilder( - controller: eventsListController, getTimeline: getTimeline, + searchFunc: (event) => event.isVideoOrImage, + limit: _mediaFetchLimit, builder: (context, eventsState) { final events = eventsState .getSuccessOrNull() ?.events ?? []; - return CustomScrollView( - slivers: [ - SliverGrid.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, + Logs().v("ChatDetailsMediaPage::events: ${events.length}"); + return SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + ), + itemCount: events.length, + itemBuilder: (context, index) => Stack( + fit: StackFit.expand, + children: [ + MxcImage( + event: events[index], + isThumbnail: true, + fit: BoxFit.cover, + onTapPreview: () {}, + isPreview: true, + placeholder: (context) => BlurHash( + hash: + events[index].blurHash ?? AppConfig.defaultImageBlurHash, + ), + cacheKey: events[index].eventId, + cacheMap: cacheMap, ), - itemCount: events.length, - itemBuilder: (context, index) => Stack( - fit: StackFit.expand, - children: [ - MxcImage( - event: events[index], - isThumbnail: true, - fit: BoxFit.cover, - onTapPreview: () {}, - isPreview: true, - placeholder: (context) => BlurHash( - hash: events[index].blurHash ?? - AppConfig.defaultImageBlurHash, + if (events[index].messageType == MessageTypes.Video) + Positioned( + bottom: ChatDetailsMediaStyle.durationPosition, + right: ChatDetailsMediaStyle.durationPosition, + child: Container( + padding: ChatDetailsMediaStyle.durationPadding, + decoration: ChatDetailsMediaStyle.durationBoxDecoration( + context, ), - cacheKey: events[index].eventId, - cacheMap: cacheMap, - ), - if (events[index].messageType == MessageTypes.Video) - Positioned( - bottom: ChatDetailsMediaStyle.durationPosition, - right: ChatDetailsMediaStyle.durationPosition, - child: Container( - padding: ChatDetailsMediaStyle.durationPadding, - decoration: ChatDetailsMediaStyle.durationBoxDecoration( - context, - ), - child: Text( - "00:00", - style: - ChatDetailsMediaStyle.durationTextStyle(context), - ), - ), + child: Text( + "00:00", + style: ChatDetailsMediaStyle.durationTextStyle(context), ), - ], - ), - ) - ], + ), + ), + ], + ), ); }, ); diff --git a/lib/pages/chat_details/chat_details_page_view/same_type_events_list_builder.dart b/lib/pages/chat_details/chat_details_page_view/same_type_events_list_builder.dart deleted file mode 100644 index f64363a8fc..0000000000 --- a/lib/pages/chat_details/chat_details_page_view/same_type_events_list_builder.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:dartz/dartz.dart'; -import 'package:fluffychat/app_state/failure.dart'; -import 'package:fluffychat/app_state/success.dart'; -import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart'; -import 'package:fluffychat/widgets/twake_components/twake_smart_refresher.dart'; - -class SameTypeEventsListBuilder extends StatelessWidget { - final SameTypeEventsListController controller; - final Future Function() getTimeline; - final Widget Function(BuildContext, Either) builder; - - const SameTypeEventsListBuilder({ - Key? key, - required this.controller, - required this.getTimeline, - required this.builder, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: controller.eventsNotifier, - builder: (context, eventsState, child) => TwakeSmartRefresher( - controller: controller.refreshController, - onRefresh: () => controller.refresh(getTimeline: getTimeline), - onLoading: () => controller.loadMore(getTimeline: getTimeline), - child: builder( - context, - eventsState, - ), - ), - ); - } -} diff --git a/lib/pages/chat_details/chat_details_page_view/same_type_events_list_builder_view.dart b/lib/pages/chat_details/chat_details_page_view/same_type_events_list_builder_view.dart new file mode 100644 index 0000000000..4f357fe159 --- /dev/null +++ b/lib/pages/chat_details/chat_details_page_view/same_type_events_list_builder_view.dart @@ -0,0 +1,52 @@ +import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart'; +import 'package:flutter/material.dart'; + +class SameTypeEventsListBuilderView extends StatelessWidget { + final SameTypeEventsBuilderController controller; + + const SameTypeEventsListBuilderView({ + Key? key, + required this.controller, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: controller.refresh, + child: CustomScrollView( + controller: controller.scrollController, + slivers: [ + ValueListenableBuilder( + valueListenable: controller.refreshing, + builder: (context, refreshing, child) => SliverToBoxAdapter( + child: refreshing ? const _LoadingIndicator() : const SizedBox(), + ), + ), + ValueListenableBuilder( + valueListenable: controller.eventsNotifier, + builder: (context, eventsState, child) => + controller.widget.builder(context, eventsState), + ), + ValueListenableBuilder( + valueListenable: controller.loadingMore, + builder: (context, loadingMore, child) => SliverToBoxAdapter( + child: loadingMore ? const _LoadingIndicator() : const SizedBox(), + ), + ) + ], + ), + ); + } +} + +class _LoadingIndicator extends StatelessWidget { + const _LoadingIndicator(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ); + } +} diff --git a/lib/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart b/lib/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart index 51e9be0287..fdbed806a4 100644 --- a/lib/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart +++ b/lib/pages/chat_details/chat_details_page_view/same_type_events_list_controller.dart @@ -1,101 +1,151 @@ -import 'package:dartz/dartz.dart'; +import 'package:dartz/dartz.dart' hide State; +import 'package:fluffychat/utils/scroll_controller_extension.dart'; +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + import 'package:fluffychat/app_state/failure.dart'; import 'package:fluffychat/app_state/success.dart'; import 'package:fluffychat/di/global/get_it_initializer.dart'; import 'package:fluffychat/domain/app_state/room/timeline_search_event_state.dart'; import 'package:fluffychat/domain/usecase/room/timeline_search_event_interactor.dart'; -import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; -import 'package:pull_to_refresh/pull_to_refresh.dart'; - -class SameTypeEventsListController { - static const _requestHistoryCount = 100; - static const _maxHistoryRequests = 10; +import 'package:fluffychat/pages/chat_details/chat_details_page_view/same_type_events_list_builder_view.dart'; +class SameTypeEventsListBuilder extends StatefulWidget { + final Future Function() getTimeline; final bool Function(Event) searchFunc; final int? limit; - SameTypeEventsListController({ + /// The builder must return a sliver. + final Widget Function(BuildContext, Either) builder; + + const SameTypeEventsListBuilder({ + Key? key, + required this.getTimeline, required this.searchFunc, - required this.limit, - }); + this.limit, + required this.builder, + }) : super(key: key); + + @override + State createState() => + SameTypeEventsBuilderController(); +} + +class SameTypeEventsBuilderController extends State { + static const _requestHistoryCount = 100; + static const _maxHistoryRequests = 10; final eventsNotifier = ValueNotifier>( Right(TimelineSearchEventInitial()), ); - final refreshController = RefreshController(initialRefresh: true); + final scrollController = ScrollController(); + final refreshing = ValueNotifier(false); + final loadingMore = ValueNotifier(false); final _searchInteractor = getIt.get(); + var _isEnd = true; + + @override + void initState() { + super.initState(); + scrollController.addListener(_onScroll); + refresh(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } - void refresh({required Future Function() getTimeline}) async { - final timeline = await getTimeline(); + Future refresh() async { + if (refreshing.value) return; + refreshing.value = true; + final timeline = await widget.getTimeline(); _searchInteractor .execute( - timeline: timeline, - searchFunc: searchFunc, - requestHistoryCount: _requestHistoryCount, - maxHistoryRequests: _maxHistoryRequests, - limit: limit, - ) + timeline: timeline, + searchFunc: widget.searchFunc, + requestHistoryCount: _requestHistoryCount, + maxHistoryRequests: _maxHistoryRequests, + limit: widget.limit, + ) .listen( - (event) { - Logs().v('SameTypeEventsListController::refresh $event'); - eventsNotifier.value = event; - }, - onError: (_) { - refreshController.refreshFailed(); - }, - onDone: () { - refreshController.refreshCompleted(); - }, - ); + _onRefreshSuccess, + onDone: _onRefreshDone, + ); } - void loadMore({required Future Function() getTimeline}) async { + void _loadMore() async { final lastSuccess = eventsNotifier.value.getSuccessOrNull(); - if (lastSuccess == null || lastSuccess.events.isEmpty) { - refreshController.loadComplete(); + if (lastSuccess == null || + refreshing.value || + loadingMore.value || + _isEnd) { return; } - final timeline = await getTimeline(); - var isEnd = false; + loadingMore.value = true; + final timeline = await widget.getTimeline(); _searchInteractor .execute( - timeline: timeline, - searchFunc: searchFunc, - requestHistoryCount: _requestHistoryCount, - maxHistoryRequests: _maxHistoryRequests, - limit: limit, - sinceEventId: lastSuccess.events.last.eventId, - ) + timeline: timeline, + searchFunc: widget.searchFunc, + requestHistoryCount: _requestHistoryCount, + maxHistoryRequests: _maxHistoryRequests, + limit: widget.limit, + sinceEventId: lastSuccess.events.last.eventId, + ) .listen( - (event) { - Logs().v('SameTypeEventsListController::loadMore $event'); - eventsNotifier.value = event.map( - (success) { - if (success is TimelineSearchEventSuccess) { - isEnd = limit != null - ? success.events.length < limit! - : success.events.isEmpty; - return lastSuccess.concat(success); - } - return success; - }, + (event) => _onLoadMoreSuccess(event, lastSuccess), + onDone: _onLoadMoreDone, ); - }, - onError: (_) { - refreshController.loadFailed(); - }, - onDone: () { - if (isEnd) { - Logs().v('SameTypeEventsListController::loadMore loadNoData'); - refreshController.loadNoData(); - } else { - Logs().v('SameTypeEventsListController::loadMore loadComplete'); - refreshController.loadComplete(); + } + + void _onRefreshDone() { + Logs().v('SameTypeEventsListController::refresh done'); + refreshing.value = false; + } + + void _onRefreshSuccess(Either event) { + Logs().v('SameTypeEventsListController::refresh $event'); + eventsNotifier.value = event; + final success = event.getSuccessOrNull(); + if (success != null && widget.limit != null) { + _isEnd = success.events.length < widget.limit!; + } + } + + void _onLoadMoreSuccess( + Either event, + TimelineSearchEventSuccess lastSuccess, + ) { + Logs().v('SameTypeEventsListController::loadMore $event'); + eventsNotifier.value = event.map( + (success) { + if (success is TimelineSearchEventSuccess) { + _isEnd = widget.limit != null + ? success.events.length < widget.limit! + : success.events.isEmpty; + return lastSuccess.concat(success); } + return success; }, ); } + + void _onLoadMoreDone() { + loadingMore.value = false; + } + + void _onScroll() { + if (scrollController.shouldLoadMore) { + _loadMore(); + } + } + + @override + Widget build(BuildContext context) { + return SameTypeEventsListBuilderView(controller: this); + } } diff --git a/lib/utils/string_extension.dart b/lib/utils/string_extension.dart index 627d66b62f..41fc73d260 100644 --- a/lib/utils/string_extension.dart +++ b/lib/utils/string_extension.dart @@ -84,7 +84,7 @@ extension StringCasingExtension on String { } String? getFirstValidUrl() { - final RegExp regex = RegExp(r'https:\/\/[^\s]+'); + final RegExp regex = RegExp(r'https:\/\/[^\s]+', caseSensitive: false); final List matches = regex.allMatches(this).map((m) => m.group(0)).toList();