Compare commits
12 Commits
Author | SHA1 | Date | |
---|---|---|---|
3fb1d7a6d4 | |||
0480b5244f | |||
56fb92c6b9 | |||
b3267f0026 | |||
88587c10da | |||
9012566dbf | |||
6e00a99803 | |||
aa17a5d52a | |||
ebeffbe1aa | |||
d22eac5c10 | |||
e5381dd5e0 | |||
1c26944a05 |
@ -486,5 +486,9 @@
|
|||||||
"shareImage": "Share as image",
|
"shareImage": "Share as image",
|
||||||
"shareImageFooter": "Only on the Solar Network",
|
"shareImageFooter": "Only on the Solar Network",
|
||||||
"fileSavedAt": "File saved at @path",
|
"fileSavedAt": "File saved at @path",
|
||||||
"showIp": "Show IP Address"
|
"showIp": "Show IP Address",
|
||||||
|
"shotOn": "Shot on @device",
|
||||||
|
"unread": "Unread",
|
||||||
|
"searchTook": "Took @time",
|
||||||
|
"searchResult": "@count Matches"
|
||||||
}
|
}
|
||||||
|
@ -482,5 +482,9 @@
|
|||||||
"shareImage": "分享图片",
|
"shareImage": "分享图片",
|
||||||
"shareImageFooter": "上 Solar Network 看更多有趣帖子",
|
"shareImageFooter": "上 Solar Network 看更多有趣帖子",
|
||||||
"fileSavedAt": "文件保存于 @path",
|
"fileSavedAt": "文件保存于 @path",
|
||||||
"showIp": "显示 IP 地址"
|
"showIp": "显示 IP 地址",
|
||||||
|
"shotOn": "由 @device 拍摄",
|
||||||
|
"unread": "未读",
|
||||||
|
"searchTook": "耗时 @time",
|
||||||
|
"searchResult": "匹配到 @count 条结果"
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,7 @@ import 'package:solian/exts.dart';
|
|||||||
import 'package:solian/platform.dart';
|
import 'package:solian/platform.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
import 'package:solian/providers/content/realm.dart';
|
import 'package:solian/providers/content/realm.dart';
|
||||||
|
import 'package:solian/providers/notifications.dart';
|
||||||
import 'package:solian/providers/relation.dart';
|
import 'package:solian/providers/relation.dart';
|
||||||
import 'package:solian/providers/theme_switcher.dart';
|
import 'package:solian/providers/theme_switcher.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
import 'package:solian/providers/websocket.dart';
|
||||||
@ -198,6 +199,8 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
|||||||
final AuthProvider auth = Get.find();
|
final AuthProvider auth = Get.find();
|
||||||
try {
|
try {
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
|
if (auth.isAuthorized.isTrue)
|
||||||
|
Get.find<NotificationProvider>().fetchNotification(),
|
||||||
if (auth.isAuthorized.isTrue)
|
if (auth.isAuthorized.isTrue)
|
||||||
Get.find<RelationshipProvider>().refreshRelativeList(),
|
Get.find<RelationshipProvider>().refreshRelativeList(),
|
||||||
if (auth.isAuthorized.isTrue)
|
if (auth.isAuthorized.isTrue)
|
||||||
@ -214,7 +217,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
|
|||||||
final AuthProvider auth = Get.find();
|
final AuthProvider auth = Get.find();
|
||||||
if (auth.isAuthorized.isTrue) {
|
if (auth.isAuthorized.isTrue) {
|
||||||
try {
|
try {
|
||||||
Get.find<WebSocketProvider>().registerPushNotifications();
|
Get.find<NotificationProvider>().registerPushNotifications();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
context.showSnackbar(
|
context.showSnackbar(
|
||||||
'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}),
|
'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}),
|
||||||
|
@ -18,6 +18,7 @@ import 'package:solian/providers/database/services/messages.dart';
|
|||||||
import 'package:solian/providers/last_read.dart';
|
import 'package:solian/providers/last_read.dart';
|
||||||
import 'package:solian/providers/link_expander.dart';
|
import 'package:solian/providers/link_expander.dart';
|
||||||
import 'package:solian/providers/navigation.dart';
|
import 'package:solian/providers/navigation.dart';
|
||||||
|
import 'package:solian/providers/notifications.dart';
|
||||||
import 'package:solian/providers/stickers.dart';
|
import 'package:solian/providers/stickers.dart';
|
||||||
import 'package:solian/providers/subscription.dart';
|
import 'package:solian/providers/subscription.dart';
|
||||||
import 'package:solian/providers/theme_switcher.dart';
|
import 'package:solian/providers/theme_switcher.dart';
|
||||||
@ -138,11 +139,12 @@ class SolianApp extends StatelessWidget {
|
|||||||
Get.put(NavigationStateProvider());
|
Get.put(NavigationStateProvider());
|
||||||
|
|
||||||
Get.lazyPut(() => AuthProvider());
|
Get.lazyPut(() => AuthProvider());
|
||||||
|
Get.lazyPut(() => WebSocketProvider());
|
||||||
Get.lazyPut(() => RelationshipProvider());
|
Get.lazyPut(() => RelationshipProvider());
|
||||||
Get.lazyPut(() => PostProvider());
|
Get.lazyPut(() => PostProvider());
|
||||||
Get.lazyPut(() => StickerProvider());
|
Get.lazyPut(() => StickerProvider());
|
||||||
Get.lazyPut(() => AttachmentProvider());
|
Get.lazyPut(() => AttachmentProvider());
|
||||||
Get.lazyPut(() => WebSocketProvider());
|
Get.lazyPut(() => NotificationProvider());
|
||||||
Get.lazyPut(() => StatusProvider());
|
Get.lazyPut(() => StatusProvider());
|
||||||
Get.lazyPut(() => ChannelProvider());
|
Get.lazyPut(() => ChannelProvider());
|
||||||
Get.lazyPut(() => RealmProvider());
|
Get.lazyPut(() => RealmProvider());
|
||||||
@ -154,6 +156,6 @@ class SolianApp extends StatelessWidget {
|
|||||||
Get.lazyPut(() => LastReadProvider());
|
Get.lazyPut(() => LastReadProvider());
|
||||||
Get.lazyPut(() => SubscriptionProvider());
|
Get.lazyPut(() => SubscriptionProvider());
|
||||||
|
|
||||||
Get.find<WebSocketProvider>().requestPermissions();
|
Get.find<NotificationProvider>().requestPermissions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,18 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
part 'notification.g.dart';
|
part 'notification.g.dart';
|
||||||
|
|
||||||
|
const Map<String, IconData> NotificationTopicIcons = {
|
||||||
|
'passport.security.alert': Icons.gpp_maybe,
|
||||||
|
'interactive.subscription': Icons.subscriptions,
|
||||||
|
'interactive.feedback': Icons.add_reaction,
|
||||||
|
'messaging.callStart': Icons.call_received,
|
||||||
|
};
|
||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class Notification {
|
class Notification {
|
||||||
int id;
|
int id;
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
DateTime? deletedAt;
|
DateTime? deletedAt;
|
||||||
|
DateTime? readAt;
|
||||||
|
String topic;
|
||||||
String title;
|
String title;
|
||||||
String? subtitle;
|
String? subtitle;
|
||||||
String body;
|
String body;
|
||||||
String? avatar;
|
String? avatar;
|
||||||
String? picture;
|
String? picture;
|
||||||
|
Map<String, dynamic>? metadata;
|
||||||
int? senderId;
|
int? senderId;
|
||||||
int accountId;
|
int accountId;
|
||||||
|
|
||||||
@ -21,11 +32,14 @@ class Notification {
|
|||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
required this.deletedAt,
|
required this.deletedAt,
|
||||||
|
required this.readAt,
|
||||||
|
required this.topic,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
required this.body,
|
required this.body,
|
||||||
required this.avatar,
|
required this.avatar,
|
||||||
required this.picture,
|
required this.picture,
|
||||||
|
required this.metadata,
|
||||||
required this.senderId,
|
required this.senderId,
|
||||||
required this.accountId,
|
required this.accountId,
|
||||||
});
|
});
|
||||||
|
@ -13,11 +13,16 @@ Notification _$NotificationFromJson(Map<String, dynamic> json) => Notification(
|
|||||||
deletedAt: json['deleted_at'] == null
|
deletedAt: json['deleted_at'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(json['deleted_at'] as String),
|
: DateTime.parse(json['deleted_at'] as String),
|
||||||
|
readAt: json['read_at'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['read_at'] as String),
|
||||||
|
topic: json['topic'] as String,
|
||||||
title: json['title'] as String,
|
title: json['title'] as String,
|
||||||
subtitle: json['subtitle'] as String?,
|
subtitle: json['subtitle'] as String?,
|
||||||
body: json['body'] as String,
|
body: json['body'] as String,
|
||||||
avatar: json['avatar'] as String?,
|
avatar: json['avatar'] as String?,
|
||||||
picture: json['picture'] as String?,
|
picture: json['picture'] as String?,
|
||||||
|
metadata: json['metadata'] as Map<String, dynamic>?,
|
||||||
senderId: (json['sender_id'] as num?)?.toInt(),
|
senderId: (json['sender_id'] as num?)?.toInt(),
|
||||||
accountId: (json['account_id'] as num).toInt(),
|
accountId: (json['account_id'] as num).toInt(),
|
||||||
);
|
);
|
||||||
@ -28,11 +33,14 @@ Map<String, dynamic> _$NotificationToJson(Notification instance) =>
|
|||||||
'created_at': instance.createdAt.toIso8601String(),
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
'updated_at': instance.updatedAt.toIso8601String(),
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
'read_at': instance.readAt?.toIso8601String(),
|
||||||
|
'topic': instance.topic,
|
||||||
'title': instance.title,
|
'title': instance.title,
|
||||||
'subtitle': instance.subtitle,
|
'subtitle': instance.subtitle,
|
||||||
'body': instance.body,
|
'body': instance.body,
|
||||||
'avatar': instance.avatar,
|
'avatar': instance.avatar,
|
||||||
'picture': instance.picture,
|
'picture': instance.picture,
|
||||||
|
'metadata': instance.metadata,
|
||||||
'sender_id': instance.senderId,
|
'sender_id': instance.senderId,
|
||||||
'account_id': instance.accountId,
|
'account_id': instance.accountId,
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,7 @@ import 'package:solian/exceptions/request.dart';
|
|||||||
import 'package:solian/exceptions/unauthorized.dart';
|
import 'package:solian/exceptions/unauthorized.dart';
|
||||||
import 'package:solian/models/auth.dart';
|
import 'package:solian/models/auth.dart';
|
||||||
import 'package:solian/providers/database/database.dart';
|
import 'package:solian/providers/database/database.dart';
|
||||||
|
import 'package:solian/providers/notifications.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
import 'package:solian/providers/websocket.dart';
|
||||||
import 'package:solian/services.dart';
|
import 'package:solian/services.dart';
|
||||||
|
|
||||||
@ -174,7 +175,7 @@ class AuthProvider extends GetConnect {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Get.find<WebSocketProvider>().connect();
|
Get.find<WebSocketProvider>().connect();
|
||||||
Get.find<WebSocketProvider>().notifyPrefetch();
|
Get.find<NotificationProvider>().fetchNotification();
|
||||||
|
|
||||||
return credentials!;
|
return credentials!;
|
||||||
}
|
}
|
||||||
@ -184,8 +185,8 @@ class AuthProvider extends GetConnect {
|
|||||||
userProfile.value = null;
|
userProfile.value = null;
|
||||||
|
|
||||||
Get.find<WebSocketProvider>().disconnect();
|
Get.find<WebSocketProvider>().disconnect();
|
||||||
Get.find<WebSocketProvider>().notifications.clear();
|
Get.find<NotificationProvider>().notifications.clear();
|
||||||
Get.find<WebSocketProvider>().notificationUnread.value = 0;
|
Get.find<NotificationProvider>().notificationUnread.value = 0;
|
||||||
|
|
||||||
AppDatabase.removeDatabase();
|
AppDatabase.removeDatabase();
|
||||||
autoStopBackgroundNotificationService();
|
autoStopBackgroundNotificationService();
|
||||||
|
@ -44,9 +44,12 @@ class PostProvider extends GetxController {
|
|||||||
final queries = [
|
final queries = [
|
||||||
'take=${10}',
|
'take=${10}',
|
||||||
'offset=$page',
|
'offset=$page',
|
||||||
|
'truncate=false',
|
||||||
];
|
];
|
||||||
final client = await auth.configureClient('interactive');
|
final client = await auth.configureClient('interactive');
|
||||||
final resp = await client.get('/posts/drafts?${queries.join('&')}');
|
final resp = await client.get(
|
||||||
|
'/posts/drafts?${queries.join('&')}',
|
||||||
|
);
|
||||||
if (resp.statusCode != 200) {
|
if (resp.statusCode != 200) {
|
||||||
throw RequestException(resp);
|
throw RequestException(resp);
|
||||||
}
|
}
|
||||||
|
175
lib/providers/notifications.dart
Normal file
175
lib/providers/notifications.dart
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
|
import 'package:flutter_udid/flutter_udid.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:solian/exceptions/request.dart';
|
||||||
|
import 'package:solian/models/notification.dart';
|
||||||
|
import 'package:solian/models/pagination.dart';
|
||||||
|
import 'package:solian/platform.dart';
|
||||||
|
import 'package:solian/providers/auth.dart';
|
||||||
|
|
||||||
|
class NotificationProvider extends GetxController {
|
||||||
|
RxBool isBusy = false.obs;
|
||||||
|
|
||||||
|
RxInt notificationUnread = 0.obs;
|
||||||
|
RxList<Notification> notifications =
|
||||||
|
List<Notification>.empty(growable: true).obs;
|
||||||
|
|
||||||
|
Future<void> fetchNotification() async {
|
||||||
|
final AuthProvider auth = Get.find();
|
||||||
|
if (auth.isAuthorized.isFalse) return;
|
||||||
|
|
||||||
|
final client = await auth.configureClient('auth');
|
||||||
|
|
||||||
|
final resp = await client.get('/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.where((x) => x.readAt == null).length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAllRead() async {
|
||||||
|
final AuthProvider auth = Get.find();
|
||||||
|
if (auth.isAuthorized.isFalse) return;
|
||||||
|
|
||||||
|
isBusy.value = true;
|
||||||
|
|
||||||
|
final NotificationProvider nty = Get.find();
|
||||||
|
|
||||||
|
List<int> markList = List.empty(growable: true);
|
||||||
|
for (final element in nty.notifications) {
|
||||||
|
if (element.id <= 0) continue;
|
||||||
|
if (element.readAt != null) continue;
|
||||||
|
markList.add(element.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markList.isNotEmpty) {
|
||||||
|
final client = await auth.configureClient('auth');
|
||||||
|
await client.put('/notifications/read', {'messages': markList});
|
||||||
|
}
|
||||||
|
|
||||||
|
nty.notifications.value = nty.notifications.map((x) {
|
||||||
|
x.readAt = DateTime.now();
|
||||||
|
return x;
|
||||||
|
}).toList();
|
||||||
|
nty.notifications.refresh();
|
||||||
|
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markOneRead(Notification element, int index) async {
|
||||||
|
final AuthProvider auth = Get.find();
|
||||||
|
if (auth.isAuthorized.isFalse) return;
|
||||||
|
|
||||||
|
final NotificationProvider nty = Get.find();
|
||||||
|
|
||||||
|
if (element.id <= 0) {
|
||||||
|
nty.notifications.removeAt(index);
|
||||||
|
return;
|
||||||
|
} else if (element.readAt != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBusy.value = true;
|
||||||
|
|
||||||
|
final client = await auth.configureClient('auth');
|
||||||
|
|
||||||
|
await client.put('/notifications/read/${element.id}', {});
|
||||||
|
|
||||||
|
nty.notifications[0].readAt = DateTime.now();
|
||||||
|
nty.notifications.refresh();
|
||||||
|
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void requestPermissions() {
|
||||||
|
try {
|
||||||
|
FirebaseMessaging.instance.requestPermission(
|
||||||
|
alert: true,
|
||||||
|
announcement: true,
|
||||||
|
carPlay: true,
|
||||||
|
badge: true,
|
||||||
|
sound: true);
|
||||||
|
} catch (_) {
|
||||||
|
// When firebase isn't initialized (background service)
|
||||||
|
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
||||||
|
FlutterLocalNotificationsPlugin();
|
||||||
|
flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
AndroidFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestNotificationsPermission();
|
||||||
|
flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
IOSFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestPermissions(
|
||||||
|
alert: true,
|
||||||
|
badge: true,
|
||||||
|
sound: true,
|
||||||
|
);
|
||||||
|
flutterLocalNotificationsPlugin
|
||||||
|
.resolvePlatformSpecificImplementation<
|
||||||
|
MacOSFlutterLocalNotificationsPlugin>()
|
||||||
|
?.requestPermissions(
|
||||||
|
alert: true,
|
||||||
|
badge: true,
|
||||||
|
sound: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> registerPushNotifications() async {
|
||||||
|
if (PlatformInfo.isWeb) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (prefs.getBool('service_background_notification') == true) {
|
||||||
|
log('Background notification service has been enabled, skip register push notifications');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final AuthProvider auth = Get.find();
|
||||||
|
if (auth.isAuthorized.isFalse) return;
|
||||||
|
|
||||||
|
late final String? token;
|
||||||
|
late final String provider;
|
||||||
|
var deviceUuid = await _getDeviceUuid();
|
||||||
|
|
||||||
|
if (deviceUuid == null || deviceUuid.isEmpty) {
|
||||||
|
log("Unable to active push notifications, couldn't get device uuid");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
log('Device UUID is $deviceUuid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
||||||
|
provider = 'apple';
|
||||||
|
token = await FirebaseMessaging.instance.getAPNSToken();
|
||||||
|
} else {
|
||||||
|
provider = 'firebase';
|
||||||
|
token = await FirebaseMessaging.instance.getToken();
|
||||||
|
}
|
||||||
|
log('Device Push Token is $token');
|
||||||
|
|
||||||
|
final client = await auth.configureClient('auth');
|
||||||
|
|
||||||
|
final resp = await client.post('/notifications/subscribe', {
|
||||||
|
'provider': provider,
|
||||||
|
'device_token': token,
|
||||||
|
'device_id': deviceUuid,
|
||||||
|
});
|
||||||
|
if (resp.statusCode != 200 && resp.statusCode != 400) {
|
||||||
|
throw RequestException(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _getDeviceUuid() async {
|
||||||
|
if (PlatformInfo.isWeb) return null;
|
||||||
|
return await FlutterUdid.consistentUdid;
|
||||||
|
}
|
||||||
|
}
|
@ -3,17 +3,11 @@ import 'dart:convert';
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
import 'package:flutter_udid/flutter_udid.dart';
|
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:solian/exceptions/request.dart';
|
|
||||||
import 'package:solian/models/notification.dart';
|
import 'package:solian/models/notification.dart';
|
||||||
import 'package:solian/models/packet.dart';
|
import 'package:solian/models/packet.dart';
|
||||||
import 'package:solian/models/pagination.dart';
|
|
||||||
import 'package:solian/platform.dart';
|
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
|
import 'package:solian/providers/notifications.dart';
|
||||||
import 'package:solian/services.dart';
|
import 'package:solian/services.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
@ -21,56 +15,10 @@ class WebSocketProvider extends GetxController {
|
|||||||
RxBool isConnected = false.obs;
|
RxBool isConnected = false.obs;
|
||||||
RxBool isConnecting = false.obs;
|
RxBool isConnecting = false.obs;
|
||||||
|
|
||||||
RxInt notificationUnread = 0.obs;
|
|
||||||
RxList<Notification> notifications =
|
|
||||||
List<Notification>.empty(growable: true).obs;
|
|
||||||
|
|
||||||
WebSocketChannel? websocket;
|
WebSocketChannel? websocket;
|
||||||
|
|
||||||
StreamController<NetworkPackage> stream = StreamController.broadcast();
|
StreamController<NetworkPackage> stream = StreamController.broadcast();
|
||||||
|
|
||||||
@override
|
|
||||||
onInit() {
|
|
||||||
notifyPrefetch();
|
|
||||||
|
|
||||||
super.onInit();
|
|
||||||
}
|
|
||||||
|
|
||||||
void requestPermissions() {
|
|
||||||
try {
|
|
||||||
FirebaseMessaging.instance.requestPermission(
|
|
||||||
alert: true,
|
|
||||||
announcement: true,
|
|
||||||
carPlay: true,
|
|
||||||
badge: true,
|
|
||||||
sound: true);
|
|
||||||
} catch (_) {
|
|
||||||
// When firebase isn't initialized (background service)
|
|
||||||
FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
|
|
||||||
FlutterLocalNotificationsPlugin();
|
|
||||||
flutterLocalNotificationsPlugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
AndroidFlutterLocalNotificationsPlugin>()
|
|
||||||
?.requestNotificationsPermission();
|
|
||||||
flutterLocalNotificationsPlugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
IOSFlutterLocalNotificationsPlugin>()
|
|
||||||
?.requestPermissions(
|
|
||||||
alert: true,
|
|
||||||
badge: true,
|
|
||||||
sound: true,
|
|
||||||
);
|
|
||||||
flutterLocalNotificationsPlugin
|
|
||||||
.resolvePlatformSpecificImplementation<
|
|
||||||
MacOSFlutterLocalNotificationsPlugin>()
|
|
||||||
?.requestPermissions(
|
|
||||||
alert: true,
|
|
||||||
badge: true,
|
|
||||||
sound: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> connect({noRetry = false}) async {
|
Future<void> connect({noRetry = false}) async {
|
||||||
if (isConnected.value) {
|
if (isConnected.value) {
|
||||||
return;
|
return;
|
||||||
@ -119,8 +67,9 @@ class WebSocketProvider extends GetxController {
|
|||||||
log('Websocket incoming message: ${packet.method} ${packet.message}');
|
log('Websocket incoming message: ${packet.method} ${packet.message}');
|
||||||
stream.sink.add(packet);
|
stream.sink.add(packet);
|
||||||
if (packet.method == 'notifications.new') {
|
if (packet.method == 'notifications.new') {
|
||||||
notifications.add(Notification.fromJson(packet.payload!));
|
final NotificationProvider nty = Get.find();
|
||||||
notificationUnread.value++;
|
nty.notifications.add(Notification.fromJson(packet.payload!));
|
||||||
|
nty.notificationUnread.value++;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDone: () {
|
onDone: () {
|
||||||
@ -133,70 +82,4 @@ class WebSocketProvider extends GetxController {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> notifyPrefetch() async {
|
|
||||||
final AuthProvider auth = Get.find();
|
|
||||||
if (auth.isAuthorized.isFalse) return;
|
|
||||||
|
|
||||||
final client = await auth.configureClient('auth');
|
|
||||||
|
|
||||||
final resp = await client.get('/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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> registerPushNotifications() async {
|
|
||||||
if (PlatformInfo.isWeb) return;
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (prefs.getBool('service_background_notification') == true) {
|
|
||||||
log('Background notification service has been enabled, skip register push notifications');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final AuthProvider auth = Get.find();
|
|
||||||
if (auth.isAuthorized.isFalse) return;
|
|
||||||
|
|
||||||
late final String? token;
|
|
||||||
late final String provider;
|
|
||||||
var deviceUuid = await _getDeviceUuid();
|
|
||||||
|
|
||||||
if (deviceUuid == null || deviceUuid.isEmpty) {
|
|
||||||
log("Unable to active push notifications, couldn't get device uuid");
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
log('Device UUID is $deviceUuid');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PlatformInfo.isIOS || PlatformInfo.isMacOS) {
|
|
||||||
provider = 'apple';
|
|
||||||
token = await FirebaseMessaging.instance.getAPNSToken();
|
|
||||||
} else {
|
|
||||||
provider = 'firebase';
|
|
||||||
token = await FirebaseMessaging.instance.getToken();
|
|
||||||
}
|
|
||||||
log('Device Push Token is $token');
|
|
||||||
|
|
||||||
final client = await auth.configureClient('auth');
|
|
||||||
|
|
||||||
final resp = await client.post('/notifications/subscribe', {
|
|
||||||
'provider': provider,
|
|
||||||
'device_token': token,
|
|
||||||
'device_id': deviceUuid,
|
|
||||||
});
|
|
||||||
if (resp.statusCode != 200 && resp.statusCode != 400) {
|
|
||||||
throw RequestException(resp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> _getDeviceUuid() async {
|
|
||||||
if (PlatformInfo.isWeb) return null;
|
|
||||||
return await FlutterUdid.consistentUdid;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
import 'package:solian/models/notification.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/models/post.dart';
|
||||||
import 'package:solian/models/notification.dart' as notify;
|
import 'package:solian/providers/notifications.dart';
|
||||||
|
import 'package:solian/router.dart';
|
||||||
|
import 'package:solian/widgets/loading_indicator.dart';
|
||||||
|
import 'package:solian/widgets/markdown_text_content.dart';
|
||||||
|
import 'package:solian/widgets/posts/post_item.dart';
|
||||||
|
import 'package:solian/widgets/relative_date.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class NotificationScreen extends StatefulWidget {
|
class NotificationScreen extends StatefulWidget {
|
||||||
@ -14,57 +19,9 @@ class NotificationScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _NotificationScreenState extends State<NotificationScreen> {
|
class _NotificationScreenState extends State<NotificationScreen> {
|
||||||
bool _isBusy = false;
|
|
||||||
|
|
||||||
Future<void> _markAllRead() async {
|
|
||||||
final AuthProvider auth = Get.find();
|
|
||||||
if (auth.isAuthorized.isFalse) return;
|
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
|
||||||
|
|
||||||
final WebSocketProvider provider = Get.find();
|
|
||||||
|
|
||||||
List<int> markList = List.empty(growable: true);
|
|
||||||
for (final element in provider.notifications) {
|
|
||||||
if (element.id <= 0) continue;
|
|
||||||
markList.add(element.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (markList.isNotEmpty) {
|
|
||||||
final client = await auth.configureClient('auth');
|
|
||||||
await client.put('/notifications/read', {'messages': markList});
|
|
||||||
}
|
|
||||||
|
|
||||||
provider.notifications.clear();
|
|
||||||
|
|
||||||
setState(() => _isBusy = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _markOneRead(notify.Notification element, int index) async {
|
|
||||||
final AuthProvider auth = Get.find();
|
|
||||||
if (auth.isAuthorized.isFalse) return;
|
|
||||||
|
|
||||||
final WebSocketProvider provider = Get.find();
|
|
||||||
|
|
||||||
if (element.id <= 0) {
|
|
||||||
provider.notifications.removeAt(index);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() => _isBusy = true);
|
|
||||||
|
|
||||||
final client = await auth.configureClient('auth');
|
|
||||||
|
|
||||||
await client.put('/notifications/read/${element.id}', {});
|
|
||||||
|
|
||||||
provider.notifications.removeAt(index);
|
|
||||||
|
|
||||||
setState(() => _isBusy = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final WebSocketProvider ws = Get.find();
|
final NotificationProvider nty = Get.find();
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: MediaQuery.of(context).size.height * 0.85,
|
height: MediaQuery.of(context).size.height * 0.85,
|
||||||
@ -77,71 +34,172 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
|||||||
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
return CustomScrollView(
|
return RefreshIndicator(
|
||||||
slivers: [
|
onRefresh: () => nty.fetchNotification(),
|
||||||
if (_isBusy)
|
child: CustomScrollView(
|
||||||
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: const LinearProgressIndicator().animate().scaleX(),
|
child: LoadingIndicator(
|
||||||
),
|
isActive: nty.isBusy.value,
|
||||||
if (ws.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 (ws.notifications.isNotEmpty)
|
if (nty.notifications
|
||||||
SliverToBoxAdapter(
|
.where((x) => x.readAt == null)
|
||||||
child: Container(
|
.isEmpty)
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
SliverToBoxAdapter(
|
||||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
child: Container(
|
||||||
child: ListTile(
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
leading: const Icon(Icons.checklist),
|
color: Theme.of(context)
|
||||||
title: Text('notifyAllRead'.tr),
|
.colorScheme
|
||||||
onTap: _isBusy ? null : () => _markAllRead(),
|
.surfaceContainerHigh,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.check),
|
||||||
|
title: Text('notifyEmpty'.tr),
|
||||||
|
subtitle: Text('notifyEmptyCaption'.tr),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (nty.notifications
|
||||||
|
.where((x) => x.readAt == null)
|
||||||
|
.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
color:
|
||||||
|
Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
child: ListTile(
|
||||||
|
leading: const Icon(Icons.checklist),
|
||||||
|
title: Text('notifyAllRead'.tr),
|
||||||
|
onTap: nty.isBusy.value
|
||||||
|
? null
|
||||||
|
: () => nty.markAllRead(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverList.separated(
|
||||||
|
itemCount: nty.notifications.length,
|
||||||
|
itemBuilder: (BuildContext context, int index) {
|
||||||
|
var element = nty.notifications[index];
|
||||||
|
return ClipRect(
|
||||||
|
child: Dismissible(
|
||||||
|
direction: element.readAt == null
|
||||||
|
? DismissDirection.horizontal
|
||||||
|
: DismissDirection.none,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
secondaryBackground: Container(
|
||||||
|
color: Colors.lightBlue,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child:
|
||||||
|
const Icon(Icons.check, color: Colors.white),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 16,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(NotificationTopicIcons[element.topic]),
|
||||||
|
const Gap(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (element.readAt == null)
|
||||||
|
Badge(
|
||||||
|
label: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.new_releases_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text('unread'.tr),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).paddingOnly(bottom: 4),
|
||||||
|
Text(
|
||||||
|
element.title,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium,
|
||||||
|
),
|
||||||
|
if (element.subtitle != null)
|
||||||
|
Text(
|
||||||
|
element.subtitle!,
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleSmall,
|
||||||
|
),
|
||||||
|
if (element.subtitle != null)
|
||||||
|
const Gap(4),
|
||||||
|
MarkdownTextContent(
|
||||||
|
content: element.body,
|
||||||
|
isAutoWarp: true,
|
||||||
|
isSelectable: true,
|
||||||
|
parentId:
|
||||||
|
'notification-${element.id}',
|
||||||
|
),
|
||||||
|
if ([
|
||||||
|
'interactive.feedback',
|
||||||
|
'interactive.subscription'
|
||||||
|
].contains(element.topic) &&
|
||||||
|
element.metadata?['related_post'] !=
|
||||||
|
null)
|
||||||
|
_PostRelatedNotificationWidget(
|
||||||
|
metadata: element.metadata!,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Opacity(
|
||||||
|
opacity: 0.75,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
RelativeDate(
|
||||||
|
element.createdAt,
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'·',
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
RelativeDate(
|
||||||
|
element.createdAt,
|
||||||
|
style: TextStyle(fontSize: 12),
|
||||||
|
isFull: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onDismissed: (_) => nty.markOneRead(element, index),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider(thickness: 0.3, height: 0.3),
|
||||||
),
|
),
|
||||||
SliverList.separated(
|
],
|
||||||
itemCount: ws.notifications.length,
|
),
|
||||||
itemBuilder: (BuildContext context, int index) {
|
|
||||||
var element = ws.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.title),
|
|
||||||
subtitle: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (element.subtitle != null)
|
|
||||||
Text(element.subtitle!),
|
|
||||||
Text(element.body),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onDismissed: (_) => _markOneRead(element, index),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
separatorBuilder: (_, __) =>
|
|
||||||
const Divider(thickness: 0.3, height: 0.3),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@ -156,7 +214,7 @@ class NotificationButton extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final WebSocketProvider provider = Get.find();
|
final NotificationProvider nty = Get.find();
|
||||||
|
|
||||||
final button = IconButton(
|
final button = IconButton(
|
||||||
icon: const Icon(Icons.notifications),
|
icon: const Icon(Icons.notifications),
|
||||||
@ -166,16 +224,16 @@ class NotificationButton extends StatelessWidget {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => const NotificationScreen(),
|
builder: (context) => const NotificationScreen(),
|
||||||
).then((_) => provider.notificationUnread.value = 0);
|
).then((_) => nty.notificationUnread.value = 0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (provider.notificationUnread.value > 0) {
|
if (nty.notificationUnread.value > 0) {
|
||||||
return Badge(
|
return Badge(
|
||||||
isLabelVisible: true,
|
isLabelVisible: true,
|
||||||
offset: const Offset(-8, 2),
|
offset: const Offset(-8, 2),
|
||||||
label: Text(provider.notificationUnread.value.toString()),
|
label: Text(nty.notificationUnread.value.toString()),
|
||||||
child: button,
|
child: button,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -184,3 +242,31 @@ class NotificationButton extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PostRelatedNotificationWidget extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> metadata;
|
||||||
|
|
||||||
|
const _PostRelatedNotificationWidget({super.key, required this.metadata});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
child: Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: PostItem(
|
||||||
|
item: Post.fromJson(metadata['related_post']),
|
||||||
|
isCompact: true,
|
||||||
|
).paddingAll(8),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final data = Post.fromJson(metadata['related_post']);
|
||||||
|
Navigator.pop(context);
|
||||||
|
AppRouter.instance.pushNamed(
|
||||||
|
'postDetail',
|
||||||
|
pathParameters: {'id': data.id.toString()},
|
||||||
|
extra: data,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -8,8 +8,8 @@ import 'package:solian/exts.dart';
|
|||||||
import 'package:solian/models/auth.dart';
|
import 'package:solian/models/auth.dart';
|
||||||
import 'package:solian/providers/auth.dart';
|
import 'package:solian/providers/auth.dart';
|
||||||
import 'package:solian/providers/content/realm.dart';
|
import 'package:solian/providers/content/realm.dart';
|
||||||
|
import 'package:solian/providers/notifications.dart';
|
||||||
import 'package:solian/providers/relation.dart';
|
import 'package:solian/providers/relation.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
|
||||||
import 'package:solian/services.dart';
|
import 'package:solian/services.dart';
|
||||||
import 'package:solian/widgets/sized_container.dart';
|
import 'package:solian/widgets/sized_container.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
@ -178,7 +178,7 @@ class _SignInScreenState extends State<SignInScreen> {
|
|||||||
|
|
||||||
Get.find<RealmProvider>().refreshAvailableRealms();
|
Get.find<RealmProvider>().refreshAvailableRealms();
|
||||||
Get.find<RelationshipProvider>().refreshRelativeList();
|
Get.find<RelationshipProvider>().refreshRelativeList();
|
||||||
Get.find<WebSocketProvider>().registerPushNotifications();
|
Get.find<NotificationProvider>().registerPushNotifications();
|
||||||
autoConfigureBackgroundNotificationService();
|
autoConfigureBackgroundNotificationService();
|
||||||
autoStartBackgroundNotificationService();
|
autoStartBackgroundNotificationService();
|
||||||
|
|
||||||
|
@ -47,16 +47,19 @@ class ChatListShell extends StatelessWidget {
|
|||||||
direction: Axis.horizontal,
|
direction: Axis.horizontal,
|
||||||
divider: ResizableDivider(
|
divider: ResizableDivider(
|
||||||
thickness: 0.3,
|
thickness: 0.3,
|
||||||
color: Theme.of(context).dividerColor,
|
color: Theme.of(context).dividerColor.withOpacity(0.3),
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
const ResizableChild(
|
const ResizableChild(
|
||||||
minSize: 280,
|
minSize: 280,
|
||||||
maxSize: 520,
|
maxSize: 520,
|
||||||
size: ResizableSize.pixels(320),
|
size: ResizableSize.pixels(360),
|
||||||
child: ChatList(),
|
child: ChatList(),
|
||||||
),
|
),
|
||||||
ResizableChild(child: child ?? const EmptyPagePlaceholder()),
|
ResizableChild(
|
||||||
|
minSize: 280,
|
||||||
|
child: child ?? const EmptyPagePlaceholder(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -20,7 +20,7 @@ import 'package:solian/providers/content/posts.dart';
|
|||||||
import 'package:solian/providers/daily_sign.dart';
|
import 'package:solian/providers/daily_sign.dart';
|
||||||
import 'package:solian/providers/database/services/messages.dart';
|
import 'package:solian/providers/database/services/messages.dart';
|
||||||
import 'package:solian/providers/last_read.dart';
|
import 'package:solian/providers/last_read.dart';
|
||||||
import 'package:solian/providers/websocket.dart';
|
import 'package:solian/providers/notifications.dart';
|
||||||
import 'package:solian/router.dart';
|
import 'package:solian/router.dart';
|
||||||
import 'package:solian/screens/account/notification.dart';
|
import 'package:solian/screens/account/notification.dart';
|
||||||
import 'package:solian/theme.dart';
|
import 'package:solian/theme.dart';
|
||||||
@ -38,7 +38,7 @@ class DashboardScreen extends StatefulWidget {
|
|||||||
class _DashboardScreenState extends State<DashboardScreen> {
|
class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
late final AuthProvider _auth = Get.find();
|
late final AuthProvider _auth = Get.find();
|
||||||
late final LastReadProvider _lastRead = Get.find();
|
late final LastReadProvider _lastRead = Get.find();
|
||||||
late final WebSocketProvider _ws = Get.find();
|
late final NotificationProvider _nty = Get.find();
|
||||||
late final PostProvider _posts = Get.find();
|
late final PostProvider _posts = Get.find();
|
||||||
late final DailySignProvider _dailySign = Get.find();
|
late final DailySignProvider _dailySign = Get.find();
|
||||||
|
|
||||||
@ -46,7 +46,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||||
|
|
||||||
List<Notification> get _pendingNotifications =>
|
List<Notification> get _pendingNotifications =>
|
||||||
List<Notification>.from(_ws.notifications)
|
List<Notification>.from(_nty.notifications.where((x) => x.readAt == null))
|
||||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
|
||||||
List<Post>? _currentPosts;
|
List<Post>? _currentPosts;
|
||||||
@ -254,7 +254,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'notificationUnreadCount'.trParams({
|
'notificationUnreadCount'.trParams({
|
||||||
'count': _ws.notifications.length.toString(),
|
'count': _pendingNotifications.length.toString(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -267,12 +267,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => const NotificationScreen(),
|
builder: (context) => const NotificationScreen(),
|
||||||
).then((_) => _ws.notificationUnread.value = 0);
|
).then((_) => _nty.notificationUnread.value = 0);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).paddingOnly(left: 18, right: 18, bottom: 8),
|
).paddingOnly(left: 18, right: 18, bottom: 8),
|
||||||
if (_ws.notifications.isNotEmpty)
|
if (_pendingNotifications.isNotEmpty)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 76,
|
height: 76,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
@ -1,15 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
|
||||||
import 'package:solian/models/pagination.dart';
|
import 'package:solian/models/pagination.dart';
|
||||||
import 'package:solian/models/post.dart';
|
import 'package:solian/models/post.dart';
|
||||||
import 'package:solian/providers/content/posts.dart';
|
import 'package:solian/providers/content/posts.dart';
|
||||||
import 'package:solian/theme.dart';
|
import 'package:solian/theme.dart';
|
||||||
import 'package:solian/widgets/app_bar_leading.dart';
|
import 'package:solian/widgets/app_bar_leading.dart';
|
||||||
import 'package:solian/widgets/app_bar_title.dart';
|
import 'package:solian/widgets/app_bar_title.dart';
|
||||||
|
import 'package:solian/widgets/loading_indicator.dart';
|
||||||
import 'package:solian/widgets/posts/post_action.dart';
|
import 'package:solian/widgets/posts/post_action.dart';
|
||||||
import 'package:solian/widgets/posts/post_owned_list.dart';
|
import 'package:solian/widgets/posts/post_item.dart';
|
||||||
import 'package:solian/widgets/root_container.dart';
|
import 'package:solian/widgets/root_container.dart';
|
||||||
|
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||||
|
|
||||||
class DraftBoxScreen extends StatefulWidget {
|
class DraftBoxScreen extends StatefulWidget {
|
||||||
const DraftBoxScreen({super.key});
|
const DraftBoxScreen({super.key});
|
||||||
@ -19,38 +20,50 @@ class DraftBoxScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _DraftBoxScreenState extends State<DraftBoxScreen> {
|
class _DraftBoxScreenState extends State<DraftBoxScreen> {
|
||||||
final PagingController<int, Post> _pagingController =
|
bool _isBusy = true;
|
||||||
PagingController(firstPageKey: 0);
|
int? _totalPosts;
|
||||||
|
final List<Post> _posts = List.empty(growable: true);
|
||||||
|
|
||||||
_getPosts(int pageKey) async {
|
_getPosts() async {
|
||||||
final PostProvider provider = Get.find();
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
Response resp;
|
final PostProvider posts = Get.find();
|
||||||
try {
|
final resp = await posts.listDraft(_posts.length);
|
||||||
resp = await provider.listDraft(pageKey);
|
|
||||||
} catch (e) {
|
|
||||||
_pagingController.error = e;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final PaginationResult result = PaginationResult.fromJson(resp.body);
|
final PaginationResult result = PaginationResult.fromJson(resp.body);
|
||||||
if (result.count == 0) {
|
|
||||||
_pagingController.appendLastPage([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
|
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
|
||||||
if (parsed != null && parsed.length >= 10) {
|
_totalPosts = result.count;
|
||||||
_pagingController.appendPage(parsed, pageKey + parsed.length);
|
_posts.addAll(parsed ?? List.empty());
|
||||||
} else if (parsed != null) {
|
|
||||||
_pagingController.appendLastPage(parsed);
|
setState(() => _isBusy = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _openActions(Post item) async {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => PostAction(
|
||||||
|
item: item,
|
||||||
|
noReact: true,
|
||||||
|
),
|
||||||
|
).then((value) {
|
||||||
|
if (value is Future) {
|
||||||
|
value.then((_) {
|
||||||
|
_posts.clear();
|
||||||
|
_getPosts();
|
||||||
|
});
|
||||||
|
} else if (value != null) {
|
||||||
|
_posts.clear();
|
||||||
|
_getPosts();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pagingController.addPageRequestListener(_getPosts);
|
_getPosts();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -68,47 +81,48 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: Column(
|
||||||
onRefresh: () => Future.sync(() => _pagingController.refresh()),
|
children: [
|
||||||
child: PagedListView<int, Post>(
|
LoadingIndicator(isActive: _isBusy),
|
||||||
pagingController: _pagingController,
|
Expanded(
|
||||||
builderDelegate: PagedChildBuilderDelegate(
|
child: RefreshIndicator(
|
||||||
itemBuilder: (context, item, index) {
|
onRefresh: () {
|
||||||
return PostOwnedListEntry(
|
_posts.clear();
|
||||||
item: item,
|
return _getPosts();
|
||||||
isFullContent: true,
|
},
|
||||||
backgroundColor:
|
child: InfiniteList(
|
||||||
Theme.of(context).colorScheme.surfaceContainerLow,
|
itemCount: _posts.length,
|
||||||
onTap: () async {
|
hasReachedMax: _totalPosts == _posts.length,
|
||||||
showModalBottomSheet(
|
isLoading: _isBusy,
|
||||||
useRootNavigator: true,
|
onFetchData: () => _getPosts(),
|
||||||
context: context,
|
itemBuilder: (context, index) {
|
||||||
builder: (context) => PostAction(
|
final item = _posts[index];
|
||||||
item: item,
|
return Card(
|
||||||
noReact: true,
|
child: GestureDetector(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
PostItem(
|
||||||
|
key: Key('p${item.id}'),
|
||||||
|
item: item,
|
||||||
|
isShowEmbed: false,
|
||||||
|
isClickable: false,
|
||||||
|
isShowReply: false,
|
||||||
|
isReactable: false,
|
||||||
|
onTapMore: () => _openActions(item),
|
||||||
|
).paddingSymmetric(vertical: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () => _openActions(item),
|
||||||
),
|
),
|
||||||
).then((value) {
|
).paddingOnly(left: 12, right: 12, bottom: 4);
|
||||||
if (value is Future) {
|
|
||||||
value.then((_) {
|
|
||||||
_pagingController.refresh();
|
|
||||||
});
|
|
||||||
} else if (value != null) {
|
|
||||||
_pagingController.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
).paddingOnly(left: 12, right: 12, bottom: 4);
|
),
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_pagingController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,9 @@ class PostSearchScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PostSearchScreenState extends State<PostSearchScreen> {
|
class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||||
|
int? _totalCount;
|
||||||
|
Duration? _lastTook;
|
||||||
|
|
||||||
final TextEditingController _probeController = TextEditingController();
|
final TextEditingController _probeController = TextEditingController();
|
||||||
final PagingController<int, Post> _pagingController =
|
final PagingController<int, Post> _pagingController =
|
||||||
PagingController(firstPageKey: 0);
|
PagingController(firstPageKey: 0);
|
||||||
@ -43,18 +46,20 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
_pagingController.nextPageKey = 0;
|
_pagingController.nextPageKey = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final PostProvider provider = Get.find();
|
final PostProvider posts = Get.find();
|
||||||
|
|
||||||
|
Stopwatch stopwatch = new Stopwatch()..start();
|
||||||
|
|
||||||
Response resp;
|
Response resp;
|
||||||
try {
|
try {
|
||||||
if (_probeController.text.isEmpty) {
|
if (_probeController.text.isEmpty) {
|
||||||
resp = await provider.listPost(
|
resp = await posts.listPost(
|
||||||
pageKey,
|
pageKey,
|
||||||
tag: widget.tag,
|
tag: widget.tag,
|
||||||
category: widget.category,
|
category: widget.category,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
resp = await provider.searchPost(
|
resp = await posts.searchPost(
|
||||||
_probeController.text,
|
_probeController.text,
|
||||||
pageKey,
|
pageKey,
|
||||||
tag: widget.tag,
|
tag: widget.tag,
|
||||||
@ -74,6 +79,11 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
_pagingController.appendLastPage(parsed);
|
_pagingController.appendLastPage(parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopwatch.stop();
|
||||||
|
|
||||||
|
_totalCount = result.count;
|
||||||
|
_lastTook = stopwatch.elapsed;
|
||||||
|
|
||||||
setState(() => _isBusy = false);
|
setState(() => _isBusy = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,6 +100,9 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Color get _unFocusColor =>
|
||||||
|
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@ -136,6 +149,42 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
LoadingIndicator(isActive: _isBusy),
|
LoadingIndicator(isActive: _isBusy),
|
||||||
|
if (_totalCount != null || _lastTook != null)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.summarize_outlined,
|
||||||
|
size: 16,
|
||||||
|
color: _unFocusColor,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
if (_totalCount != null)
|
||||||
|
Text(
|
||||||
|
'searchResult'.trParams({
|
||||||
|
'count': _totalCount!.toString(),
|
||||||
|
}),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: _unFocusColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
if (_lastTook != null)
|
||||||
|
Text(
|
||||||
|
'searchTook'.trParams({
|
||||||
|
'time':
|
||||||
|
'${(_lastTook!.inMilliseconds / 1000).toStringAsFixed(3)}s',
|
||||||
|
}),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: _unFocusColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
onRefresh: () => Future.sync(() => _pagingController.refresh()),
|
onRefresh: () => Future.sync(() => _pagingController.refresh()),
|
||||||
|
@ -43,7 +43,10 @@ class RootShell extends StatelessWidget {
|
|||||||
|
|
||||||
final showRailNavigation = AppTheme.isLargeScreen(context);
|
final showRailNavigation = AppTheme.isLargeScreen(context);
|
||||||
|
|
||||||
final destNames = AppNavigation.destinations.map((x) => x.page).toList();
|
final destNames = [
|
||||||
|
'postDetail',
|
||||||
|
...AppNavigation.destinations.map((x) => x.page),
|
||||||
|
];
|
||||||
final showBottomNavigation =
|
final showBottomNavigation =
|
||||||
destNames.contains(routeName) && !showRailNavigation;
|
destNames.contains(routeName) && !showRailNavigation;
|
||||||
|
|
||||||
@ -52,13 +55,22 @@ class RootShell extends StatelessWidget {
|
|||||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
bottomNavigationBar: showBottomNavigation
|
bottomNavigationBar: showBottomNavigation
|
||||||
? AppNavigationBottom(
|
? AppNavigationBottom(
|
||||||
initialIndex: destNames.indexOf(routeName ?? 'page'),
|
initialIndex: AppNavigation.destinations
|
||||||
|
.map((x) => x.page)
|
||||||
|
.toList()
|
||||||
|
.indexOf(routeName ?? 'page'),
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
body: AppTheme.isLargeScreen(context)
|
body: AppTheme.isLargeScreen(context)
|
||||||
? Row(
|
? Row(
|
||||||
children: [
|
children: [
|
||||||
if (showRailNavigation) const AppNavigationRail(),
|
if (showRailNavigation)
|
||||||
|
AppNavigationRail(
|
||||||
|
initialIndex: AppNavigation.destinations
|
||||||
|
.map((x) => x.page)
|
||||||
|
.toList()
|
||||||
|
.indexOf(routeName ?? 'page'),
|
||||||
|
),
|
||||||
if (showRailNavigation)
|
if (showRailNavigation)
|
||||||
const VerticalDivider(
|
const VerticalDivider(
|
||||||
width: 0.3,
|
width: 0.3,
|
||||||
|
@ -8,6 +8,7 @@ import 'package:flutter_animate/flutter_animate.dart';
|
|||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:solian/exts.dart';
|
import 'package:solian/exts.dart';
|
||||||
import 'package:solian/models/attachment.dart';
|
import 'package:solian/models/attachment.dart';
|
||||||
import 'package:solian/platform.dart';
|
import 'package:solian/platform.dart';
|
||||||
@ -103,9 +104,10 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final metaTextStyle = TextStyle(
|
final metaTextStyle = GoogleFonts.roboto(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: _unFocusColor,
|
color: _unFocusColor,
|
||||||
|
height: 1,
|
||||||
);
|
);
|
||||||
|
|
||||||
return DismissiblePage(
|
return DismissiblePage(
|
||||||
@ -239,27 +241,43 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
|
|||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 6,
|
spacing: 6,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
if (widget.item.metadata?['exif'] == null)
|
||||||
'#${widget.item.rid}',
|
|
||||||
style: metaTextStyle,
|
|
||||||
),
|
|
||||||
if (widget.item.metadata?['width'] != null &&
|
|
||||||
widget.item.metadata?['height'] != null)
|
|
||||||
Text(
|
Text(
|
||||||
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
|
'#${widget.item.rid}',
|
||||||
style: metaTextStyle,
|
style: metaTextStyle,
|
||||||
),
|
),
|
||||||
if (widget.item.metadata?['ratio'] != null)
|
if (widget.item.metadata?['exif']?['Model'] != null)
|
||||||
Text(
|
Text(
|
||||||
'${_getRatio().toPrecision(2)}',
|
'shotOn'.trParams({
|
||||||
|
'device': widget.item.metadata?['exif']
|
||||||
|
?['Model']
|
||||||
|
}),
|
||||||
|
style: metaTextStyle,
|
||||||
|
).paddingOnly(right: 2),
|
||||||
|
if (widget.item.metadata?['exif']?['ShutterSpeed'] !=
|
||||||
|
null)
|
||||||
|
Text(
|
||||||
|
widget.item.metadata?['exif']?['ShutterSpeed'],
|
||||||
|
style: metaTextStyle,
|
||||||
|
).paddingOnly(right: 2),
|
||||||
|
if (widget.item.metadata?['exif']?['ISO'] != null)
|
||||||
|
Text(
|
||||||
|
'ISO${widget.item.metadata?['exif']?['ISO']}',
|
||||||
|
style: metaTextStyle,
|
||||||
|
).paddingOnly(right: 2),
|
||||||
|
if (widget.item.metadata?['exif']?['Megapixels'] !=
|
||||||
|
null)
|
||||||
|
Text(
|
||||||
|
'${widget.item.metadata?['exif']?['Megapixels']}MP',
|
||||||
|
style: metaTextStyle,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
widget.item.size.formatBytes(),
|
||||||
style: metaTextStyle,
|
style: metaTextStyle,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
widget.item.size.formatBytes(),
|
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
|
||||||
style: metaTextStyle,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
widget.item.mimetype,
|
|
||||||
style: metaTextStyle,
|
style: metaTextStyle,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_animate/flutter_animate.dart';
|
import 'package:flutter_animate/flutter_animate.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
@ -34,6 +35,24 @@ class ChatEventList extends StatelessWidget {
|
|||||||
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
|
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openActions(BuildContext context, Event item) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => ChatEventAction(
|
||||||
|
channel: channel,
|
||||||
|
realm: channel.realm,
|
||||||
|
item: item,
|
||||||
|
onEdit: () {
|
||||||
|
onEdit(item);
|
||||||
|
},
|
||||||
|
onReply: () {
|
||||||
|
onReply(item);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
@ -65,50 +84,45 @@ class ChatEventList extends StatelessWidget {
|
|||||||
|
|
||||||
final item = chatController.currentEvents[index].data;
|
final item = chatController.currentEvents[index].data;
|
||||||
|
|
||||||
return GestureDetector(
|
return TapRegion(
|
||||||
behavior: HitTestBehavior.opaque,
|
child: GestureDetector(
|
||||||
child: Builder(builder: (context) {
|
behavior: HitTestBehavior.opaque,
|
||||||
final widget = ChatEvent(
|
child: Builder(builder: (context) {
|
||||||
key: Key('m${item!.uuid}'),
|
final widget = ChatEvent(
|
||||||
item: item,
|
key: Key('m${item!.uuid}'),
|
||||||
isMerged: isMerged,
|
item: item,
|
||||||
chatController: chatController,
|
isMerged: isMerged,
|
||||||
).paddingOnly(
|
chatController: chatController,
|
||||||
top: !isMerged ? 8 : 0,
|
).paddingOnly(
|
||||||
bottom: !hasMerged ? 8 : 0,
|
top: !isMerged ? 8 : 0,
|
||||||
);
|
bottom: !hasMerged ? 8 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
if (noAnimated) {
|
if (noAnimated) {
|
||||||
return widget;
|
return widget;
|
||||||
} else {
|
} else {
|
||||||
return widget
|
return widget
|
||||||
.animate(
|
.animate(
|
||||||
key: Key('animated-m${item.uuid}'),
|
key: Key('animated-m${item.uuid}'),
|
||||||
)
|
)
|
||||||
.slideY(
|
.slideY(
|
||||||
curve: Curves.fastLinearToSlowEaseIn,
|
curve: Curves.fastLinearToSlowEaseIn,
|
||||||
duration: 250.ms,
|
duration: 250.ms,
|
||||||
begin: 0.5,
|
begin: 0.5,
|
||||||
end: 0,
|
end: 0,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onLongPress: () {
|
||||||
|
_openActions(context, item!);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onTapInside: (event) {
|
||||||
|
if (event.buttons == kSecondaryMouseButton) {
|
||||||
|
_openActions(context, item!);
|
||||||
|
} else if (event.buttons == kMiddleMouseButton) {
|
||||||
|
onReply(item!);
|
||||||
}
|
}
|
||||||
}),
|
|
||||||
onLongPress: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) => ChatEventAction(
|
|
||||||
channel: channel,
|
|
||||||
realm: channel.realm,
|
|
||||||
item: item!,
|
|
||||||
onEdit: () {
|
|
||||||
onEdit(item);
|
|
||||||
},
|
|
||||||
onReply: () {
|
|
||||||
onReply(item);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -91,17 +91,21 @@ class _PostActionState extends State<PostAction> {
|
|||||||
|
|
||||||
setState(() => _isBusy = true);
|
setState(() => _isBusy = true);
|
||||||
|
|
||||||
|
final double width = hasMultipleAttachment ? 640 : 480;
|
||||||
|
|
||||||
final screenshot = ScreenshotController();
|
final screenshot = ScreenshotController();
|
||||||
final image = await screenshot.captureFromLongWidget(
|
final image = await screenshot.captureFromLongWidget(
|
||||||
MediaQuery(
|
MediaQuery(
|
||||||
data: MediaQuery.of(context),
|
data: MediaQuery.of(context).copyWith(
|
||||||
|
size: Size(width, double.infinity),
|
||||||
|
),
|
||||||
child: PostShareImage(item: widget.item),
|
child: PostShareImage(item: widget.item),
|
||||||
),
|
),
|
||||||
context: context,
|
context: context,
|
||||||
pixelRatio: 2,
|
pixelRatio: 2,
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
minWidth: 480,
|
minWidth: 480,
|
||||||
maxWidth: hasMultipleAttachment ? 640 : 480,
|
maxWidth: width,
|
||||||
minHeight: 640,
|
minHeight: 640,
|
||||||
maxHeight: double.infinity,
|
maxHeight: double.infinity,
|
||||||
),
|
),
|
||||||
|
@ -660,6 +660,11 @@ class _PostHeaderWidget extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -2,
|
||||||
|
),
|
||||||
onPressed: () => onTapMore!(),
|
onPressed: () => onTapMore!(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:get/get.dart';
|
|
||||||
import 'package:solian/models/post.dart';
|
|
||||||
import 'package:solian/widgets/posts/post_item.dart';
|
|
||||||
|
|
||||||
class PostOwnedListEntry extends StatelessWidget {
|
|
||||||
final Post item;
|
|
||||||
final Function onTap;
|
|
||||||
final bool isFullContent;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
|
|
||||||
const PostOwnedListEntry({
|
|
||||||
super.key,
|
|
||||||
required this.item,
|
|
||||||
required this.onTap,
|
|
||||||
this.isFullContent = false,
|
|
||||||
this.backgroundColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Card(
|
|
||||||
child: GestureDetector(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
PostItem(
|
|
||||||
key: Key('p${item.id}'),
|
|
||||||
item: item,
|
|
||||||
isShowEmbed: false,
|
|
||||||
isClickable: false,
|
|
||||||
isShowReply: false,
|
|
||||||
isReactable: false,
|
|
||||||
).paddingSymmetric(vertical: 8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => onTap(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,20 +4,25 @@ import 'package:timeago/timeago.dart';
|
|||||||
|
|
||||||
class RelativeDate extends StatelessWidget {
|
class RelativeDate extends StatelessWidget {
|
||||||
final DateTime date;
|
final DateTime date;
|
||||||
|
final TextStyle? style;
|
||||||
final bool isFull;
|
final bool isFull;
|
||||||
|
|
||||||
const RelativeDate(this.date, {super.key, this.isFull = false});
|
const RelativeDate(this.date, {super.key, this.style, this.isFull = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (isFull) {
|
if (isFull) {
|
||||||
return Text(DateFormat('y/M/d HH:mm').format(date));
|
return Text(
|
||||||
|
DateFormat('y/M/d HH:mm').format(date),
|
||||||
|
style: style,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return Text(
|
return Text(
|
||||||
format(
|
format(
|
||||||
date,
|
date,
|
||||||
locale: 'en_short',
|
locale: 'en_short',
|
||||||
),
|
),
|
||||||
|
style: style,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ name: solian
|
|||||||
description: "The Solar Network App"
|
description: "The Solar Network App"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
|
|
||||||
version: 1.3.8+13
|
version: 1.4.0+16
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.3.4 <4.0.0"
|
sdk: ">=3.3.4 <4.0.0"
|
||||||
|
Reference in New Issue
Block a user