Compare commits

...

20 Commits

Author SHA1 Message Date
56fb92c6b9 🚀 Launch 1.4.0+15 2024-10-16 23:06:31 +08:00
b3267f0026 Summary on search post 2024-10-16 22:49:34 +08:00
88587c10da Notification embed post 2024-10-16 22:38:01 +08:00
9012566dbf 💄 Optimized notification list 2024-10-16 22:32:44 +08:00
6e00a99803 Better attachment fullscreen (support exif meta) 2024-10-16 22:16:03 +08:00
aa17a5d52a 🐛 Bug fixes on notifications 2024-10-16 00:53:29 +08:00
ebeffbe1aa ♻️ Refactored notification 2024-10-16 00:50:48 +08:00
d22eac5c10 🚀 Launch 1.4.0+14 2024-10-16 00:02:36 +08:00
e5381dd5e0 Support more mouse related actions 2024-10-16 00:02:18 +08:00
1c26944a05 🐛 Fix draft box 2024-10-15 21:14:56 +08:00
df787f02a1 🚀 Launch 1.3.8+13 2024-10-14 23:48:15 +08:00
db43b7dca5 💄 Better auto save 2024-10-14 23:08:53 +08:00
59c4d667f6 🐛 Bug fixes on edit content truncated post 2024-10-14 23:04:55 +08:00
063c087089 Post show more button 2024-10-14 22:58:37 +08:00
48e3b510cf Better audit logs 2024-10-14 22:05:49 +08:00
77288713e1 💫 Better loading animation 2024-10-14 21:13:57 +08:00
1abc65f8fa Share post as image on web 2024-10-14 20:51:56 +08:00
a6b17f2c05 Multi-platform support share as image 2024-10-14 13:26:30 +08:00
d8dd4060c0 🚀 Launch 1.3.7+12 2024-10-14 00:39:35 +08:00
c8e131c1ab 🐛 Fix share image size issue 2024-10-14 00:37:42 +08:00
52 changed files with 1126 additions and 614 deletions

View File

@ -140,7 +140,7 @@
"clear": "Clear", "clear": "Clear",
"pinPost": "Pin this post", "pinPost": "Pin this post",
"unpinPost": "Unpin this post", "unpinPost": "Unpin this post",
"postRestoreFromLocal": "Restore from local", "postRestoreFromLocal": "Restored",
"postAutoSaveAt": "Auto saved at @date", "postAutoSaveAt": "Auto saved at @date",
"postCategoriesAndTags": "Categories n' Tags", "postCategoriesAndTags": "Categories n' Tags",
"postPublishDate": "Publish Date", "postPublishDate": "Publish Date",
@ -367,7 +367,7 @@
"bsRegisteringPushNotify": "Enabling Push Notifications", "bsRegisteringPushNotify": "Enabling Push Notifications",
"bsDismissibleErrorHint": "Click anywhere to ignore this error", "bsDismissibleErrorHint": "Click anywhere to ignore this error",
"postShareContent": "@content\n\n@username on the Solar Network\nCheck it out: @link", "postShareContent": "@content\n\n@username on the Solar Network\nCheck it out: @link",
"postShareSubject": "@username posted a post on the Solar Network", "postShareSubject": "@title by @username on Solar Network",
"themeColor": "Global Theme Color", "themeColor": "Global Theme Color",
"themeColorRed": "Modern Red", "themeColorRed": "Modern Red",
"themeColorBlue": "Classic Blue", "themeColorBlue": "Classic Blue",
@ -484,5 +484,11 @@
"authMaximumAuthStepsDesc": "The maximum number of authentication steps when logging in, higher value is more secure, lower value is more convenient; default is 2", "authMaximumAuthStepsDesc": "The maximum number of authentication steps when logging in, higher value is more secure, lower value is more convenient; default is 2",
"auditLog": "Audit log", "auditLog": "Audit log",
"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",
"showIp": "Show IP Address",
"shotOn": "Shot on @device",
"unread": "Unread",
"searchTook": "Took @time",
"searchResult": "@count Matches"
} }

View File

