From 319d5c7d7ff473922606eee3e7054d0e0d801108 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Fri, 31 Jan 2025 20:12:46 +0800 Subject: [PATCH] :recycle: Refactor notification indicator --- api/Passport/Developer Notify One User.bru | 26 +++ lib/providers/notification.dart | 10 +- lib/screens/account.dart | 14 +- lib/screens/notification.dart | 19 ++- lib/widgets/markdown_content.dart | 3 + lib/widgets/notify_indicator.dart | 183 +++++++++++++++++---- lib/widgets/post/post_item.dart | 1 - lib/widgets/post/post_reaction.dart | 2 +- 8 files changed, 212 insertions(+), 46 deletions(-) create mode 100644 api/Passport/Developer Notify One User.bru diff --git a/api/Passport/Developer Notify One User.bru b/api/Passport/Developer Notify One User.bru new file mode 100644 index 0000000..51522d0 --- /dev/null +++ b/api/Passport/Developer Notify One User.bru @@ -0,0 +1,26 @@ +meta { + name: Developer Notify One User + type: http + seq: 2 +} + +post { + url: {{endpoint}}/cgi/id/dev/notify/1 + body: json + auth: inherit +} + +body:json { + { + "client_id": "{{third_client_id}}", + "client_secret":"{{third_client_tk}}", + "type": "general", + "subject": "测试", + "subtitle": "Alphabot です", + "content": "全新通知动画", + "metadata": { + "image": "D2EDbcrsTugs3xk5" + }, + "priority": 10 + } +} diff --git a/lib/providers/notification.dart b/lib/providers/notification.dart index 55197dc..60cb15a 100644 --- a/lib/providers/notification.dart +++ b/lib/providers/notification.dart @@ -71,22 +71,28 @@ class NotificationProvider extends ChangeNotifier { ); } + int showingCount = 0; List notifications = List.empty(growable: true); void listen() { _ws.stream.stream.listen((event) { if (event.method == 'notifications.new') { final notification = SnNotification.fromJson(event.payload!); + showingCount++; notifications.add(notification); + Future.delayed(const Duration(seconds: 3), () { + if (showingCount >= 0) showingCount--; + notifyListeners(); + }); notifyListeners(); final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true; - if (doHaptic) HapticFeedback.lightImpact(); + if (doHaptic) HapticFeedback.mediumImpact(); } }); } void clear() { - notifications.clear(); + showingCount = 0; notifyListeners(); } } diff --git a/lib/screens/account.dart b/lib/screens/account.dart index fce2792..e7b18ba 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -28,7 +28,19 @@ class AccountScreen extends StatelessWidget { return AppScaffold( appBar: AppBar( leading: AutoAppBarLeading(), - title: Text("screenAccount").tr(), + title: Text( + "screenAccount", + style: TextStyle( + color: Colors.white, + shadows: [ + Shadow( + offset: Offset(1, 1), + blurRadius: 5.0, + color: Color.fromARGB(255, 0, 0, 0), + ), + ], + ), + ).tr(), flexibleSpace: ua.user != null && ua.user!.banner.isNotEmpty ? Stack( fit: StackFit.expand, diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index 9af48cf..64cb4e8 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -21,6 +21,16 @@ import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import '../providers/userinfo.dart'; import '../widgets/unauthorized_hint.dart'; +const Map kNotificationTopicIcons = { + 'general': Symbols.notifications, + 'passport.security.alert': Symbols.gpp_maybe, + 'passport.security.otp': Symbols.password, + 'interactive.subscription': Symbols.subscriptions, + 'interactive.feedback': Symbols.add_reaction, + 'messaging.callStart': Symbols.call_received, + 'wallet.transaction.new': Symbols.receipt, +}; + class NotificationScreen extends StatefulWidget { const NotificationScreen({super.key}); @@ -36,15 +46,6 @@ class _NotificationScreenState extends State { final List _notifications = List.empty(growable: true); int? _totalCount; - static const Map kNotificationTopicIcons = { - 'passport.security.alert': Symbols.gpp_maybe, - 'passport.security.otp': Symbols.password, - 'interactive.subscription': Symbols.subscriptions, - 'interactive.feedback': Symbols.add_reaction, - 'messaging.callStart': Symbols.call_received, - 'wallet.transaction.new': Symbols.receipt, - }; - Future _fetchNotifications() async { final ua = context.read(); if (!ua.isAuthorized) return; diff --git a/lib/widgets/markdown_content.dart b/lib/widgets/markdown_content.dart index edf9f2a..8c614fb 100644 --- a/lib/widgets/markdown_content.dart +++ b/lib/widgets/markdown_content.dart @@ -20,6 +20,7 @@ class MarkdownTextContent extends StatelessWidget { final bool isAutoWarp; final bool isEnlargeSticker; final TextScaler? textScaler; + final Color? textColor; final List? attachments; const MarkdownTextContent({ @@ -28,6 +29,7 @@ class MarkdownTextContent extends StatelessWidget { this.isAutoWarp = false, this.isEnlargeSticker = false, this.textScaler, + this.textColor, this.attachments, }); @@ -42,6 +44,7 @@ class MarkdownTextContent extends StatelessWidget { Theme.of(context), ).copyWith( textScaler: textScaler, + p: textColor != null ? Theme.of(context).textTheme.bodyMedium!.copyWith(color: textColor) : null, blockquote: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, ), diff --git a/lib/widgets/notify_indicator.dart b/lib/widgets/notify_indicator.dart index b1aae5d..5f03faa 100644 --- a/lib/widgets/notify_indicator.dart +++ b/lib/widgets/notify_indicator.dart @@ -1,60 +1,179 @@ +import 'dart:math' show min; + import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:gap/gap.dart'; -import 'package:material_symbols_icons/symbols.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import 'package:surface/providers/notification.dart'; +import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/userinfo.dart'; +import 'package:surface/screens/notification.dart'; +import 'package:surface/types/notification.dart'; +import 'package:surface/widgets/dialog.dart'; +import 'package:surface/widgets/universal_image.dart'; -class NotifyIndicator extends StatelessWidget { +import 'markdown_content.dart'; + +class NotifyIndicator extends StatefulWidget { const NotifyIndicator({super.key}); + @override + State createState() => _NotifyIndicatorState(); +} + +class _NotifyIndicatorState extends State with SingleTickerProviderStateMixin { + late final AnimationController _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + + void _markOneAsRead(SnNotification notification) async { + final ua = context.read(); + if (!ua.isAuthorized) return; + + if (notification.id == 0) return; + if (notification.readAt != null) return; + + try { + final sn = context.read(); + await sn.client.put('/cgi/id/notifications/read/${notification.id}'); + + if (!mounted) return; + context.showSnackbar( + 'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']), + ); + } catch (err) { + if (!mounted) return; + context.showErrorDialog(err); + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final sn = context.read(); final ua = context.read(); final nty = context.watch(); - final show = nty.notifications.isNotEmpty && ua.isAuthorized; + final show = nty.showingCount > 0 && ua.isAuthorized; + + if (show) { + _animationController.animateTo(1); + } else { + _animationController.animateTo(0); + } return ListenableBuilder( listenable: nty, builder: (context, _) { + final current = nty.notifications.lastOrNull; + return IgnorePointer( ignoring: !show, child: GestureDetector( - child: Material( - elevation: 2, - shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(16))), - color: Theme.of(context).colorScheme.secondaryContainer, - child: ua.isAuthorized - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - nty.notifications.lastOrNull?.title ?? - 'notificationUnreadCount'.plural(nty.notifications.length), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (nty.notifications.lastOrNull?.body != null) - Text( - nty.notifications.lastOrNull!.body, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ).padding(left: 4), - const Gap(8), - const Icon(Symbols.notifications_unread, size: 18), - ], - ).padding(horizontal: 8, vertical: 4) - : const SizedBox.shrink(), - ).opacity(show ? 1 : 0, animate: true).animate( - const Duration(milliseconds: 300), - Curves.easeInOut, + child: Animate( + controller: _animationController, + effects: [ + SlideEffect( + begin: Offset(0, -1), + end: Offset(0, 0), + duration: Duration(milliseconds: 300), + curve: Curves.fastEaseInToSlowEaseOut, ), + FadeEffect( + begin: 0.0, + end: 1.0, + duration: Duration(milliseconds: 300), + curve: Curves.easeInOut, + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16), + width: double.infinity, + constraints: BoxConstraints( + maxWidth: min(480, MediaQuery.of(context).size.width - 16), + ), + child: Material( + elevation: 2, + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.surfaceContainer, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (current?.metadata['avatar'] != null) + CircleAvatar( + radius: 14, + backgroundImage: UniversalImage.provider( + sn.getAttachmentUrl(current!.metadata['avatar']), + ), + ) + else + Icon(kNotificationTopicIcons[current?.topic] ?? Symbols.notifications), + const Gap(16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + current?.title ?? 'Notification', + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + if (current?.subtitle?.isNotEmpty ?? false) + Text( + current!.subtitle!, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.bold, + ), + ), + MarkdownTextContent( + content: current?.body ?? '', + isAutoWarp: true, + ), + ], + ), + ), + const Gap(16), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(DateFormat('HH:mm').format(current?.createdAt.toLocal() ?? DateTime.now())) + .fontSize(12) + .padding(right: 2), + const Gap(6), + if (current?.metadata['image'] != null) + SizedBox( + width: 40, + height: 40, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AutoResizeUniversalImage( + sn.getAttachmentUrl(current?.metadata['image']), + fit: BoxFit.cover, + ), + ), + ), + ], + ), + ], + ).padding(horizontal: 16, vertical: 12), + ), + ), + ), onTap: () { nty.clear(); + if (current != null) { + _markOneAsRead(current); + } }, ), ); diff --git a/lib/widgets/post/post_item.dart b/lib/widgets/post/post_item.dart index 1b08e3d..4019499 100644 --- a/lib/widgets/post/post_item.dart +++ b/lib/widgets/post/post_item.dart @@ -1,4 +1,3 @@ -import 'dart:developer'; import 'dart:io'; import 'dart:math' as math; diff --git a/lib/widgets/post/post_reaction.dart b/lib/widgets/post/post_reaction.dart index d2879a7..a46469c 100644 --- a/lib/widgets/post/post_reaction.dart +++ b/lib/widgets/post/post_reaction.dart @@ -61,7 +61,7 @@ class _PostReactionPopupState extends State { ); } } - HapticFeedback.mediumImpact(); + HapticFeedback.heavyImpact(); } catch (err) { // ignore: use_build_context_synchronously if (context.mounted) context.showErrorDialog(err);