Notifications

This commit is contained in:
2024-05-25 13:00:40 +08:00
parent 806ae602d5
commit f376603482
13 changed files with 652 additions and 56 deletions

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/providers/account.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/providers/friend.dart';
@ -31,6 +32,14 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => FriendProvider());
Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => AccountProvider());
final AuthProvider auth = Get.find();
auth.isAuthorized.then((value) async {
if (value) {
Get.find<AccountProvider>().connect();
}
});
},
builder: (context, child) {
return ScaffoldMessenger(

161
lib/providers/account.dart Normal file
View File

@ -0,0 +1,161 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:get/get.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/notification.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
import 'package:web_socket_channel/io.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
class AccountProvider extends GetxController {
final FlutterLocalNotificationsPlugin localNotify =
FlutterLocalNotificationsPlugin();
RxBool isConnected = false.obs;
RxBool isConnecting = false.obs;
RxInt notificationUnread = 0.obs;
RxList<Notification> notifications =
List<Notification>.empty(growable: true).obs;
IOWebSocketChannel? websocket;
@override
onInit() {
Permission.notification.request().then((status) {
notifyInitialization();
notifyPrefetch();
});
super.onInit();
}
void connect({noRetry = false}) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) throw Exception('unauthorized');
if (auth.credentials == null) await auth.loadCredentials();
final uri = Uri.parse(
'${ServiceFinder.services['passport']}/api/ws?tk=${auth.credentials!.accessToken}'
.replaceFirst('http', 'ws'),
);
isConnecting.value = true;
try {
websocket = IOWebSocketChannel.connect(uri);
await websocket?.ready;
} catch (e) {
if (!noRetry) {
await auth.refreshCredentials();
return connect(noRetry: true);
}
}
listen();
isConnected.value = true;
isConnecting.value = false;
}
void disconnect() {
websocket?.sink.close(WebSocketStatus.normalClosure);
isConnected.value = false;
}
void listen() {
websocket?.stream.listen(
(event) {
final packet = NetworkPackage.fromJson(jsonDecode(event));
switch (packet.method) {
case 'notifications.new':
final notification = Notification.fromJson(packet.payload!);
notificationUnread++;
notifications.add(notification);
notifyMessage(notification.subject, notification.content);
break;
}
},
onDone: () {
isConnected.value = false;
},
onError: (err) {
isConnected.value = false;
},
);
}
void notifyInitialization() {
const androidSettings = AndroidInitializationSettings('app_icon');
const darwinSettings = DarwinInitializationSettings(
notificationCategories: [
DarwinNotificationCategory('general'),
],
);
const linuxSettings =
LinuxInitializationSettings(defaultActionName: 'Open notification');
const InitializationSettings initializationSettings =
InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
linux: linuxSettings,
);
localNotify.initialize(initializationSettings);
}
void notifyMessage(String title, String body) {
const androidSettings = AndroidNotificationDetails(
'general',
'General',
importance: Importance.high,
priority: Priority.high,
silent: true,
);
const darwinSettings = DarwinNotificationDetails(
presentAlert: true,
presentBanner: true,
presentBadge: true,
presentSound: false,
);
const linuxSettings = LinuxNotificationDetails();
localNotify.show(
math.max(1, math.Random().nextInt(100000000)),
title,
body,
const NotificationDetails(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
linux: linuxSettings,
),
);
}
Future<void> notifyPrefetch() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
final resp = await client.get('/api/notifications?skip=0&take=100');
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final data = result.data?.map((x) => Notification.fromJson(x)).toList();
if (data != null) {
notifications.addAll(data);
notificationUnread.value = data.length;
}
}
}
}

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/providers/account.dart';
import 'package:solian/services.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
@ -25,26 +26,30 @@ class AuthProvider extends GetConnect {
oauth2.Credentials? credentials;
Future<void> refreshCredentials() async {
final resp = await post('/api/auth/token', {
'refresh_token': credentials!.refreshToken,
'grant_type': 'refresh_token',
});
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
credentials = oauth2.Credentials(
resp.body['access_token'],
refreshToken: resp.body['refresh_token'],
idToken: resp.body['access_token'],
tokenEndpoint: tokenEndpoint,
expiration: DateTime.now().add(const Duration(minutes: 3)),
);
storage.write(
key: 'auth_credentials',
value: jsonEncode(credentials!.toJson()),
);
}
Future<Request<T?>> requestAuthenticator<T>(Request<T?> request) async {
if (credentials != null && credentials!.isExpired) {
final resp = await post('/api/auth/token', {
'refresh_token': credentials!.refreshToken,
'grant_type': 'refresh_token',
});
if (resp.statusCode != 200) {
throw Exception(resp.bodyString);
}
credentials = oauth2.Credentials(
resp.body['access_token'],
refreshToken: resp.body['refresh_token'],
idToken: resp.body['access_token'],
tokenEndpoint: tokenEndpoint,
expiration: DateTime.now().add(const Duration(minutes: 3)),
);
storage.write(
key: 'auth_credentials',
value: jsonEncode(credentials!.toJson()),
);
refreshCredentials();
}
if (credentials != null) {
@ -91,12 +96,16 @@ class AuthProvider extends GetConnect {
value: jsonEncode(credentials!.toJson()),
);
Get.find<AccountProvider>().connect();
return credentials!;
}
void signout() {
_cacheUserProfileResponse = null;
Get.find<AccountProvider>().disconnect();
storage.deleteAll();
}

View File

@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/providers/account.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
import 'package:solian/models/notification.dart' as notify;
import 'package:url_launcher/url_launcher_string.dart';
import 'package:uuid/uuid.dart';
class NotificationScreen extends StatefulWidget {
const NotificationScreen({super.key});
@override
State<NotificationScreen> createState() => _NotificationScreenState();
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isBusy = false;
Future<void> markAllRead() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
setState(() => _isBusy = true);
final AccountProvider provider = Get.find();
List<int> markList = List.empty(growable: true);
for (final element in provider.notifications) {
if (element.isRealtime) continue;
markList.add(element.id);
}
if (markList.isNotEmpty) {
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
await client.put('/api/notifications/batch/read', {'messages': markList});
}
provider.notifications.clear();
setState(() => _isBusy = false);
}
Future<void> markOneRead(notify.Notification element, int index) async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
final AccountProvider provider = Get.find();
if (element.isRealtime) {
provider.notifications.removeAt(index);
return;
}
setState(() => _isBusy = true);
final client = GetConnect();
client.httpClient.baseUrl = ServiceFinder.services['passport'];
client.httpClient.addAuthenticator(auth.requestAuthenticator);
await client.put('/api/notifications/${element.id}/read', {});
provider.notifications.removeAt(index);
setState(() => _isBusy = false);
}
@override
Widget build(BuildContext context) {
final AccountProvider provider = Get.find();
return SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'notification'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded(
child: Obx(() {
return CustomScrollView(
slivers: [
if (_isBusy)
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
if (provider.notifications.isEmpty)
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
color:
Theme.of(context).colorScheme.surfaceContainerHigh,
child: ListTile(
leading: const Icon(Icons.check),
title: Text('notifyEmpty'.tr),
subtitle: Text('notifyEmptyCaption'.tr),
),
),
),
if (provider.notifications.isNotEmpty)
SliverToBoxAdapter(
child: ListTile(
tileColor:
Theme.of(context).colorScheme.secondaryContainer,
leading: const Icon(Icons.checklist),
title: Text('notifyAllRead'.tr),
contentPadding:
const EdgeInsets.symmetric(horizontal: 28),
onTap: _isBusy ? null : () => markAllRead(),
),
),
SliverList.separated(
itemCount: provider.notifications.length,
itemBuilder: (BuildContext context, int index) {
var element = provider.notifications[index];
return Dismissible(
key: Key(const Uuid().v4()),
background: Container(
color: Colors.lightBlue,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.check, color: Colors.white),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
title: Text(element.subject),
subtitle: Column(
children: [
Text(element.content),
if (element.links != null)
Row(
children: element.links!
.map((e) => InkWell(
child: Text(
e.label,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSecondaryContainer,
decoration:
TextDecoration.underline,
),
),
onTap: () {
launchUrlString(e.url);
},
).paddingOnly(right: 5))
.toList(),
),
],
),
),
onDismissed: (_) => markOneRead(element, index),
);
},
separatorBuilder: (_, __) =>
const Divider(thickness: 0.3, height: 0.3),
),
],
);
}),
),
],
),
);
}
}
class NotificationButton extends StatelessWidget {
const NotificationButton({super.key});
@override
Widget build(BuildContext context) {
final AccountProvider provider = Get.find();
final button = IconButton(
icon: const Icon(Icons.notifications),
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const NotificationScreen(),
).then((_) => provider.notificationUnread.value = 0);
},
);
return Obx(() {
if (provider.notificationUnread.value > 0) {
return Badge(
isLabelVisible: true,
offset: const Offset(-8, 2),
label: Text(provider.notificationUnread.value.toString()),
child: button,
);
} else {
return button;
}
});
}
}