@ -363,7 +363,7 @@
"bsRegisteringPushNotify": "正在启用推送通知", "bsRegisteringPushNotify": "正在启用推送通知",
"bsDismissibleErrorHint": "点击任意地方忽略此错误", "bsDismissibleErrorHint": "点击任意地方忽略此错误",
"postShareContent": "@content\n\n@username 在 Solar Network\n原帖地址@link", "postShareContent": "@content\n\n@username 在 Solar Network\n原帖地址@link",
"postShareSubject": "@username 在 Solar Network 上发布了一篇帖子", "postShareSubject": "@username 在 Solar Network 发表的 @title",
"themeColor": "全局主题色", "themeColor": "全局主题色",
"themeColorRed": "现代红", "themeColorRed": "现代红",
"themeColorBlue": "经典蓝", "themeColorBlue": "经典蓝",
@ -480,5 +480,11 @@
"authMaximumAuthStepsDesc": "登陆时最多的验证步数,值越高则越安全,反之则会相对方便;默认设置为 2", "authMaximumAuthStepsDesc": "登陆时最多的验证步数,值越高则越安全,反之则会相对方便;默认设置为 2",
"auditLog": "活动日志", "auditLog": "活动日志",
"shareImage": "分享图片", "shareImage": "分享图片",
"shareImageFooter": "上 Solar Network 看更多有趣帖子" "shareImageFooter": "上 Solar Network 看更多有趣帖子",
"fileSavedAt": "文件保存于 @path",
"showIp": "显示 IP 地址",
"shotOn": "由 @device 拍摄",
"unread": "未读",
"searchTook": "耗时 @time",
"searchResult": "匹配到 @count 条结果"
} }

View File

