From 3a2894b533b3ecb880f2b2f937e97aa679f0583f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Wed, 24 Apr 2024 23:19:26 +0800 Subject: [PATCH] :sparkles: Notifications --- ...icon-rounded-padded.png => icon-macos.png} | Bin lib/i18n/app_en.arb | 4 + lib/i18n/app_zh.arb | 11 ++ lib/main.dart | 5 +- lib/models/notification.dart | 12 +- lib/providers/notify.dart | 56 ++++++ lib/router.dart | 6 + lib/screens/explore.dart | 2 + lib/screens/notification.dart | 177 ++++++++++++++++++ lib/widgets/chat/maintainer.dart | 5 +- lib/widgets/chat/message_editor.dart | 2 +- lib/widgets/notification_notifier.dart | 90 +++++++++ pubspec.yaml | 2 +- 13 files changed, 362 insertions(+), 10 deletions(-) rename assets/{icon-rounded-padded.png => icon-macos.png} (100%) create mode 100644 lib/providers/notify.dart create mode 100644 lib/screens/notification.dart create mode 100644 lib/widgets/notification_notifier.dart diff --git a/assets/icon-rounded-padded.png b/assets/icon-macos.png similarity index 100% rename from assets/icon-rounded-padded.png rename to assets/icon-macos.png diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index 5480722..f3d2b10 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -26,6 +26,10 @@ "report": "Report", "reply": "Reply", "settings": "Settings", + "notification": "Notification", + "notifyDone": "You're done!", + "notifyDoneCaption": "There are no notifications unread for you.", + "notifyListHint": "Pull to refresh, swipe to dismiss", "reaction": "Reaction", "reactVerb": "React", "post": "Post", diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index 23fa963..01561ea 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -26,6 +26,10 @@ "report": "举报", "reply": "回复", "settings": "设置", + "notification": "通知", + "notifyDone": "所有通知已读!", + "notifyDoneCaption": "这里没有什么东西可以给你看的了~", + "notifyListHint": "下拉以刷新,左滑来已读", "reaction": "反应", "reactVerb": "作出反应", "post": "帖子", @@ -49,6 +53,13 @@ "chatNew": "新聊天", "chatNewCreate": "新建频道", "chatNewJoin": "加入已有频道", + "chatChannelUsage": "频道", + "chatChannelUsageCaption": "频道是一个地方供你聊天,跟一个人,或者一堆人", + "chatChannelOrganize": "组织频道", + "chatChannelEditNotify": "你正在编辑一个已经存在的频道……", + "chatChannelAliasLabel": "频道别名", + "chatChannelNameLabel": "频道名称", + "chatChannelDescriptionLabel": "频道简介", "chatMessagePlaceholder": "发条消息……", "chatMessageEditNotify": "你正在编辑信息中……", "chatMessageReplyNotify": "你正在回复消息中……", diff --git a/lib/main.dart b/lib/main.dart index cee130b..fff3f97 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,10 +3,12 @@ import 'package:provider/provider.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/providers/chat.dart'; import 'package:solian/providers/navigation.dart'; +import 'package:solian/providers/notify.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/timeago.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:solian/utils/video_player.dart'; +import 'package:solian/widgets/notification_notifier.dart'; void main() { initVideo(); @@ -39,8 +41,9 @@ class SolianApp extends StatelessWidget { Provider(create: (_) => NavigationProvider()), Provider(create: (_) => AuthProvider()), Provider(create: (_) => ChatProvider()), + ChangeNotifierProvider(create: (_) => NotifyProvider()), ], - child: child, + child: NotificationNotifier(child: child ?? Container()), ); }) ], diff --git a/lib/models/notification.dart b/lib/models/notification.dart index e8cd55f..dd80529 100755 --- a/lib/models/notification.dart +++ b/lib/models/notification.dart @@ -7,6 +7,7 @@ class Notification { String content; List? links; bool isImportant; + bool isRealtime; DateTime? readAt; int senderId; int recipientId; @@ -20,6 +21,7 @@ class Notification { required this.content, this.links, required this.isImportant, + required this.isRealtime, this.readAt, required this.senderId, required this.recipientId, @@ -32,10 +34,9 @@ class Notification { deletedAt: json["deleted_at"], subject: json["subject"], content: json["content"], - links: json["links"] != null - ? List.from(json["links"].map((x) => Link.fromJson(x))) - : List.empty(), + links: json["links"] != null ? List.from(json["links"].map((x) => Link.fromJson(x))) : List.empty(), isImportant: json["is_important"], + isRealtime: json["is_realtime"], readAt: json["read_at"], senderId: json["sender_id"], recipientId: json["recipient_id"], @@ -48,10 +49,9 @@ class Notification { "deleted_at": deletedAt, "subject": subject, "content": content, - "links": links != null - ? List.from(links!.map((x) => x.toJson())) - : List.empty(), + "links": links != null ? List.from(links!.map((x) => x.toJson())) : List.empty(), "is_important": isImportant, + "is_realtime": isRealtime, "read_at": readAt, "sender_id": senderId, "recipient_id": recipientId, diff --git a/lib/providers/notify.dart b/lib/providers/notify.dart new file mode 100644 index 0000000..d2c06cf --- /dev/null +++ b/lib/providers/notify.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:solian/models/pagination.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/utils/service_url.dart'; +import 'package:solian/models/notification.dart' as model; +import 'package:web_socket_channel/web_socket_channel.dart'; + +class NotifyProvider extends ChangeNotifier { + bool isOpened = false; + + List notifications = List.empty(growable: true); + + Future fetch(AuthProvider auth) async { + if (!await auth.isAuthorized()) return; + + var uri = getRequestUri('passport', '/api/notifications?skip=0&take=25'); + var res = await auth.client!.get(uri); + if (res.statusCode == 200) { + final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes))); + notifications = result.data?.map((x) => model.Notification.fromJson(x)).toList() ?? List.empty(growable: true); + } + + notifyListeners(); + } + + Future connect(AuthProvider auth) async { + if (auth.client == null) await auth.pickClient(); + if (!await auth.isAuthorized()) return null; + + await auth.refreshToken(); + + var ori = getRequestUri('passport', '/api/notifications/listen'); + var uri = Uri( + scheme: ori.scheme.replaceFirst('http', 'ws'), + host: ori.host, + path: ori.path, + queryParameters: {'tk': Uri.encodeComponent(auth.client!.credentials.accessToken)}, + ); + + final channel = WebSocketChannel.connect(uri); + await channel.ready; + + return channel; + } + + void onRemoteMessage(model.Notification item) { + notifications.add(item); + notifyListeners(); + } + + void clearNonRealtime() { + notifications = notifications.where((x) => !x.isRealtime).toList(); + } +} diff --git a/lib/router.dart b/lib/router.dart index 1d99e89..69cce68 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -5,6 +5,7 @@ import 'package:solian/screens/account.dart'; import 'package:solian/screens/chat/chat.dart'; import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/explore.dart'; +import 'package:solian/screens/notification.dart'; import 'package:solian/screens/posts/comment_editor.dart'; import 'package:solian/screens/posts/moment_editor.dart'; import 'package:solian/screens/posts/screen.dart'; @@ -59,6 +60,11 @@ final router = GoRouter( dataset: state.pathParameters['dataset'] as String, ), ), + GoRoute( + path: '/notification', + name: 'notification', + builder: (context, state) => const NotificationScreen(), + ), GoRoute( path: '/auth/sign-in', name: 'auth.sign-in', diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index c516261..14a4d1f 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -11,6 +11,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:http/http.dart' as http; import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:solian/widgets/notification_notifier.dart'; import 'package:solian/widgets/posts/item.dart'; class ExploreScreen extends StatefulWidget { @@ -76,6 +77,7 @@ class _ExploreScreenState extends State { } }, ), + appBarActions: const [NotificationButton()], title: AppLocalizations.of(context)!.explore, child: RefreshIndicator( onRefresh: () => Future.sync( diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart new file mode 100644 index 0000000..b9b432c --- /dev/null +++ b/lib/screens/notification.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/notify.dart'; +import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:solian/models/notification.dart' as model; + +class NotificationScreen extends StatefulWidget { + const NotificationScreen({super.key}); + + @override + State createState() => _NotificationScreenState(); +} + +class _NotificationScreenState extends State { + @override + Widget build(BuildContext context) { + final auth = context.read(); + final nty = context.watch(); + + return IndentWrapper( + noSafeArea: true, + title: AppLocalizations.of(context)!.notification, + child: RefreshIndicator( + onRefresh: () => nty.fetch(auth), + child: CustomScrollView( + slivers: [ + nty.notifications.isEmpty + ? SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10), + color: Theme.of(context).colorScheme.surfaceVariant, + child: ListTile( + leading: const Icon(Icons.check), + title: Text(AppLocalizations.of(context)!.notifyDone), + subtitle: Text(AppLocalizations.of(context)!.notifyDoneCaption), + ), + ), + ) + : SliverList.builder( + itemCount: nty.notifications.length, + itemBuilder: (BuildContext context, int index) { + var element = nty.notifications[index]; + return NotificationItem( + index: index, + item: element, + onDismiss: () => setState(() { + nty.notifications.removeAt(index); + }), + ); + }, + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.only(top: 12), + child: Text( + AppLocalizations.of(context)!.notifyListHint, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ], + ), + ), + ); + } +} + +class NotificationItem extends StatelessWidget { + final int index; + final model.Notification item; + final void Function()? onDismiss; + + const NotificationItem({super.key, required this.index, required this.item, this.onDismiss}); + + bool hasLinks() => item.links != null && item.links!.isNotEmpty; + + void showLinks(BuildContext context) { + if (!hasLinks()) return; + + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12), + child: Text( + "Links", + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + Expanded( + child: ListView.builder( + itemCount: item.links!.length, + itemBuilder: (BuildContext context, int index) { + var element = item.links![index]; + return ListTile( + title: Text(element.label), + onTap: () async { + await launchUrlString(element.url); + if (Navigator.canPop(context)) { + Navigator.pop(context); + } + }, + ); + }, + ), + ), + ], + ); + }, + ); + } + + Future markAsRead(model.Notification element, BuildContext context) async { + if (element.isRealtime) return; + + final auth = context.read(); + if (!await auth.isAuthorized()) return; + + var id = element.id; + var uri = getRequestUri('passport', '/api/notifications/$id/read'); + await auth.client!.put(uri); + } + + @override + Widget build(BuildContext context) { + return Dismissible( + key: Key('n$index'), + onDismissed: (direction) { + markAsRead(item, context).then((value) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: RichText( + text: TextSpan( + children: [ + TextSpan( + text: item.subject, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const TextSpan(text: " is marked as read") + ], + ), + ), + ), + ); + }); + if (onDismiss != null) { + onDismiss!(); + } + }, + background: Container( + color: Colors.lightBlue, + ), + child: Container( + padding: const EdgeInsets.only(left: 10), + child: ListTile( + title: Text(item.subject), + subtitle: Text(item.content), + trailing: hasLinks() + ? TextButton( + onPressed: () => showLinks(context), + style: TextButton.styleFrom(shape: const CircleBorder()), + child: const Icon(Icons.more_vert), + ) + : null, + ), + ), + ); + } +} diff --git a/lib/widgets/chat/maintainer.dart b/lib/widgets/chat/maintainer.dart index b32d44a..e0cba9d 100644 --- a/lib/widgets/chat/maintainer.dart +++ b/lib/widgets/chat/maintainer.dart @@ -31,7 +31,7 @@ class _ChatMaintainerState extends State { final notify = ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.connectingServer), - duration: const Duration(days: 1), + duration: const Duration(minutes: 1), ), ); @@ -55,6 +55,7 @@ class _ChatMaintainerState extends State { } }, onError: (_, __) => connect(), + onDone: () => connect(), ); notify.close(); @@ -72,6 +73,8 @@ class _ChatMaintainerState extends State { @override Widget build(BuildContext context) { + ScaffoldMessenger.of(context).clearSnackBars(); + return widget.child; } } diff --git a/lib/widgets/chat/message_editor.dart b/lib/widgets/chat/message_editor.dart index 9c09a36..ceee790 100644 --- a/lib/widgets/chat/message_editor.dart +++ b/lib/widgets/chat/message_editor.dart @@ -36,7 +36,7 @@ class _ChatMessageEditorState extends State { builder: (context) => AttachmentEditor( provider: 'messaging', current: _attachments, - onUpdate: (value) => _attachments = value, + onUpdate: (value) => setState(() => _attachments = value), ), ); } diff --git a/lib/widgets/notification_notifier.dart b/lib/widgets/notification_notifier.dart new file mode 100644 index 0000000..5cacbaf --- /dev/null +++ b/lib/widgets/notification_notifier.dart @@ -0,0 +1,90 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/notify.dart'; +import 'package:solian/router.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:solian/models/notification.dart' as model; +import 'package:badges/badges.dart' as badge; + +class NotificationNotifier extends StatefulWidget { + final Widget child; + + const NotificationNotifier({super.key, required this.child}); + + @override + State createState() => _NotificationNotifierState(); +} + +class _NotificationNotifierState extends State { + void connect() { + final notify = ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context)!.connectingServer), + duration: const Duration(minutes: 1), + ), + ); + + final auth = context.read(); + final nty = context.read(); + + nty.fetch(auth); + nty.connect(auth).then((snapshot) { + snapshot!.stream.listen( + (event) { + final result = model.Notification.fromJson(jsonDecode(event)); + nty.onRemoteMessage(result); + }, + onError: (_, __) => connect(), + onDone: () => connect(), + ); + + notify.close(); + }); + } + + @override + void initState() { + Future.delayed(Duration.zero, () { + connect(); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} + +class NotificationButton extends StatefulWidget { + const NotificationButton({super.key}); + + @override + State createState() => _NotificationButtonState(); +} + +class _NotificationButtonState extends State { + @override + Widget build(BuildContext context) { + final nty = context.watch(); + + return badge.Badge( + showBadge: nty.notifications.isNotEmpty, + position: badge.BadgePosition.custom(top: -2, end: 8), + badgeContent: Text( + nty.notifications.length.toString(), + style: const TextStyle(color: Colors.white), + ), + child: IconButton( + icon: const Icon(Icons.notifications), + onPressed: () { + router.pushNamed("notification"); + }, + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6129418..bc5f3ad 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -131,4 +131,4 @@ flutter_launcher_icons: icon_size: 256 macos: generate: true - image_path: "assets/icon-rounded-padded.png" + image_path: "assets/icon-macos.png"