Replies sheet is back

🗑️ The silver paging helper is merged, remove the one inside the own codebase
This commit is contained in:
2025-06-20 01:11:17 +08:00
parent 89fd80bcb8
commit 8d855867c1
12 changed files with 146 additions and 312 deletions

View File

@ -1,236 +0,0 @@
// ignore_for_file: implementation_imports, invalid_use_of_internal_member
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_paging_utils/src/paging_data.dart';
import 'package:riverpod_paging_utils/src/paging_helper_view_theme.dart';
import 'package:riverpod_paging_utils/src/paging_notifier_mixin.dart';
import 'package:visibility_detector/visibility_detector.dart';
/// A generic widget for pagination.
///
/// Main features:
/// 1. Displays the widget created by [contentBuilder] when data is available.
/// 2. Shows a CircularProgressIndicator while loading the first page.
/// 3. Displays an error widget when there is an error on the first page.
/// 4. Shows error messages using a SnackBar.
/// 5. Loads the next page when the last item is displayed.
/// 6. Supports pull-to-refresh functionality.
///
/// You can customize the appearance of the loading view, error view, and endItemView using [PagingHelperViewTheme].
final class PagingHelperSliverView<D extends PagingData<I>, I>
extends ConsumerWidget {
const PagingHelperSliverView({
required this.provider,
required this.futureRefreshable,
required this.notifierRefreshable,
required this.contentBuilder,
this.showSecondPageError = true,
super.key,
});
final ProviderListenable<AsyncValue<D>> provider;
final Refreshable<Future<D>> futureRefreshable;
final Refreshable<PagingNotifierMixin<D, I>> notifierRefreshable;
/// Specifies a function that returns a widget to display when data is available.
/// endItemView is a widget to detect when the last displayed item is visible.
/// If endItemView is non-null, it is displayed at the end of the list.
final Widget Function(D data, int widgetCount, Widget endItemView)
contentBuilder;
final bool showSecondPageError;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context).extension<PagingHelperViewTheme>();
final loadingBuilder =
theme?.loadingViewBuilder ??
(context) => SliverFillRemaining(
child: const Center(child: CircularProgressIndicator()),
);
final errorBuilder =
theme?.errorViewBuilder ??
(context, e, st, onPressed) => SliverFillRemaining(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
onPressed: onPressed,
icon: const Icon(Icons.refresh),
),
Text(e.toString()),
],
),
),
);
return ref
.watch(provider)
.whenIgnorableError(
data: (
data, {
required hasError,
required isLoading,
required error,
}) {
final content = contentBuilder(
data,
// Add 1 to the length to include the endItemView
data.items.length + 1,
switch ((data.hasMore, hasError, isLoading)) {
// Display a widget to detect when the last element is reached
// if there are more pages and no errors
(true, false, _) => _EndVDLoadingItemView(
onScrollEnd:
() async => ref.read(notifierRefreshable).loadNext(),
),
(true, true, false) when showSecondPageError =>
_EndErrorItemView(
error: error,
onRetryButtonPressed:
() async => ref.read(notifierRefreshable).loadNext(),
),
(true, true, true) => const _EndLoadingItemView(),
_ => const SizedBox.shrink(),
},
);
return content;
},
// Loading state for the first page
loading: () => loadingBuilder(context),
// Error state for the first page
error:
(e, st) => errorBuilder(
context,
e,
st,
() => ref.read(notifierRefreshable).forceRefresh(),
),
// Prioritize data for errors on the second page and beyond
skipErrorOnHasValue: true,
);
}
}
final class _EndLoadingItemView extends StatelessWidget {
const _EndLoadingItemView();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).extension<PagingHelperViewTheme>();
final childBuilder =
theme?.endLoadingViewBuilder ??
(context) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
return childBuilder(context);
}
}
final class _EndVDLoadingItemView extends StatelessWidget {
const _EndVDLoadingItemView({required this.onScrollEnd});
final VoidCallback onScrollEnd;
@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: key ?? const Key('EndItem'),
onVisibilityChanged: (info) {
if (info.visibleFraction > 0.1) {
onScrollEnd();
}
},
child: const _EndLoadingItemView(),
);
}
}
final class _EndErrorItemView extends StatelessWidget {
const _EndErrorItemView({
required this.error,
required this.onRetryButtonPressed,
});
final Object? error;
final VoidCallback onRetryButtonPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).extension<PagingHelperViewTheme>();
final childBuilder =
theme?.endErrorViewBuilder ??
(context, e, onPressed) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
IconButton(
onPressed: onPressed,
icon: const Icon(Icons.refresh),
),
Text(error.toString()),
],
),
),
);
return childBuilder(context, error, onRetryButtonPressed);
}
}
extension _AsyncValueX<T> on AsyncValue<T> {
/// Extends the [when] method to handle async data states more effectively,
/// especially when maintaining data integrity despite errors.
///
/// Use `skipErrorOnHasValue` to retain and display existing data
/// even if subsequent fetch attempts result in errors,
/// ideal for maintaining a seamless user experience.
R whenIgnorableError<R>({
required R Function(
T data, {
required bool hasError,
required bool isLoading,
required Object? error,
})
data,
required R Function(Object error, StackTrace stackTrace) error,
required R Function() loading,
bool skipLoadingOnReload = false,
bool skipLoadingOnRefresh = true,
bool skipError = false,
bool skipErrorOnHasValue = false,
}) {
if (skipErrorOnHasValue) {
if (hasValue && hasError) {
return data(
requireValue,
hasError: true,
isLoading: isLoading,
error: this.error,
);
}
}
return when(
skipLoadingOnReload: skipLoadingOnReload,
skipLoadingOnRefresh: skipLoadingOnRefresh,
skipError: skipError,
data:
(d) => data(
d,
hasError: hasError,
isLoading: isLoading,
error: this.error,
),
error: error,
loading: loading,
);
}
}

