From 38a15bb62af5f36ec7f7e036b69ab7615e3c00e8 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 6 Dec 2025 13:13:30 +0800 Subject: [PATCH] :sparkles: Better loading animation in paginationed list --- lib/screens/notification.dart | 243 +++++++++++++----------- lib/widgets/paging/pagination_list.dart | 93 +++++---- pubspec.lock | 8 + pubspec.yaml | 1 + 4 files changed, 197 insertions(+), 148 deletions(-) diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index 00ca8d66..c427cb97 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -3,6 +3,7 @@ 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:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.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/websocket.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/markdown.dart'; import 'package:island/widgets/content/sheet.dart'; @@ -101,11 +101,10 @@ class NotificationListNotifier extends AsyncNotifier> queryParameters: queryParams, ); totalCount = int.parse(response.headers.value('X-Total') ?? '0'); - final notifications = - response.data - .map((json) => SnNotification.fromJson(json)) - .cast() - .toList(); + final notifications = response.data + .map((json) => SnNotification.fromJson(json)) + .cast() + .toList(); final unreadCount = notifications.where((n) => n.viewedAt == null).length; ref.read(notificationUnreadCountProvider.notifier).decrement(unreadCount); @@ -145,15 +144,22 @@ class NotificationSheet extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { // Refresh unread count when sheet opens to sync across devices - ref.read(notificationUnreadCountProvider.notifier).refresh(); + useEffect(() { + Future(() { + ref.read(notificationUnreadCountProvider.notifier).refresh(); + }); + return null; + }, []); + + final isLoading = useState(false); Future markAllRead() async { - showLoadingModal(context); + isLoading.value = true; final apiClient = ref.watch(apiClientProvider); await apiClient.post('/ring/notifications/all/read'); if (!context.mounted) return; - hideLoadingModal(context); - ref.invalidate(notificationListProvider); + isLoading.value = false; + ref.read(notificationListProvider.notifier).refresh(); ref.watch(notificationUnreadCountProvider.notifier).clear(); } @@ -165,108 +171,125 @@ class NotificationSheet extends HookConsumerWidget { icon: const Icon(Symbols.mark_as_unread), ), ], - child: PaginationList( - provider: notificationListProvider, - notifier: notificationListProvider.notifier, - itemBuilder: (context, index, notification) { - final pfp = notification.meta['pfp'] as String?; - final images = notification.meta['images'] as List?; - final imageIds = images?.cast() ?? []; - - return ListTile( - isThreeLine: true, - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - leading: - pfp != null - ? ProfilePictureWidget(fileId: pfp, radius: 20) - : CircleAvatar( - backgroundColor: - Theme.of(context).colorScheme.primaryContainer, - child: Icon( - _getNotificationIcon(notification.topic), - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - title: Text(notification.title), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (notification.subtitle.isNotEmpty) - Text(notification.subtitle).bold(), - Row( - spacing: 6, - children: [ - Text( - DateFormat().format(notification.createdAt.toLocal()), - ).fontSize(11), - Text('·').fontSize(11).bold(), - Text( - RelativeTime( - context, - ).format(notification.createdAt.toLocal()), - ).fontSize(11), - ], - ).opacity(0.75).padding(bottom: 4), - MarkdownTextContent( - content: notification.content, - textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withOpacity(0.8), - ), - ), - if (imageIds.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: - imageIds.map((imageId) { - return SizedBox( - width: 80, - height: 80, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CloudImageWidget( - fileId: imageId, - aspectRatio: 1, - fit: BoxFit.cover, - ), - ), - ); - }).toList(), - ), - ), - ], + child: Column( + children: [ + if (isLoading.value) + LinearProgressIndicator( + minHeight: 2, + color: Theme.of(context).colorScheme.primary, ), - trailing: - notification.viewedAt != null - ? null - : Container( - width: 12, - height: 12, - decoration: const BoxDecoration( - color: Colors.blue, - shape: BoxShape.circle, + Expanded( + child: PaginationList( + provider: notificationListProvider, + notifier: notificationListProvider.notifier, + itemBuilder: (context, index, notification) { + final pfp = notification.meta['pfp'] as String?; + final images = notification.meta['images'] as List?; + final imageIds = images?.cast() ?? []; + + return ListTile( + isThreeLine: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + leading: pfp != null + ? ProfilePictureWidget(fileId: pfp, radius: 20) + : CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primaryContainer, + child: Icon( + _getNotificationIcon(notification.topic), + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + ), + ), + title: Text(notification.title), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (notification.subtitle.isNotEmpty) + Text(notification.subtitle).bold(), + Row( + spacing: 6, + children: [ + Text( + DateFormat().format( + notification.createdAt.toLocal(), + ), + ).fontSize(11), + Text('·').fontSize(11).bold(), + Text( + RelativeTime( + context, + ).format(notification.createdAt.toLocal()), + ).fontSize(11), + ], + ).opacity(0.75).padding(bottom: 4), + MarkdownTextContent( + content: notification.content, + textStyle: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withOpacity(0.8), + ), ), - ), - onTap: () { - if (notification.meta['action_uri'] != null) { - var uri = notification.meta['action_uri'] as String; - if (uri.startsWith('/')) { - // In-app routes - rootNavigatorKey.currentContext?.push( - notification.meta['action_uri'], - ); - } else { - // External URLs - launchUrlString(uri); - } - } - }, - ); - }, + if (imageIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: imageIds.map((imageId) { + return SizedBox( + width: 80, + height: 80, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CloudImageWidget( + fileId: imageId, + aspectRatio: 1, + fit: BoxFit.cover, + ), + ), + ); + }).toList(), + ), + ), + ], + ), + trailing: notification.viewedAt != null + ? null + : Container( + width: 12, + height: 12, + decoration: const BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + ), + onTap: () { + if (notification.meta['action_uri'] != null) { + var uri = notification.meta['action_uri'] as String; + if (uri.startsWith('/')) { + // In-app routes + rootNavigatorKey.currentContext?.push( + notification.meta['action_uri'], + ); + } else { + // External URLs + launchUrlString(uri); + } + } + }, + ); + }, + ), + ), + ], ), ); } diff --git a/lib/widgets/paging/pagination_list.dart b/lib/widgets/paging/pagination_list.dart index 12f8bcb5..3fd6d3d7 100644 --- a/lib/widgets/paging/pagination_list.dart +++ b/lib/widgets/paging/pagination_list.dart @@ -1,5 +1,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_riverpod/misc.dart'; import 'package:hooks_riverpod/hooks_riverpod.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:material_symbols_icons/material_symbols_icons.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:skeletonizer/skeletonizer.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -48,31 +50,30 @@ class PaginationList extends HookConsumerWidget { return isSliver ? SliverFillRemaining(child: content) : content; } - final listView = - isSliver - ? SuperSliverList.builder( - itemCount: (data.value?.length ?? 0) + 1, - itemBuilder: (context, idx) { - if (idx == data.value?.length) { - return PaginationListFooter(noti: noti, data: data); - } - final entry = data.value?[idx]; - if (entry != null) return itemBuilder(context, idx, entry); - return null; - }, - ) - : SuperListView.builder( - padding: padding, - itemCount: (data.value?.length ?? 0) + 1, - itemBuilder: (context, idx) { - if (idx == data.value?.length) { - return PaginationListFooter(noti: noti, data: data); - } - final entry = data.value?[idx]; - if (entry != null) return itemBuilder(context, idx, entry); - return null; - }, - ); + final listView = isSliver + ? SuperSliverList.builder( + itemCount: (data.value?.length ?? 0) + 1, + itemBuilder: (context, idx) { + if (idx == data.value?.length) { + return PaginationListFooter(noti: noti, data: data); + } + final entry = data.value?[idx]; + if (entry != null) return itemBuilder(context, idx, entry); + return null; + }, + ) + : SuperListView.builder( + padding: padding, + itemCount: (data.value?.length ?? 0) + 1, + itemBuilder: (context, idx) { + if (idx == data.value?.length) { + return PaginationListFooter(noti: noti, data: data); + } + final entry = data.value?[idx]; + if (entry != null) return itemBuilder(context, idx, entry); + return null; + }, + ); return isRefreshable ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView) @@ -124,40 +125,56 @@ class PaginationWidget extends HookConsumerWidget { } } -class PaginationListFooter extends StatelessWidget { +class PaginationListFooter extends HookConsumerWidget { final PaginationController noti; final AsyncValue> data; + final Widget? skeletonChild; final bool isSliver; const PaginationListFooter({ super.key, required this.noti, required this.data, + this.skeletonChild, this.isSliver = false, }); @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( height: 64, child: Center( - child: - data.isLoading - ? CircularProgressIndicator() - : Row( - spacing: 8, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Symbols.close, size: 16), - Text('noFurtherData').tr().fontSize(13), - ], - ).opacity(0.9), + child: hasBeenVisible.value + ? data.isLoading + ? placeholder + : Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Symbols.close, size: 16), + Text('noFurtherData').tr().fontSize(13), + ], + ).opacity(0.9) + : placeholder, ).padding(all: 8), ); return VisibilityDetector( key: Key("pagination-list-${noti.hashCode}"), onVisibilityChanged: (VisibilityInfo info) { + hasBeenVisible.value = true; if (!noti.fetchedAll && !data.isLoading && !data.hasError) { noti.fetchFurther(); } diff --git a/pubspec.lock b/pubspec.lock index 008bee77..900ea86d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -2486,6 +2486,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index b879463f..1cce0e93 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -170,6 +170,7 @@ dependencies: flutter_animate: ^4.5.2 http_parser: ^4.1.2 flutter_code_editor: ^0.3.5 + skeletonizer: ^2.1.1 dev_dependencies: flutter_test: