Better loading animation in paginationed list

This commit is contained in:
2025-12-06 13:13:30 +08:00
parent 9d03faf594
commit 38a15bb62a
4 changed files with 197 additions and 148 deletions

View File

@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
@@ -10,7 +11,6 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
@@ -101,8 +101,7 @@ class NotificationListNotifier extends AsyncNotifier<List<SnNotification>>
queryParameters: queryParams, queryParameters: queryParams,
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final notifications = final notifications = response.data
response.data
.map((json) => SnNotification.fromJson(json)) .map((json) => SnNotification.fromJson(json))
.cast<SnNotification>() .cast<SnNotification>()
.toList(); .toList();
@@ -145,15 +144,22 @@ class NotificationSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Refresh unread count when sheet opens to sync across devices // Refresh unread count when sheet opens to sync across devices
useEffect(() {
Future(() {
ref.read(notificationUnreadCountProvider.notifier).refresh(); ref.read(notificationUnreadCountProvider.notifier).refresh();
});
return null;
}, []);
final isLoading = useState(false);
Future<void> markAllRead() async { Future<void> markAllRead() async {
showLoadingModal(context); isLoading.value = true;
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
await apiClient.post('/ring/notifications/all/read'); await apiClient.post('/ring/notifications/all/read');
if (!context.mounted) return; if (!context.mounted) return;
hideLoadingModal(context); isLoading.value = false;
ref.invalidate(notificationListProvider); ref.read(notificationListProvider.notifier).refresh();
ref.watch(notificationUnreadCountProvider.notifier).clear(); ref.watch(notificationUnreadCountProvider.notifier).clear();
} }
@@ -165,6 +171,14 @@ class NotificationSheet extends HookConsumerWidget {
icon: const Icon(Symbols.mark_as_unread), icon: const Icon(Symbols.mark_as_unread),
), ),
], ],
child: Column(
children: [
if (isLoading.value)
LinearProgressIndicator(
minHeight: 2,
color: Theme.of(context).colorScheme.primary,
),
Expanded(
child: PaginationList( child: PaginationList(
provider: notificationListProvider, provider: notificationListProvider,
notifier: notificationListProvider.notifier, notifier: notificationListProvider.notifier,
@@ -175,16 +189,21 @@ class NotificationSheet extends HookConsumerWidget {
return ListTile( return ListTile(
isThreeLine: true, isThreeLine: true,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), contentPadding: EdgeInsets.symmetric(
leading: horizontal: 16,
pfp != null vertical: 8,
),
leading: pfp != null
? ProfilePictureWidget(fileId: pfp, radius: 20) ? ProfilePictureWidget(fileId: pfp, radius: 20)
: CircleAvatar( : CircleAvatar(
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.primaryContainer, context,
).colorScheme.primaryContainer,
child: Icon( child: Icon(
_getNotificationIcon(notification.topic), _getNotificationIcon(notification.topic),
color: Theme.of(context).colorScheme.onPrimaryContainer, color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
), ),
), ),
title: Text(notification.title), title: Text(notification.title),
@@ -197,7 +216,9 @@ class NotificationSheet extends HookConsumerWidget {
spacing: 6, spacing: 6,
children: [ children: [
Text( Text(
DateFormat().format(notification.createdAt.toLocal()), DateFormat().format(
notification.createdAt.toLocal(),
),
).fontSize(11), ).fontSize(11),
Text('·').fontSize(11).bold(), Text('·').fontSize(11).bold(),
Text( Text(
@@ -209,7 +230,8 @@ class NotificationSheet extends HookConsumerWidget {
).opacity(0.75).padding(bottom: 4), ).opacity(0.75).padding(bottom: 4),
MarkdownTextContent( MarkdownTextContent(
content: notification.content, content: notification.content,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( textStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurface.withOpacity(0.8), ).colorScheme.onSurface.withOpacity(0.8),
@@ -221,8 +243,7 @@ class NotificationSheet extends HookConsumerWidget {
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: children: imageIds.map((imageId) {
imageIds.map((imageId) {
return SizedBox( return SizedBox(
width: 80, width: 80,
height: 80, height: 80,
@@ -240,8 +261,7 @@ class NotificationSheet extends HookConsumerWidget {
), ),
], ],
), ),
trailing: trailing: notification.viewedAt != null
notification.viewedAt != null
? null ? null
: Container( : Container(
width: 12, width: 12,
@@ -268,6 +288,9 @@ class NotificationSheet extends HookConsumerWidget {
); );
}, },
), ),
),
],
),
); );
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_riverpod/misc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
@@ -7,6 +8,7 @@ import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:visibility_detector/visibility_detector.dart'; import 'package:visibility_detector/visibility_detector.dart';
@@ -48,8 +50,7 @@ class PaginationList<T> extends HookConsumerWidget {
return isSliver ? SliverFillRemaining(child: content) : content; return isSliver ? SliverFillRemaining(child: content) : content;
} }
final listView = final listView = isSliver
isSliver
? SuperSliverList.builder( ? SuperSliverList.builder(
itemCount: (data.value?.length ?? 0) + 1, itemCount: (data.value?.length ?? 0) + 1,
itemBuilder: (context, idx) { itemBuilder: (context, idx) {
@@ -124,26 +125,40 @@ class PaginationWidget<T> extends HookConsumerWidget {
} }
} }
class PaginationListFooter<T> extends StatelessWidget { class PaginationListFooter<T> extends HookConsumerWidget {
final PaginationController<T> noti; final PaginationController<T> noti;
final AsyncValue<List<T>> data; final AsyncValue<List<T>> data;
final Widget? skeletonChild;
final bool isSliver; final bool isSliver;
const PaginationListFooter({ const PaginationListFooter({
super.key, super.key,
required this.noti, required this.noti,
required this.data, required this.data,
this.skeletonChild,
this.isSliver = false, this.isSliver = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final hasBeenVisible = useState(false);
final placeholder = Skeletonizer(
enabled: true,
child:
skeletonChild ??
ListTile(
title: Text('Some data'),
subtitle: const Text('Subtitle here'),
trailing: const Icon(Icons.ac_unit),
),
);
final child = SizedBox( final child = SizedBox(
height: 64, height: 64,
child: Center( child: Center(
child: child: hasBeenVisible.value
data.isLoading ? data.isLoading
? CircularProgressIndicator() ? placeholder
: Row( : Row(
spacing: 8, spacing: 8,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -151,13 +166,15 @@ class PaginationListFooter<T> extends StatelessWidget {
const Icon(Symbols.close, size: 16), const Icon(Symbols.close, size: 16),
Text('noFurtherData').tr().fontSize(13), Text('noFurtherData').tr().fontSize(13),
], ],
).opacity(0.9), ).opacity(0.9)
: placeholder,
).padding(all: 8), ).padding(all: 8),
); );
return VisibilityDetector( return VisibilityDetector(
key: Key("pagination-list-${noti.hashCode}"), key: Key("pagination-list-${noti.hashCode}"),
onVisibilityChanged: (VisibilityInfo info) { onVisibilityChanged: (VisibilityInfo info) {
hasBeenVisible.value = true;
if (!noti.fetchedAll && !data.isLoading && !data.hasError) { if (!noti.fetchedAll && !data.isLoading && !data.hasError) {
noti.fetchFurther(); noti.fetchFurther();
} }

View File

@@ -2486,6 +2486,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.1" version: "0.2.1"
skeletonizer:
dependency: "direct main"
description:
name: skeletonizer
sha256: "5d2d44120916cc749ede54c236cef60c2478742806df0b1f065212f00721b185"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@@ -170,6 +170,7 @@ dependencies:
flutter_animate: ^4.5.2 flutter_animate: ^4.5.2
http_parser: ^4.1.2 http_parser: ^4.1.2
flutter_code_editor: ^0.3.5 flutter_code_editor: ^0.3.5
skeletonizer: ^2.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: