import 'dart:async'; 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:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/models/account.dart'; import 'package:island/pods/network.dart'; import 'package:island/pods/paging.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/notification_tile.dart'; import 'package:island/widgets/paging/pagination_list.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'; part 'notification.g.dart'; class SkeletonNotificationTile extends StatelessWidget { const SkeletonNotificationTile({super.key}); @override Widget build(BuildContext context) { const fakeTitle = 'New notification'; const fakeSubtitle = 'You have a new message from someone'; const fakeContent = 'This is a preview of the notification content. It may contain formatted text.'; const List fakeImageIds = []; // Empty list for no images const String? fakePfp = null; // No profile picture return ListTile( isThreeLine: true, contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), leading: fakePfp != null ? ProfilePictureWidget(fileId: fakePfp, radius: 20) : CircleAvatar( backgroundColor: Theme.of(context).colorScheme.primaryContainer, child: Icon( Symbols.notifications, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), title: const Text(fakeTitle), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(fakeSubtitle).bold(), Row( spacing: 6, children: [ Text('Loading...').fontSize(11), Skeleton.ignore(child: Text('ยท').fontSize(11).bold()), Text('Now').fontSize(11), ], ).opacity(0.75).padding(bottom: 4), MarkdownTextContent( content: fakeContent, textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8), ), ), if (fakeImageIds.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Wrap( spacing: 8, runSpacing: 8, children: fakeImageIds.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: Container( width: 12, height: 12, decoration: const BoxDecoration( color: Colors.blue, shape: BoxShape.circle, ), ), onTap: () {}, ); } } @riverpod class NotificationUnreadCountNotifier extends _$NotificationUnreadCountNotifier { StreamSubscription? _subscription; @override Future build() async { // Subscribe to websocket events when this provider is built _subscribeToWebSocket(); // Dispose the subscription when this provider is disposed ref.onDispose(() { _subscription?.cancel(); }); try { final client = ref.read(apiClientProvider); final response = await client.get('/ring/notifications/count'); return (response.data as num).toInt(); } catch (_) { return 0; } } void _subscribeToWebSocket() { final webSocketService = ref.read(websocketProvider); _subscription = webSocketService.dataStream.listen((packet) { if (packet.type == 'notifications.new' && packet.data != null) { final notification = SnNotification.fromJson(packet.data!); if (notification.topic != 'messages.new') _incrementCounter(); } }); } Future _incrementCounter() async { final current = await future; state = AsyncData(current + 1); } Future decrement(int count) async { final current = await future; state = AsyncData(math.max(current - count, 0)); } void clear() async { state = AsyncData(0); } Future refresh() async { try { final client = ref.read(apiClientProvider); final response = await client.get('/ring/notifications/count'); state = AsyncData((response.data as num).toInt()); } catch (_) { // Keep the current state if refresh fails } } } final notificationListProvider = AsyncNotifierProvider.autoDispose( NotificationListNotifier.new, ); class NotificationListNotifier extends AsyncNotifier> with AsyncPaginationController { static const int pageSize = 5; @override Future> fetch() async { final client = ref.read(apiClientProvider); final queryParams = {'offset': fetchedCount.toString(), 'take': pageSize}; final response = await client.get( '/ring/notifications', queryParameters: queryParams, ); totalCount = int.parse(response.headers.value('X-Total') ?? '0'); 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); return notifications; } } class NotificationSheet extends HookConsumerWidget { const NotificationSheet({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { // Refresh unread count when sheet opens to sync across devices useEffect(() { Future(() { ref.read(notificationUnreadCountProvider.notifier).refresh(); }); return null; }, []); final isLoading = useState(false); Future markAllRead() async { isLoading.value = true; final apiClient = ref.watch(apiClientProvider); await apiClient.post('/ring/notifications/all/read'); if (!context.mounted) return; isLoading.value = false; ref.read(notificationListProvider.notifier).refresh(); ref.watch(notificationUnreadCountProvider.notifier).clear(); } return SheetScaffold( titleText: 'notifications'.tr(), actions: [ IconButton( onPressed: markAllRead, 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( provider: notificationListProvider, notifier: notificationListProvider.notifier, footerSkeletonChild: const SkeletonNotificationTile(), itemBuilder: (context, index, notification) { return NotificationTile(notification: notification); }, ), ), ], ), ); } }