View File

@ -6,6 +6,8 @@ import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/post_explore.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_item.dart';
@ -69,32 +71,61 @@ class _SocialScreenState extends State<SocialScreen> {
}),
body: Material(
color: Theme.of(context).colorScheme.surface,
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: PagedListView<int, Post>.separated(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) {
return GestureDetector(
child: PostItem(key: Key('p${item.alias}'), item: item)
.paddingSymmetric(
vertical: (item.attachments?.isEmpty ?? false) ? 8 : 0,
child: SafeArea(
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) {
return [
SliverOverlapAbsorber(
handle:
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar(
title: Text('social'.tr),
centerTitle: false,
titleSpacing:
SolianTheme.isLargeScreen(context) ? null : 24,
forceElevated: innerBoxIsScrolled,
actions: const [
NotificationButton(),
],
),
onTap: () {},
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(item: item),
).then((value) {
if (value == true) _pagingController.refresh();
});
},
);
},
),
];
},
body: MediaQuery.removePadding(
removeTop: true,
context: context,
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: PagedListView<int, Post>.separated(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) {
return GestureDetector(
child: PostItem(
key: Key('p${item.alias}'),
item: item,
).paddingSymmetric(
vertical:
(item.attachments?.isEmpty ?? false) ? 8 : 0,
),
onTap: () {},
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(item: item),
).then((value) {
if (value == true) _pagingController.refresh();
});
},
);
},
),
separatorBuilder: (_, __) =>
const Divider(thickness: 0.3, height: 0.3),
),
),
),
separatorBuilder: (_, __) =>
const Divider(thickness: 0.3, height: 0.3),
),
),
),