@ -38,6 +38,8 @@ PODS:
- file_picker (0.0.1): - file_picker (0.0.1):
- DKImagePickerController/PhotoGallery - DKImagePickerController/PhotoGallery
- Flutter - Flutter
- file_saver (0.0.1):
- Flutter
- Firebase/Analytics (11.2.0): - Firebase/Analytics (11.2.0):
- Firebase/Core - Firebase/Core
- Firebase/Core (11.2.0): - Firebase/Core (11.2.0):
@ -308,6 +310,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`)
- file_saver (from `.symlinks/plugins/file_saver/ios`)
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`) - firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`) - firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
@ -383,6 +386,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios" :path: ".symlinks/plugins/device_info_plus/ios"
file_picker: file_picker:
:path: ".symlinks/plugins/file_picker/ios" :path: ".symlinks/plugins/file_picker/ios"
file_saver:
:path: ".symlinks/plugins/file_saver/ios"
firebase_analytics: firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios" :path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core: firebase_core:
@ -462,6 +467,7 @@ SPEC CHECKSUMS:
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655 file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
firebase_analytics: fbc57838bdb94eef1e0ff504f127d974ff2981ad firebase_analytics: fbc57838bdb94eef1e0ff504f127d974ff2981ad
firebase_core: 2bedc3136ec7c7b8561c6123ed0239387b53f2af firebase_core: 2bedc3136ec7c7b8561c6123ed0239387b53f2af

View File

@ -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()}),

View File

@ -43,14 +43,17 @@ class PostEditorController extends GetxController {
RxBool isRestoreFromLocal = false.obs; RxBool isRestoreFromLocal = false.obs;
Rx<DateTime?> lastSaveTime = Rx(null); Rx<DateTime?> lastSaveTime = Rx(null);
Timer? _saveTimer; Future? _saveFuture;
PostEditorController() { PostEditorController() {
SharedPreferences.getInstance().then((inst) { SharedPreferences.getInstance().then((inst) {
_prefs = inst; _prefs = inst;
_saveTimer = Timer.periodic( });
const Duration(seconds: 3), contentController.addListener(() {
(Timer t) { contentLength.value = contentController.text.length;
_saveFuture ??= Future.delayed(
const Duration(seconds: 1),
() {
if (isNotEmpty) { if (isNotEmpty) {
localSave(); localSave();
lastSaveTime.value = DateTime.now(); lastSaveTime.value = DateTime.now();
@ -59,12 +62,10 @@ class PostEditorController extends GetxController {
localClear(); localClear();
lastSaveTime.value = null; lastSaveTime.value = null;
} }
_saveFuture = null;
}, },
); );
}); });
contentController.addListener(() {
contentLength.value = contentController.text.length;
});
} }
Future<void> editOverview(BuildContext context) { Future<void> editOverview(BuildContext context) {
@ -355,8 +356,6 @@ class PostEditorController extends GetxController {
@override @override
void dispose() { void dispose() {
_saveTimer?.cancel();
titleController.dispose(); titleController.dispose();
descriptionController.dispose(); descriptionController.dispose();
contentController.dispose(); contentController.dispose();

View File

@ -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();
} }
} }

View File

@ -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,
}); });

View File

@ -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,
}; };

View File

@ -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();

View 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;
}
}

View File

@ -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;
}
} }

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.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:google_fonts/google_fonts.dart';
import 'package:marquee/marquee.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/audit_log.dart'; import 'package:solian/models/audit_log.dart';
@ -29,7 +30,7 @@ class _AuditLogScreenState extends State<AuditLogScreen> {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
final client = await auth.configureClient('id'); final client = await auth.configureClient('id');
final resp = final resp =
await client.get('/users/me/events?take=10&offset=${_events.length}'); await client.get('/users/me/events?take=15&offset=${_events.length}');
if (resp.statusCode != 200) { if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp)); context.showErrorDialog(RequestException(resp));
} }
@ -45,6 +46,22 @@ class _AuditLogScreenState extends State<AuditLogScreen> {
}); });
} }
bool _showIp = false;
String _censorIpAddress(String ip) {
List<String> parts = ip.split('.');
if (parts.length == 4) {
String censoredPart1 = '*' * parts[1].length;
String censoredPart2 = '*' * parts[2].length;
String censoredPart3 = '*' * parts[3].length;
return '${parts[0]}.$censoredPart1.$censoredPart2.$censoredPart3';
} else {
return '***.***.***.***';
}
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -53,42 +70,85 @@ class _AuditLogScreenState extends State<AuditLogScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InfiniteList( return Column(
itemCount: _events.length, children: [
isLoading: _isBusy, CheckboxListTile(
onFetchData: () { value: _showIp,
_getEvents(); title: Text('showIp'.tr),
}, contentPadding: const EdgeInsets.symmetric(horizontal: 24),
itemBuilder: (context, idx) { secondary: const Icon(Icons.alternate_email),
final element = _events[idx]; tileColor:
return TimelineTile( Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.5),
isFirst: idx == 0, onChanged: (val) {
isLast: _events.length - 1 == idx, setState(() => _showIp = val ?? false);
alignment: TimelineAlign.start, },
endChild: Container( ),
child: Card( Expanded(
child: Column( child: RefreshIndicator(
crossAxisAlignment: CrossAxisAlignment.start, onRefresh: () {
children: [ _events.clear();
Text( return _getEvents();
element.type, },
style: GoogleFonts.robotoMono(fontSize: 15), child: InfiniteList(
padding: const EdgeInsets.symmetric(vertical: 12),
itemCount: _events.length,
isLoading: _isBusy,
onFetchData: () {
_getEvents();
},
itemBuilder: (context, idx) {
final element = _events[idx];
return TimelineTile(
isFirst: idx == 0,
isLast: _events.length - 1 == idx,
alignment: TimelineAlign.start,
indicatorStyle: IndicatorStyle(width: 15),
endChild: Container(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
element.type,
style: GoogleFonts.robotoMono(fontSize: 15),
),
Text(
_showIp
? element.ipAddress
: _censorIpAddress(element.ipAddress),
style: GoogleFonts.sourceCodePro(
fontWeight: FontWeight.bold,
),
),
SizedBox(
height: 20,
width: double.maxFinite,
child: Marquee(
text: element.userAgent,
velocity: 25,
startAfter: Duration(milliseconds: 500),
pauseAfterRound: Duration(milliseconds: 3000),
),
),
Row(
children: [
RelativeDate(element.createdAt),
const Gap(6),
Text('·'),
const Gap(6),
RelativeDate(element.createdAt, isFull: true),
],
),
],
).paddingSymmetric(horizontal: 12, vertical: 8),
).paddingOnly(left: 16),
), ),
Row( ).paddingSymmetric(horizontal: 18);
children: [ },
RelativeDate(element.createdAt), ),
const Gap(6),
Text('·'),
const Gap(6),
RelativeDate(element.createdAt, isFull: true),
],
),
],
).paddingSymmetric(horizontal: 12, vertical: 8),
).paddingOnly(left: 16),
), ),
).paddingSymmetric(horizontal: 18); ),
}, ],
); );
} }
} }

View File

@ -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,
);
},
);
}
}

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart'; import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/loading_indicator.dart';
class NotificationPreferencesScreen extends StatefulWidget { class NotificationPreferencesScreen extends StatefulWidget {
const NotificationPreferencesScreen({super.key}); const NotificationPreferencesScreen({super.key});
@ -76,7 +76,7 @@ class _NotificationPreferencesScreenState
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart'; import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:solian/exceptions/request.dart'; import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/loading_indicator.dart';
class AuthPreferencesScreen extends StatefulWidget { class AuthPreferencesScreen extends StatefulWidget {
const AuthPreferencesScreen({super.key}); const AuthPreferencesScreen({super.key});
@ -67,7 +67,7 @@ class _AuthPreferencesScreenState extends State<AuthPreferencesScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer, tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart'; import 'package:image_cropper/image_cropper.dart';
@ -12,6 +11,7 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart'; import 'package:solian/providers/content/attachment.dart';
import 'package:solian/services.dart'; import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/loading_indicator.dart';
class PersonalizeScreen extends StatefulWidget { class PersonalizeScreen extends StatefulWidget {
const PersonalizeScreen({super.key}); const PersonalizeScreen({super.key});
@ -188,7 +188,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
return ListView( return ListView(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
const Gap(24), const Gap(24),
Stack( Stack(
children: [ children: [

View File

@ -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();

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -9,6 +8,7 @@ import 'package:solian/providers/content/channel.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.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/root_container.dart'; import 'package:solian/widgets/root_container.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -132,7 +132,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
top: false, top: false,
child: Column( child: Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
if (widget.edit != null) if (widget.edit != null)
MaterialBanner( MaterialBanner(
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),

View File

@ -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(),
),
], ],
), ),
); );
@ -288,7 +291,7 @@ class _ChatListState extends State<ChatList> {
return Column( return Column(
children: [ children: [
const ChatCallCurrentIndicator(), const ChatCallCurrentIndicator(),
if (_isBusy) const LoadingIndicator(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: TabBarView( child: TabBarView(
children: [ children: [

View File

@ -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(

View File

@ -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();
}
} }

View File

@ -6,6 +6,7 @@ import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/last_read.dart'; import 'package:solian/providers/last_read.dart';
import 'package:solian/theme.dart'; import 'package:solian/theme.dart';
import 'package:solian/widgets/loading_indicator.dart'; import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart'; import 'package:solian/widgets/posts/post_replies.dart';
@ -40,7 +41,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Get.find<LastReadProvider>().feedLastReadAt = _item?.id; Get.find<LastReadProvider>().feedLastReadAt = _item?.id;
setState(() => _isBusy = false); if (mounted) setState(() => _isBusy = false);
} }
@override @override
@ -62,12 +63,12 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
return CustomScrollView( return CustomScrollView(
slivers: [ slivers: [
if (_isBusy) SliverToBoxAdapter(
SliverToBoxAdapter( child: LoadingIndicator(isActive: _isBusy),
child: LoadingIndicator(), ),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: PostItem( child: PostItem(
key: ValueKey(_item),
item: _item!, item: _item!,
isClickable: false, isClickable: false,
isOverrideEmbedClickable: true, isOverrideEmbedClickable: true,
@ -80,6 +81,24 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
vertical: 8, vertical: 8,
) )
: EdgeInsets.zero, : EdgeInsets.zero,
onTapMore: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(
item: _item!,
noReact: true,
),
).then((value) {
if (value is Future) {
value.then((_) {
_getDetail();
});
} else if (value != null) {
_getDetail();
}
});
},
), ),
), ),
SliverToBoxAdapter( SliverToBoxAdapter(

View File

@ -16,6 +16,7 @@ import 'package:solian/router.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/markdown_text_content.dart'; import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/posts/post_item.dart'; import 'package:solian/widgets/posts/post_item.dart';
import 'package:badges/badges.dart' as badges; import 'package:badges/badges.dart' as badges;
@ -274,7 +275,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
), ),
], ],
), ),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: DefaultTabController( child: DefaultTabController(
length: 2, length: 2,

View File

@ -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(
@ -135,7 +148,43 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
}, },
), ),
), ),
if (_isBusy) const LoadingIndicator(), 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()),

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
@ -15,6 +14,7 @@ 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/auto_cache_image.dart'; import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/current_state_action.dart'; import 'package:solian/widgets/current_state_action.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/root_container.dart'; import 'package:solian/widgets/root_container.dart';
import 'package:solian/widgets/sized_container.dart'; import 'package:solian/widgets/sized_container.dart';
@ -93,7 +93,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
return Column( return Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: CenteredContainer( child: CenteredContainer(
child: RefreshIndicator( child: RefreshIndicator(

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_cropper/image_cropper.dart'; import 'package:image_cropper/image_cropper.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
@ -13,6 +12,7 @@ import 'package:solian/router.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/root_container.dart'; import 'package:solian/widgets/root_container.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
@ -208,7 +208,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
top: false, top: false,
child: Column( child: Column(
children: [ children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
if (widget.edit != null) if (widget.edit != null)
MaterialBanner( MaterialBanner(
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),

View File

@ -21,6 +21,7 @@ import 'package:solian/providers/content/attachment.dart';
import 'package:solian/widgets/attachments/attachment_attr_editor.dart'; import 'package:solian/widgets/attachments/attachment_attr_editor.dart';
import 'package:solian/widgets/attachments/attachment_editor_thumbnail.dart'; import 'package:solian/widgets/attachments/attachment_editor_thumbnail.dart';
import 'package:solian/widgets/attachments/attachment_fullscreen.dart'; import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
import 'package:solian/widgets/loading_indicator.dart';
class AttachmentEditorPopup extends StatefulWidget { class AttachmentEditorPopup extends StatefulWidget {
final String pool; final String pool;
@ -660,7 +661,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
), ),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: CustomScrollView( child: CustomScrollView(
slivers: [ slivers: [

View File

@ -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,
), ),
], ],

View File

@ -49,6 +49,7 @@ class _AttachmentListState extends State<AttachmentList> {
bool _isLoading = true; bool _isLoading = true;
bool _showMature = false; bool _showMature = false;
// ignore: unused_field
double _aspectRatio = 1; double _aspectRatio = 1;
List<Attachment?> _attachments = List.empty(); List<Attachment?> _attachments = List.empty();

View File

@ -38,11 +38,13 @@ class _ChannelListWidgetState extends State<ChannelListWidget> {
Future<void> _loadLastMessages() async { Future<void> _loadLastMessages() async {
final messages = await _eventController.src.getLastInAllChannels(); final messages = await _eventController.src.getLastInAllChannels();
setState(() { if (mounted) {
_lastMessages = messages setState(() {
.map((k, v) => MapEntry(k, v.firstOrNull)) _lastMessages = messages
.cast<int, LocalMessageEventTableData>(); .map((k, v) => MapEntry(k, v.firstOrNull))
}); .cast<int, LocalMessageEventTableData>();
});
}
} }
@override @override

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -8,6 +7,7 @@ import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/account/relative_select.dart'; import 'package:solian/widgets/account/relative_select.dart';
import 'package:solian/widgets/loading_indicator.dart';
class ChannelMemberListPopup extends StatefulWidget { class ChannelMemberListPopup extends StatefulWidget {
final Channel channel; final Channel channel;
@ -131,7 +131,7 @@ class _ChannelMemberListPopupState extends State<ChannelMemberListPopup> {
'channelMembers'.tr, 'channelMembers'.tr,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 20),

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
@ -7,6 +6,7 @@ import 'package:solian/models/event.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/chat/chat_event_deletion.dart'; import 'package:solian/widgets/chat/chat_event_deletion.dart';
import 'package:solian/widgets/loading_indicator.dart';
class ChatEventAction extends StatefulWidget { class ChatEventAction extends StatefulWidget {
final Channel channel; final Channel channel;
@ -73,7 +73,7 @@ class _ChatEventActionState extends State<ChatEventAction> {
), ),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [

View File

@ -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);
},
),
);
}, },
); );
}, },

View File

@ -1,27 +1,88 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:gap/gap.dart';
class LoadingIndicator extends StatelessWidget { class LoadingIndicator extends StatefulWidget {
const LoadingIndicator({super.key}); final bool isActive;
final Color? backgroundColor;
const LoadingIndicator({
super.key,
this.isActive = true,
this.backgroundColor,
});
@override
State<LoadingIndicator> createState() => _LoadingIndicatorState();
}
class _LoadingIndicatorState extends State<LoadingIndicator>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
if (widget.isActive) {
_controller.forward();
} else {
_controller.reverse();
}
}
@override
void didUpdateWidget(covariant LoadingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive != oldWidget.isActive) {
if (widget.isActive) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return SizeTransition(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), sizeFactor: _animation,
color: Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5), axisAlignment: -1, // Align animation from the top
child: Row( child: Container(
mainAxisAlignment: MainAxisAlignment.center, padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
crossAxisAlignment: CrossAxisAlignment.center, color: widget.backgroundColor ??
children: [ Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5),
const SizedBox( child: Row(
height: 16, mainAxisAlignment: MainAxisAlignment.center,
width: 16, crossAxisAlignment: CrossAxisAlignment.center,
child: CircularProgressIndicator(strokeWidth: 2.5), children: [
), const SizedBox(
const Gap(8), height: 16,
Text('loading'.tr) width: 16,
], child: CircularProgressIndicator(strokeWidth: 2.5),
),
const Gap(8),
Text('loading'.tr),
],
),
), ),
); );
} }

View File

@ -1,20 +1,20 @@
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:file_saver/file_saver.dart';
import 'package:firebase_analytics/firebase_analytics.dart'; import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import 'package:screenshot/screenshot.dart'; import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.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/posts.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_editor.dart'; import 'package:solian/screens/posts/post_editor.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_share.dart'; import 'package:solian/widgets/posts/post_share.dart';
import 'package:solian/widgets/reports/abuse_report.dart'; import 'package:solian/widgets/reports/abuse_report.dart';
@ -29,20 +29,14 @@ class PostAction extends StatefulWidget {
} }
class _PostActionState extends State<PostAction> { class _PostActionState extends State<PostAction> {
bool _isBusy = true; bool _isBusy = false;
bool _canModifyContent = false; bool _canModifyContent = false;
void _checkAbleToModifyContent() async { void _checkAbleToModifyContent() async {
final AuthProvider auth = Get.find(); final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return; if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true); _canModifyContent = auth.userProfile.value!['id'] == widget.item.author.id;
setState(() {
_canModifyContent =
auth.userProfile.value!['id'] == widget.item.author.id;
_isBusy = false;
});
} }
Future<void> _doShare({bool noUri = false}) async { Future<void> _doShare({bool noUri = false}) async {
@ -73,7 +67,8 @@ class _PostActionState extends State<PostAction> {
'link': 'https://solsynth.dev/posts/$id', 'link': 'https://solsynth.dev/posts/$id',
}), }),
subject: 'postShareSubject'.trParams({ subject: 'postShareSubject'.trParams({
'username': widget.item.author.nick, 'username': '@${widget.item.author.name}',
'title': widget.item.body['title'] ?? '#${widget.item.id}',
}), }),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size, sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
); );
@ -92,41 +87,72 @@ class _PostActionState extends State<PostAction> {
final List<String> attachments = widget.item.body['attachments'] is List final List<String> attachments = widget.item.body['attachments'] is List
? List.from(widget.item.body['attachments']?.whereType<String>()) ? List.from(widget.item.body['attachments']?.whereType<String>())
: List.empty(); : List.empty();
final hasAttachment = attachments.isNotEmpty; final hasMultipleAttachment = attachments.length > 1;
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: hasAttachment ? 480 : 640, maxWidth: width,
minHeight: 640, minHeight: 640,
maxHeight: double.infinity, maxHeight: double.infinity,
), ),
); );
final directory = await getApplicationDocumentsDirectory();
final imageFile = await File(
'${directory.path}/temporary_share_image.png',
).create();
await imageFile.writeAsBytes(image);
final box = context.findRenderObject() as RenderBox?; final filename = 'share_post#${widget.item.id}';
final file = XFile(imageFile.path); if (PlatformInfo.isAndroid || PlatformInfo.isIOS) {
await Share.shareXFiles( final box = context.findRenderObject() as RenderBox?;
[file],
subject: 'postShareSubject'.trParams({
'username': widget.item.author.nick,
}),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
);
await imageFile.delete(); final file = XFile.fromData(
image,
mimeType: 'image/png',
name: filename,
);
await Share.shareXFiles(
[file],
subject: 'postShareSubject'.trParams({
'username': '@${widget.item.author.name}',
'title': widget.item.body['title'] ?? '#${widget.item.id}',
}),
sharePositionOrigin: box!.localToGlobal(Offset.zero) & box.size,
);
} else {
final filepath = await FileSaver.instance.saveFile(
name: filename,
ext: 'png',
mimeType: MimeType.png,
bytes: image,
);
context.showSnackbar('fileSavedAt'.trParams({'path': filepath}));
}
setState(() => _isBusy = false);
}
Future<Post> _getFullPost() async {
final PostProvider posts = Get.find();
try {
final resp = await posts.getPost(widget.item.id.toString());
return Post.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context));
}
return widget.item;
} }
@override @override
@ -172,7 +198,13 @@ class _PostActionState extends State<PostAction> {
), ),
], ],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(
isActive: _isBusy,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHigh
.withOpacity(0.5),
),
Expanded( Expanded(
child: ListView( child: ListView(
children: [ children: [
@ -192,15 +224,16 @@ class _PostActionState extends State<PostAction> {
Navigator.pop(context); Navigator.pop(context);
}, },
), ),
if (PlatformInfo.isIOS || PlatformInfo.isAndroid) IconButton(
IconButton( icon: const Icon(Icons.image),
icon: const Icon(Icons.image), tooltip: 'shareImage'.tr,
tooltip: 'shareImage'.tr, onPressed: _isBusy
onPressed: () async { ? null
await _shareImage(); : () async {
Navigator.pop(context); await _shareImage();
}, Navigator.pop(context);
), },
),
], ],
), ),
onTap: () async { onTap: () async {
@ -279,15 +312,23 @@ class _PostActionState extends State<PostAction> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.edit), leading: const Icon(Icons.edit),
title: Text('edit'.tr), title: Text('edit'.tr),
onTap: () async { onTap: _isBusy
Navigator.pop( ? null
context, : () async {
AppRouter.instance.pushNamed( setState(() => _isBusy = true);
'postEditor', var item = widget.item;
extra: PostPublishArguments(edit: widget.item), if (item.body?['content_truncated'] == true) {
), item = await _getFullPost();
); }
}, Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
extra: PostPublishArguments(edit: item),
),
);
if (mounted) setState(() => _isBusy = false);
},
), ),
if (_canModifyContent) if (_canModifyContent)
ListTile( ListTile(

View File

@ -38,6 +38,7 @@ class PostItem extends StatefulWidget {
final EdgeInsets? padding; final EdgeInsets? padding;
final Function? onComment; final Function? onComment;
final Function? onTapMore;
const PostItem({ const PostItem({
super.key, super.key,
@ -55,6 +56,7 @@ class PostItem extends StatefulWidget {
this.attachmentParent, this.attachmentParent,
this.padding, this.padding,
this.onComment, this.onComment,
this.onTapMore,
}); });
@override @override
@ -99,6 +101,7 @@ class _PostItemState extends State<PostItem> {
_PostHeaderWidget( _PostHeaderWidget(
isCompact: widget.isCompact, isCompact: widget.isCompact,
isFullDate: widget.isFullDate, isFullDate: widget.isFullDate,
onTapMore: widget.onTapMore,
item: item, item: item,
).paddingSymmetric(horizontal: 12), ).paddingSymmetric(horizontal: 12),
_PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12), _PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12),
@ -161,6 +164,7 @@ class _PostItemState extends State<PostItem> {
_PostHeaderWidget( _PostHeaderWidget(
isCompact: widget.isCompact, isCompact: widget.isCompact,
isFullDate: widget.isFullDate, isFullDate: widget.isFullDate,
onTapMore: widget.onTapMore,
item: item, item: item,
), ),
_PostHeaderDividerWidget(item: item), _PostHeaderDividerWidget(item: item),
@ -588,10 +592,13 @@ class _PostHeaderWidget extends StatelessWidget {
final bool isFullDate; final bool isFullDate;
final Post item; final Post item;
final Function? onTapMore;
const _PostHeaderWidget({ const _PostHeaderWidget({
required this.isCompact, required this.isCompact,
required this.isFullDate, required this.isFullDate,
required this.item, required this.item,
required this.onTapMore,
}); });
@override @override
@ -649,10 +656,17 @@ class _PostHeaderWidget extends StatelessWidget {
], ],
), ),
), ),
if (item.type == 'article') if (onTapMore != null)
Badge( IconButton(
label: Text('article'.tr), color: Theme.of(context).colorScheme.primary,
).paddingOnly(top: 3), icon: const Icon(Icons.more_vert),
padding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
onPressed: () => onTapMore!(),
),
], ],
), ),
const Gap(8), const Gap(8),

View File

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
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:infinite_scroll_pagination/infinite_scroll_pagination.dart';
@ -73,50 +74,60 @@ class PostListEntryWidget extends StatelessWidget {
required this.onUpdate, required this.onUpdate,
}); });
void _openActions(BuildContext context) {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(item: item),
).then((value) {
if (value is Future) {
value.then((_) {
onUpdate();
});
} else if (value != null) {
onUpdate();
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return TapRegion(
child: PostItem( child: GestureDetector(
key: Key('p${item.id}'), onLongPress: () => _openActions(context),
item: item, child: PostItem(
isShowEmbed: isShowEmbed, key: Key('p${item.id}'),
isClickable: isNestedClickable, item: item,
showFeaturedReply: showFeaturedReply, isShowEmbed: isShowEmbed,
padding: padding, isClickable: isNestedClickable,
onComment: () { showFeaturedReply: showFeaturedReply,
AppRouter.instance padding: padding,
.pushNamed( onTapMore: () => _openActions(context),
'postEditor', onComment: () {
extra: PostPublishArguments(reply: item), AppRouter.instance
) .pushNamed(
.then((value) { 'postEditor',
if (value is Future) { extra: PostPublishArguments(reply: item),
value.then((_) { )
.then((value) {
if (value is Future) {
value.then((_) {
onUpdate();
});
} else if (value != null) {
onUpdate(); onUpdate();
}); }
} else if (value != null) {
onUpdate();
}
});
},
).paddingSymmetric(vertical: 8),
onLongPress: () {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(item: item),
).then((value) {
if (value is Future) {
value.then((_) {
onUpdate();
}); });
} else if (value != null) { },
onUpdate(); ).paddingSymmetric(vertical: 8),
} ),
}); onTapInside: (event) {
if (event.buttons == kSecondaryMouseButton) {
_openActions(context);
}
}, },
); );
} }

View File

@ -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(),
),
);
}
}

View File

@ -1,5 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:solian/exts.dart'; import 'package:solian/exts.dart';
import 'package:solian/models/realm.dart'; import 'package:solian/models/realm.dart';
@ -8,6 +7,7 @@ import 'package:solian/services.dart';
import 'package:solian/widgets/account/account_avatar.dart'; import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart'; import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/account/relative_select.dart'; import 'package:solian/widgets/account/relative_select.dart';
import 'package:solian/widgets/loading_indicator.dart';
class RealmMemberListPopup extends StatefulWidget { class RealmMemberListPopup extends StatefulWidget {
final Realm realm; final Realm realm;
@ -128,7 +128,7 @@ class _RealmMemberListPopupState extends State<RealmMemberListPopup> {
'realmMembers'.tr, 'realmMembers'.tr,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(), LoadingIndicator(isActive: _isBusy),
ListTile( ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
contentPadding: const EdgeInsets.symmetric(horizontal: 20), contentPadding: const EdgeInsets.symmetric(horizontal: 20),

View File

@ -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,
); );
} }
} }

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h> #include <desktop_drop/desktop_drop_plugin.h>
#include <file_saver/file_saver_plugin.h>
#include <file_selector_linux/file_selector_plugin.h> #include <file_selector_linux/file_selector_plugin.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h> #include <flutter_acrylic/flutter_acrylic_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h> #include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
@ -22,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_drop_registrar = g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar); desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
g_autoptr(FlPluginRegistrar) file_saver_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSaverPlugin");
file_saver_plugin_register_with_registrar(file_saver_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar); file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop desktop_drop
file_saver
file_selector_linux file_selector_linux
flutter_acrylic flutter_acrylic
flutter_secure_storage_linux flutter_secure_storage_linux

View File

@ -8,6 +8,7 @@ import Foundation
import connectivity_plus import connectivity_plus
import desktop_drop import desktop_drop
import device_info_plus import device_info_plus
import file_saver
import file_selector_macos import file_selector_macos
import firebase_analytics import firebase_analytics
import firebase_core import firebase_core
@ -39,6 +40,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))

View File

@ -6,6 +6,8 @@ PODS:
- FlutterMacOS - FlutterMacOS
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- FlutterMacOS - FlutterMacOS
- file_saver (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1): - file_selector_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- Firebase/Analytics (11.2.0): - Firebase/Analytics (11.2.0):
@ -230,6 +232,7 @@ DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`) - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`)
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`) - desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_saver (from `Flutter/ephemeral/.symlinks/plugins/file_saver/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`) - firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`)
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
@ -288,6 +291,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos :path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
device_info_plus: device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_saver:
:path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos
file_selector_macos: file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
firebase_analytics: firebase_analytics:
@ -349,6 +354,7 @@ SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898 desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: f1aae8670672f75c4c8850ecbe0b2ddef62b0a22 device_info_plus: f1aae8670672f75c4c8850ecbe0b2ddef62b0a22
file_saver: 44e6fbf666677faf097302460e214e977fdd977b
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
firebase_analytics: 30ff72f6d4847ff0b479d8edd92fc8582e719072 firebase_analytics: 30ff72f6d4847ff0b479d8edd92fc8582e719072

View File

@ -14,6 +14,8 @@
<true/> <true/>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>

View File

@ -62,5 +62,9 @@
<string>Allow you record audio for your message or post</string> <string>Allow you record audio for your message or post</string>
<key>NSPhotoLibraryUsageDescription</key> <key>NSPhotoLibraryUsageDescription</key>
<string>Allow you add photo to your message or post</string> <string>Allow you add photo to your message or post</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict> </dict>
</plist> </plist>

View File

@ -12,6 +12,8 @@
<true/> <true/>
<key>com.apple.security.device.camera</key> <key>com.apple.security.device.camera</key>
<true/> <true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key> <key>com.apple.security.files.user-selected.read-only</key>
<true/> <true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>

View File

@ -74,10 +74,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: args name: args
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.0" version: "2.6.0"
async: async:
dependency: "direct main" dependency: "direct main"
description: description:
@ -414,6 +414,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.5" version: "2.0.5"
fading_edge_scrollview:
dependency: transitive
description:
name: fading_edge_scrollview
sha256: "1f84fe3ea8e251d00d5735e27502a6a250e4aa3d3b330d3fdcb475af741464ef"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -454,6 +462,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.2" version: "8.1.2"
file_saver:
dependency: "direct main"
description:
name: file_saver
sha256: "017a127de686af2d2fbbd64afea97052d95f2a0f87d19d25b87e097407bf9c1e"
url: "https://pub.dev"
source: hosted
version: "0.2.14"
file_selector_linux: file_selector_linux:
dependency: transitive dependency: transitive
description: description:
@ -1269,6 +1285,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
marquee:
dependency: "direct main"
description:
name: marquee
sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862
url: "https://pub.dev"
source: hosted
version: "2.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@ -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.7+11 version: 1.4.0+15
environment: environment:
sdk: ">=3.3.4 <4.0.0" sdk: ">=3.3.4 <4.0.0"
@ -88,6 +88,8 @@ dependencies:
screenshot: ^3.0.0 screenshot: ^3.0.0
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0
flutter_resizable_container: ^3.0.0 flutter_resizable_container: ^3.0.0
file_saver: ^0.2.14
marquee: ^2.3.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@ -111,15 +111,14 @@
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"> <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
</head> </head>
<body> <body oncontextmenu="return false;">
<picture id="splash"> <picture id="splash">
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)"> <source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)"> <source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt=""> <img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
</picture> </picture>
<script src="flutter_bootstrap.js" async=""></script>
<script src="flutter_bootstrap.js" async=""></script>
</body></html> </body></html>

View File

@ -8,6 +8,7 @@
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <desktop_drop/desktop_drop_plugin.h> #include <desktop_drop/desktop_drop_plugin.h>
#include <file_saver/file_saver_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h> #include <flutter_acrylic/flutter_acrylic_plugin.h>
@ -31,6 +32,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DesktopDropPluginRegisterWithRegistrar( DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin")); registry->GetRegistrarForPlugin("DesktopDropPlugin"));
FileSaverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSaverPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows")); registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar( FirebaseCorePluginCApiRegisterWithRegistrar(

View File

@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus connectivity_plus
desktop_drop desktop_drop
file_saver
file_selector_windows file_selector_windows
firebase_core firebase_core
flutter_acrylic flutter_acrylic