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"