Notifications

This commit is contained in:
LittleSheep 2024-04-24 23:19:26 +08:00
parent 0d96a6f9ac
commit 3a2894b533
13 changed files with 362 additions and 10 deletions

View File

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -26,6 +26,10 @@
"report": "Report", "report": "Report",
"reply": "Reply", "reply": "Reply",
"settings": "Settings", "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", "reaction": "Reaction",
"reactVerb": "React", "reactVerb": "React",
"post": "Post", "post": "Post",

View File

@ -26,6 +26,10 @@
"report": "举报", "report": "举报",
"reply": "回复", "reply": "回复",
"settings": "设置", "settings": "设置",
"notification": "通知",
"notifyDone": "所有通知已读!",
"notifyDoneCaption": "这里没有什么东西可以给你看的了~",
"notifyListHint": "下拉以刷新,左滑来已读",
"reaction": "反应", "reaction": "反应",
"reactVerb": "作出反应", "reactVerb": "作出反应",
"post": "帖子", "post": "帖子",
@ -49,6 +53,13 @@
"chatNew": "新聊天", "chatNew": "新聊天",
"chatNewCreate": "新建频道", "chatNewCreate": "新建频道",
"chatNewJoin": "加入已有频道", "chatNewJoin": "加入已有频道",
"chatChannelUsage": "频道",
"chatChannelUsageCaption": "频道是一个地方供你聊天,跟一个人,或者一堆人",
"chatChannelOrganize": "组织频道",
"chatChannelEditNotify": "你正在编辑一个已经存在的频道……",
"chatChannelAliasLabel": "频道别名",
"chatChannelNameLabel": "频道名称",
"chatChannelDescriptionLabel": "频道简介",
"chatMessagePlaceholder": "发条消息……", "chatMessagePlaceholder": "发条消息……",
"chatMessageEditNotify": "你正在编辑信息中……", "chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……", "chatMessageReplyNotify": "你正在回复消息中……",

View File

@ -3,10 +3,12 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart'; import 'package:solian/providers/chat.dart';
import 'package:solian/providers/navigation.dart'; import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/utils/timeago.dart'; import 'package:solian/utils/timeago.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/video_player.dart'; import 'package:solian/utils/video_player.dart';
import 'package:solian/widgets/notification_notifier.dart';
void main() { void main() {
initVideo(); initVideo();
@ -39,8 +41,9 @@ class SolianApp extends StatelessWidget {
Provider(create: (_) => NavigationProvider()), Provider(create: (_) => NavigationProvider()),
Provider(create: (_) => AuthProvider()), Provider(create: (_) => AuthProvider()),
Provider(create: (_) => ChatProvider()), Provider(create: (_) => ChatProvider()),
ChangeNotifierProvider(create: (_) => NotifyProvider()),
], ],
child: child, child: NotificationNotifier(child: child ?? Container()),
); );
}) })
], ],

View File

