✨ Notifications
This commit is contained in:
@ -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
161
lib/providers/account.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
|
210
lib/screens/account/notification.dart
Normal file
210
lib/screens/account/notification.dart
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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': '你将会以本身份发表帖子',
|
||||
|
Reference in New Issue
Block a user