View File

@ -213,7 +213,7 @@ class ComposeLogic {
// Prepare API request
final client = ref.watch(apiClientProvider);
final isNewPost = originalPost == null;
final endpoint = isNewPost ? '/posts' : '/posts/${originalPost!.id}';
final endpoint = isNewPost ? '/posts' : '/posts/${originalPost.id}';
// Create request payload
final payload = {

View File

@ -19,6 +19,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_file_collection.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/post_replies_sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
@ -235,18 +236,52 @@ class PostItem extends HookConsumerWidget {
),
],
),
PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.only(left: 48),
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(item.copyWith(reactionsCount: reactionsCount));
},
Row(
children: [
// Replies count button
Padding(
padding: const EdgeInsets.only(left: 48, right: 12),
child: ActionChip(
avatar: Icon(Symbols.reply, size: 16),
label: Text(
(item.repliesCount > 0)
? 'repliesCount'.plural(item.repliesCount)
: 'reply'.tr(),
),
visualDensity: const VisualDensity(
horizontal: VisualDensity.minimumDensity,
vertical: VisualDensity.minimumDensity,
),
onPressed: () {
if (isOpenable) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => PostRepliesSheet(post: item),
);
}
},
),
),
// Reactions list
Expanded(
child: PostReactionList(
parentId: item.id,
reactions: item.reactionsCount,
padding: EdgeInsets.zero,
onReact: (symbol, attitude, delta) {
final reactionsCount = Map<String, int>.from(
item.reactionsCount,
);
reactionsCount[symbol] =
(reactionsCount[symbol] ?? 0) + delta;
onUpdate?.call(
item.copyWith(reactionsCount: reactionsCount),
);
},
),
),
],
),
],
),

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/content/paging_helper_ext.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_creator.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

View File

@ -3,7 +3,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/content/paging_helper_ext.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@ -57,7 +56,8 @@ class PostRepliesNotifier extends _$PostRepliesNotifier
class PostRepliesList extends HookConsumerWidget {
final String postId;
const PostRepliesList({super.key, required this.postId});
final Color? backgroundColor;
const PostRepliesList({super.key, required this.postId, this.backgroundColor});
@override
Widget build(BuildContext context, WidgetRef ref) {
@ -93,7 +93,7 @@ class PostRepliesList extends HookConsumerWidget {
children: [
PostItem(
item: data.items[index],
backgroundColor: isWide ? Colors.transparent : null,
backgroundColor: backgroundColor ?? (isWide ? Colors.transparent : null),
showReferencePost: false,
),
const Divider(height: 1),

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/post_replies.dart';
import 'package:island/widgets/post/post_quick_reply.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:styled_widget/styled_widget.dart';
class PostRepliesSheet extends HookConsumerWidget {
final SnPost post;
const PostRepliesSheet({super.key, required this.post});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SheetScaffold(
titleText: 'repliesCount'.plural(post.repliesCount),
child: Column(
children: [
// Replies list
Expanded(
child: CustomScrollView(
slivers: [PostRepliesList(
postId: post.id.toString(),
backgroundColor: Colors.transparent,
)],
),
),
// Quick reply section
Material(
elevation: 2,
child: PostQuickReply(
parent: post,
onPosted: () {
ref.invalidate(postRepliesNotifierProvider(post.id));
},
).padding(
bottom: MediaQuery.of(context).padding.bottom + 16,
top: 16,
horizontal: 16,
),
),
],
),
);
}
}