224 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			224 lines
		
	
	
		
			7.6 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
import 'dart:async';
 | 
						|
import 'dart:math' as math;
 | 
						|
 | 
						|
import 'package:easy_localization/easy_localization.dart';
 | 
						|
import 'package:flutter/material.dart';
 | 
						|
import 'package:go_router/go_router.dart';
 | 
						|
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
						|
import 'package:island/models/account.dart';
 | 
						|
import 'package:island/pods/network.dart';
 | 
						|
import 'package:island/pods/websocket.dart';
 | 
						|
import 'package:island/route.dart';
 | 
						|
import 'package:island/widgets/alert.dart';
 | 
						|
import 'package:island/widgets/content/markdown.dart';
 | 
						|
import 'package:island/widgets/content/sheet.dart';
 | 
						|
import 'package:material_symbols_icons/material_symbols_icons.dart';
 | 
						|
import 'package:relative_time/relative_time.dart';
 | 
						|
import 'package:riverpod_annotation/riverpod_annotation.dart';
 | 
						|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
 | 
						|
import 'package:styled_widget/styled_widget.dart';
 | 
						|
import 'package:url_launcher/url_launcher_string.dart';
 | 
						|
 | 
						|
part 'notification.g.dart';
 | 
						|
 | 
						|
@riverpod
 | 
						|
class NotificationUnreadCountNotifier
 | 
						|
    extends _$NotificationUnreadCountNotifier {
 | 
						|
  StreamSubscription<WebSocketPacket>? _subscription;
 | 
						|
 | 
						|
  @override
 | 
						|
  Future<int> 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<void> _incrementCounter() async {
 | 
						|
    final current = await future;
 | 
						|
    state = AsyncData(current + 1);
 | 
						|
  }
 | 
						|
 | 
						|
  Future<void> decrement(int count) async {
 | 
						|
    final current = await future;
 | 
						|
    state = AsyncData(math.max(current - count, 0));
 | 
						|
  }
 | 
						|
 | 
						|
  void clear() async {
 | 
						|
    state = AsyncData(0);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
@riverpod
 | 
						|
class NotificationListNotifier extends _$NotificationListNotifier
 | 
						|
    with CursorPagingNotifierMixin<SnNotification> {
 | 
						|
  static const int _pageSize = 5;
 | 
						|
 | 
						|
  @override
 | 
						|
  Future<CursorPagingData<SnNotification>> build() => fetch(cursor: null);
 | 
						|
 | 
						|
  @override
 | 
						|
  Future<CursorPagingData<SnNotification>> fetch({
 | 
						|
    required String? cursor,
 | 
						|
  }) async {
 | 
						|
    final client = ref.read(apiClientProvider);
 | 
						|
    final offset = cursor == null ? 0 : int.parse(cursor);
 | 
						|
 | 
						|
    final queryParams = {'offset': offset, 'take': _pageSize};
 | 
						|
 | 
						|
    final response = await client.get(
 | 
						|
      '/ring/notifications',
 | 
						|
      queryParameters: queryParams,
 | 
						|
    );
 | 
						|
    final total = int.parse(response.headers.value('X-Total') ?? '0');
 | 
						|
    final List<dynamic> data = response.data;
 | 
						|
    final notifications =
 | 
						|
        data.map((json) => SnNotification.fromJson(json)).toList();
 | 
						|
 | 
						|
    final hasMore = offset + notifications.length < total;
 | 
						|
    final nextCursor =
 | 
						|
        hasMore ? (offset + notifications.length).toString() : null;
 | 
						|
    final unreadCount = notifications.where((n) => n.viewedAt == null).length;
 | 
						|
    ref
 | 
						|
        .read(notificationUnreadCountNotifierProvider.notifier)
 | 
						|
        .decrement(unreadCount);
 | 
						|
 | 
						|
    return CursorPagingData(
 | 
						|
      items: notifications,
 | 
						|
      hasMore: hasMore,
 | 
						|
      nextCursor: nextCursor,
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
class NotificationSheet extends HookConsumerWidget {
 | 
						|
  const NotificationSheet({super.key});
 | 
						|
 | 
						|
  @override
 | 
						|
  Widget build(BuildContext context, WidgetRef ref) {
 | 
						|
    Future<void> markAllRead() async {
 | 
						|
      showLoadingModal(context);
 | 
						|
      final apiClient = ref.watch(apiClientProvider);
 | 
						|
      await apiClient.post('/ring/notifications/all/read');
 | 
						|
      if (!context.mounted) return;
 | 
						|
      hideLoadingModal(context);
 | 
						|
      ref.invalidate(notificationListNotifierProvider);
 | 
						|
      ref.watch(notificationUnreadCountNotifierProvider.notifier).clear();
 | 
						|
    }
 | 
						|
 | 
						|
    return SheetScaffold(
 | 
						|
      titleText: 'notifications'.tr(),
 | 
						|
      actions: [
 | 
						|
        IconButton(
 | 
						|
          onPressed: markAllRead,
 | 
						|
          icon: const Icon(Symbols.mark_as_unread),
 | 
						|
        ),
 | 
						|
      ],
 | 
						|
      child: PagingHelperView(
 | 
						|
        provider: notificationListNotifierProvider,
 | 
						|
        futureRefreshable: notificationListNotifierProvider.future,
 | 
						|
        notifierRefreshable: notificationListNotifierProvider.notifier,
 | 
						|
        contentBuilder:
 | 
						|
            (data, widgetCount, endItemView) => ListView.builder(
 | 
						|
              padding: EdgeInsets.zero,
 | 
						|
              itemCount: widgetCount,
 | 
						|
              itemBuilder: (context, index) {
 | 
						|
                if (index == widgetCount - 1) {
 | 
						|
                  return endItemView;
 | 
						|
                }
 | 
						|
 | 
						|
                final notification = data.items[index];
 | 
						|
                return ListTile(
 | 
						|
                  isThreeLine: true,
 | 
						|
                  contentPadding: EdgeInsets.symmetric(
 | 
						|
                    horizontal: 16,
 | 
						|
                    vertical: 8,
 | 
						|
                  ),
 | 
						|
                  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),
 | 
						|
                        ),
 | 
						|
                      ),
 | 
						|
                    ],
 | 
						|
                  ),
 | 
						|
                  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);
 | 
						|
                      }
 | 
						|
                    }
 | 
						|
                  },
 | 
						|
                );
 | 
						|
              },
 | 
						|
            ),
 | 
						|
      ),
 | 
						|
    );
 | 
						|
  }
 | 
						|
}
 |