diff --git a/lib/widgets/post/post_reaction_sheet.dart b/lib/widgets/post/post_reaction_sheet.dart index 97e933b0..d859f16e 100644 --- a/lib/widgets/post/post_reaction_sheet.dart +++ b/lib/widgets/post/post_reaction_sheet.dart @@ -2,17 +2,65 @@ import 'dart:math' as math; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_popup_card/flutter_popup_card.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/post.dart'; import 'package:island/pods/network.dart'; import 'package:island/services/time.dart'; +import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:styled_widget/styled_widget.dart'; +part 'post_reaction_sheet.g.dart'; + +@riverpod +class ReactionListNotifier extends _$ReactionListNotifier + with CursorPagingNotifierMixin { + static const int _pageSize = 20; + + int? totalCount; + + @override + Future> build({ + required String symbol, + required String postId, + }) { + return fetch(cursor: null); + } + + @override + Future> fetch({ + required String? cursor, + }) async { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final response = await client.get( + '/sphere/posts/$postId/reactions', + queryParameters: {'symbol': symbol, 'offset': offset, 'take': _pageSize}, + ); + + totalCount = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0; + + final List data = response.data; + final reactions = + data.map((json) => SnPostReaction.fromJson(json)).toList(); + + final hasMore = reactions.length == _pageSize; + final nextCursor = hasMore ? (offset + reactions.length).toString() : null; + + return CursorPagingData( + items: reactions, + hasMore: hasMore, + nextCursor: nextCursor, + ); + } +} + const kAvailableStickers = { 'angry', 'clap', @@ -49,6 +97,7 @@ class PostReactionSheet extends StatelessWidget { final Function(String symbol, int attitude) onReact; final String postId; const PostReactionSheet({ + super.key, required this.reactionsCount, required this.reactionsMade, required this.onReact, @@ -162,6 +211,18 @@ class PostReactionSheet extends StatelessWidget { symbol, details.localPosition, postId, + reactionsCount[symbol] ?? 0, + ); + } + }, + onSecondaryTapUp: (details) { + if (count > 0) { + showReactionDetailsPopup( + context, + symbol, + details.localPosition, + postId, + reactionsCount[symbol] ?? 0, ); } }, @@ -269,56 +330,20 @@ class PostReactionSheet extends StatelessWidget { class ReactionDetailsPopup extends HookConsumerWidget { final String symbol; final String postId; + final int totalCount; const ReactionDetailsPopup({ super.key, required this.symbol, required this.postId, + required this.totalCount, }); @override Widget build(BuildContext context, WidgetRef ref) { - final reactions = useState>([]); - final isLoading = useState(false); - final hasMore = useState(true); - final offset = useState(0); - - Future loadReactions() async { - if (isLoading.value || !hasMore.value) return; - - isLoading.value = true; - try { - final client = ref.watch(apiClientProvider); - final response = await client.get( - '/sphere/posts/${postId}/reactions', - queryParameters: { - 'symbol': symbol, - 'offset': offset.value, - 'take': 20, - }, - ); - - final newReactions = - (response.data as List) - .map((json) => SnPostReaction.fromJson(json)) - .toList(); - - if (newReactions.length < 20) { - hasMore.value = false; - } - - reactions.value = [...reactions.value, ...newReactions]; - offset.value += newReactions.length; - } catch (err) { - // Handle error - } finally { - isLoading.value = false; - } - } - - useEffect(() { - loadReactions(); - return null; - }, []); + final provider = reactionListNotifierProvider( + symbol: symbol, + postId: postId, + ); final width = math.min(MediaQuery.of(context).size.width * 0.8, 480.0); return PopupCard( @@ -340,40 +365,39 @@ class ReactionDetailsPopup extends HookConsumerWidget { style: Theme.of(context).textTheme.titleMedium, ).tr(), const Spacer(), - Text('${reactions.value.length} reactions'.tr()), + Text('reactions'.plural(totalCount)), ], ), ), const Divider(height: 1), Expanded( - child: ListView.builder( - itemCount: reactions.value.length + (hasMore.value ? 1 : 0), - itemBuilder: (context, index) { - if (index == reactions.value.length) { - if (isLoading.value) { - return const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: CircularProgressIndicator(), - ), - ); - } else { - loadReactions(); - return const SizedBox.shrink(); - } - } + child: PagingHelperView( + provider: provider, + futureRefreshable: provider.future, + notifierRefreshable: provider.notifier, + contentBuilder: + (data, widgetCount, endItemView) => ListView.builder( + itemCount: widgetCount, + itemBuilder: (context, index) { + if (index == widgetCount - 1) { + return endItemView; + } - final reaction = reactions.value[index]; - return ListTile( - leading: ProfilePictureWidget( - file: reaction.account?.profile.picture, + final reaction = data.items[index]; + return ListTile( + leading: AccountPfcGestureDetector( + uname: reaction.account?.name ?? 'unknown', + child: ProfilePictureWidget( + file: reaction.account?.profile.picture, + ), + ), + title: Text(reaction.account?.nick ?? 'unknown'.tr()), + subtitle: Text( + '${reaction.createdAt.formatRelative(context)} · ${reaction.createdAt.formatSystem()}', + ), + ); + }, ), - title: Text(reaction.account?.nick ?? 'unknown'.tr()), - subtitle: Text( - '${reaction.createdAt.formatRelative(context)} · ${reaction.createdAt.formatSystem()}', - ), - ); - }, ), ), ], @@ -388,11 +412,17 @@ Future showReactionDetailsPopup( String symbol, Offset offset, String postId, + int totalCount, ) async { await showPopupCard( offset: offset, context: context, - builder: (context) => ReactionDetailsPopup(symbol: symbol, postId: postId), + builder: + (context) => ReactionDetailsPopup( + symbol: symbol, + postId: postId, + totalCount: totalCount, + ), alignment: Alignment.center, dimBackground: true, ); diff --git a/lib/widgets/post/post_reaction_sheet.g.dart b/lib/widgets/post/post_reaction_sheet.g.dart new file mode 100644 index 00000000..06750266 --- /dev/null +++ b/lib/widgets/post/post_reaction_sheet.g.dart @@ -0,0 +1,206 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'post_reaction_sheet.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$reactionListNotifierHash() => + r'92cf80d2461e46ca62cf6e6a37f8b16c239e7449'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ReactionListNotifier + extends + BuildlessAutoDisposeAsyncNotifier> { + late final String symbol; + late final String postId; + + FutureOr> build({ + required String symbol, + required String postId, + }); +} + +/// See also [ReactionListNotifier]. +@ProviderFor(ReactionListNotifier) +const reactionListNotifierProvider = ReactionListNotifierFamily(); + +/// See also [ReactionListNotifier]. +class ReactionListNotifierFamily + extends Family>> { + /// See also [ReactionListNotifier]. + const ReactionListNotifierFamily(); + + /// See also [ReactionListNotifier]. + ReactionListNotifierProvider call({ + required String symbol, + required String postId, + }) { + return ReactionListNotifierProvider(symbol: symbol, postId: postId); + } + + @override + ReactionListNotifierProvider getProviderOverride( + covariant ReactionListNotifierProvider provider, + ) { + return call(symbol: provider.symbol, postId: provider.postId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'reactionListNotifierProvider'; +} + +/// See also [ReactionListNotifier]. +class ReactionListNotifierProvider + extends + AutoDisposeAsyncNotifierProviderImpl< + ReactionListNotifier, + CursorPagingData + > { + /// See also [ReactionListNotifier]. + ReactionListNotifierProvider({required String symbol, required String postId}) + : this._internal( + () => + ReactionListNotifier() + ..symbol = symbol + ..postId = postId, + from: reactionListNotifierProvider, + name: r'reactionListNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$reactionListNotifierHash, + dependencies: ReactionListNotifierFamily._dependencies, + allTransitiveDependencies: + ReactionListNotifierFamily._allTransitiveDependencies, + symbol: symbol, + postId: postId, + ); + + ReactionListNotifierProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.symbol, + required this.postId, + }) : super.internal(); + + final String symbol; + final String postId; + + @override + FutureOr> runNotifierBuild( + covariant ReactionListNotifier notifier, + ) { + return notifier.build(symbol: symbol, postId: postId); + } + + @override + Override overrideWith(ReactionListNotifier Function() create) { + return ProviderOverride( + origin: this, + override: ReactionListNotifierProvider._internal( + () => + create() + ..symbol = symbol + ..postId = postId, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + symbol: symbol, + postId: postId, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement< + ReactionListNotifier, + CursorPagingData + > + createElement() { + return _ReactionListNotifierProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ReactionListNotifierProvider && + other.symbol == symbol && + other.postId == postId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, symbol.hashCode); + hash = _SystemHash.combine(hash, postId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ReactionListNotifierRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `symbol` of this provider. + String get symbol; + + /// The parameter `postId` of this provider. + String get postId; +} + +class _ReactionListNotifierProviderElement + extends + AutoDisposeAsyncNotifierProviderElement< + ReactionListNotifier, + CursorPagingData + > + with ReactionListNotifierRef { + _ReactionListNotifierProviderElement(super.provider); + + @override + String get symbol => (origin as ReactionListNotifierProvider).symbol; + @override + String get postId => (origin as ReactionListNotifierProvider).postId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package