@ -7,6 +7,7 @@ class Notification {
String content; String content;
List<Link>? links; List<Link>? links;
bool isImportant; bool isImportant;
bool isRealtime;
DateTime? readAt; DateTime? readAt;
int senderId; int senderId;
int recipientId; int recipientId;
@ -20,6 +21,7 @@ class Notification {
required this.content, required this.content,
this.links, this.links,
required this.isImportant, required this.isImportant,
required this.isRealtime,
this.readAt, this.readAt,
required this.senderId, required this.senderId,
required this.recipientId, required this.recipientId,
@ -32,10 +34,9 @@ class Notification {
deletedAt: json["deleted_at"], deletedAt: json["deleted_at"],
subject: json["subject"], subject: json["subject"],
content: json["content"], content: json["content"],
links: json["links"] != null links: json["links"] != null ? List<Link>.from(json["links"].map((x) => Link.fromJson(x))) : List.empty(),
? List<Link>.from(json["links"].map((x) => Link.fromJson(x)))
: List.empty(),
isImportant: json["is_important"], isImportant: json["is_important"],
isRealtime: json["is_realtime"],
readAt: json["read_at"], readAt: json["read_at"],
senderId: json["sender_id"], senderId: json["sender_id"],
recipientId: json["recipient_id"], recipientId: json["recipient_id"],
@ -48,10 +49,9 @@ class Notification {
"deleted_at": deletedAt, "deleted_at": deletedAt,
"subject": subject, "subject": subject,
"content": content, "content": content,
"links": links != null "links": links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
? List<dynamic>.from(links!.map((x) => x.toJson()))
: List.empty(),
"is_important": isImportant, "is_important": isImportant,
"is_realtime": isRealtime,
"read_at": readAt, "read_at": readAt,
"sender_id": senderId, "sender_id": senderId,
"recipient_id": recipientId, "recipient_id": recipientId,

56
lib/providers/notify.dart Normal file
View File

@ -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<model.Notification> notifications = List.empty(growable: true);
Future<void> 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<WebSocketChannel?> 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();
}
}

View File

@ -5,6 +5,7 @@ import 'package:solian/screens/account.dart';
import 'package:solian/screens/chat/chat.dart'; import 'package:solian/screens/chat/chat.dart';
import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/index.dart';
import 'package:solian/screens/explore.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/comment_editor.dart';
import 'package:solian/screens/posts/moment_editor.dart'; import 'package:solian/screens/posts/moment_editor.dart';
import 'package:solian/screens/posts/screen.dart'; import 'package:solian/screens/posts/screen.dart';
@ -59,6 +60,11 @@ final router = GoRouter(
dataset: state.pathParameters['dataset'] as String, dataset: state.pathParameters['dataset'] as String,
), ),
), ),
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
GoRoute( GoRoute(
path: '/auth/sign-in', path: '/auth/sign-in',
name: 'auth.sign-in', name: 'auth.sign-in',

View File

@ -11,6 +11,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:solian/widgets/indent_wrapper.dart'; import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/posts/item.dart'; import 'package:solian/widgets/posts/item.dart';
class ExploreScreen extends StatefulWidget { class ExploreScreen extends StatefulWidget {
@ -76,6 +77,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
} }
}, },
), ),
appBarActions: const [NotificationButton()],
title: AppLocalizations.of(context)!.explore, title: AppLocalizations.of(context)!.explore,
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => Future.sync( onRefresh: () => Future.sync(

View File

@ -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<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final nty = context.watch<NotifyProvider>();
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<void>(
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<void> markAsRead(model.Notification element, BuildContext context) async {
if (element.isRealtime) return;
final auth = context.read<AuthProvider>();
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,
),
),
);
}
}

View File

@ -31,7 +31,7 @@ class _ChatMaintainerState extends State<ChatMaintainer> {
final notify = ScaffoldMessenger.of(context).showSnackBar( final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer), content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(days: 1), duration: const Duration(minutes: 1),
), ),
); );
@ -55,6 +55,7 @@ class _ChatMaintainerState extends State<ChatMaintainer> {
} }
}, },
onError: (_, __) => connect(), onError: (_, __) => connect(),
onDone: () => connect(),
); );
notify.close(); notify.close();
@ -72,6 +73,8 @@ class _ChatMaintainerState extends State<ChatMaintainer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ScaffoldMessenger.of(context).clearSnackBars();
return widget.child; return widget.child;
} }
} }

View File

@ -36,7 +36,7 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
builder: (context) => AttachmentEditor( builder: (context) => AttachmentEditor(
provider: 'messaging', provider: 'messaging',
current: _attachments, current: _attachments,
onUpdate: (value) => _attachments = value, onUpdate: (value) => setState(() => _attachments = value),
), ),
); );
} }

View File

@ -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<NotificationNotifier> createState() => _NotificationNotifierState();
}
class _NotificationNotifierState extends State<NotificationNotifier> {
void connect() {
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
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<NotificationButton> createState() => _NotificationButtonState();
}
class _NotificationButtonState extends State<NotificationButton> {
@override
Widget build(BuildContext context) {
final nty = context.watch<NotifyProvider>();
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");
},
),
);
}
}

View File

@ -131,4 +131,4 @@ flutter_launcher_icons:
icon_size: 256 icon_size: 256
macos: macos:
generate: true generate: true
image_path: "assets/icon-rounded-padded.png" image_path: "assets/icon-macos.png"