View File

@ -18,6 +18,7 @@ class SolianMessages extends Translations {
'delete': 'Delete',
'reply': 'Reply',
'repost': 'Repost',
'notification': 'Notification',
'errorHappened': 'An error occurred',
'email': 'Email',
'username': 'Username',
@ -49,7 +50,7 @@ class SolianMessages extends Translations {
'signinRiskDetected':
'Risk detected, click Next to open a webpage and signin through it to pass security check.',
'signup': 'Sign up',
'signupGreeting': 'Welcome onboard 👋',
'signupGreeting': 'Welcome onboard',
'signupCaption':
'Create an account on Solarpass and then get the access of entire Solar Network!',
'signout': 'Sign out',
@ -57,6 +58,9 @@ class SolianMessages extends Translations {
'matureContent': 'Mature Content',
'matureContentCaption':
'The content is rated and may not suitable for everyone to view',
'notifyAllRead': 'Mark all as read',
'notifyEmpty': 'All notifications read',
'notifyEmptyCaption': 'It seems like nothing happened recently',
'postAction': 'Post',
'postPublishing': 'Post a post',
'postIdentityNotify': 'You will post this post as',
@ -98,6 +102,7 @@ class SolianMessages extends Translations {
'apply': '应用',
'reply': '回复',
'repost': '转帖',
'notification': '通知',
'errorHappened': '发生错误了',
'email': '邮件地址',
'username': '用户名',
@ -131,6 +136,9 @@ class SolianMessages extends Translations {
'riskDetection': '检测到风险',
'matureContent': '评级内容',
'matureContentCaption': '该内容已被评级为家长指导级或以上,这可能说明内容包含一系列不友好的成分',
'notifyAllRead': '已读所有通知',
'notifyEmpty': '通知箱为空',
'notifyEmptyCaption': '看起来最近没发生什么呢',
'postAction': '发表',
'postPublishing': '发表帖子',
'postIdentityNotify': '你将会以本身份发表帖子',