Compare commits

...

46 Commits

Author SHA1 Message Date
107379d9fe 🐛 Bug fixes 2024-10-20 16:15:24 +08:00
0d807b8708 👔 Article wont show expand attachment list 2024-10-19 16:55:14 +08:00
ac1b3fe15c 🐛 Optimize content render 2024-10-19 00:32:16 +08:00
5853de32a2 🐛 Fix localization issue 2024-10-17 23:50:47 +08:00
eac1be365e Birthday celebration screen 2024-10-17 23:49:20 +08:00
3fb1d7a6d4 🚀 Launch 1.4.0+16 2024-10-17 23:01:42 +08:00
0480b5244f 🐛 Fix draft box 2024-10-17 22:44:00 +08:00
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
f4621dd2b4 🚀 Launch 1.3.7+11 2024-10-14 00:18:46 +08:00
6e442c144e Chat shell resizable 2024-10-14 00:13:01 +08:00
8bbd964026 🐛 Fix share as image on iPad 2024-10-14 00:10:13 +08:00
0b8a5a3303 💄 Optimize share as image 2024-10-14 00:03:45 +08:00
65c6083640 🚀 Launch 1.3.7+10 2024-10-13 23:19:03 +08:00
ad7a34ec18 Share via image 2024-10-13 23:12:23 +08:00
6c32d76f78 Audit logs 2024-10-13 22:17:23 +08:00
2aa699547c 🐛 Bug fixing on searching 2024-10-13 21:50:47 +08:00
1f4aa8916d Search posts 2024-10-13 21:48:53 +08:00
e2c2e41f89 Improve post detail page first time loading 2024-10-13 21:31:15 +08:00
0f2b854e45 👔 Update level requirements 2024-10-13 20:54:26 +08:00
c21ca5573c Show post visibility 2024-10-13 20:45:00 +08:00
1809f2557d 👽 Support server-side truncate content 2024-10-13 20:36:10 +08:00
1fc84099fe ♻️ Better push token uuid 2024-10-13 20:03:36 +08:00
f8755f5220 🐛 Bug fixes 2024-10-13 19:56:37 +08:00
4041d6dc4e Optimize chat list and attachment loading 2024-10-13 16:26:46 +08:00
cc1071d86e 🚀 Launch 1.3.7+9 2024-10-13 14:58:47 +08:00
e334b862df Auth preferences 2024-10-13 14:13:16 +08:00
32c33a963a 💄 Optimized post list 2024-10-13 01:31:59 +08:00
77 changed files with 2952 additions and 1355 deletions

5
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"cSpell.words": [
"annvisery"
]
}

View File

@ -54,6 +54,7 @@
"about": "About",
"edit": "Edit",
"delete": "Delete",
"insert": "Insert",
"settings": "Settings",
"settingsNotificationBgService": "Background notification service",
"settingsNotificationBgServiceDesc": "A notification service is always installed on the device, so that some devices that do not support push notifications can receive notifications in the background. When this feature is enabled, push notifications will not be registered with the server, and you will always appear to be online in the eyes of others (except for invisible). You may need to turn off power and traffic optimization in the settings.",
@ -140,7 +141,7 @@
"clear": "Clear",
"pinPost": "Pin this post",
"unpinPost": "Unpin this post",
"postRestoreFromLocal": "Restore from local",
"postRestoreFromLocal": "Restored",
"postAutoSaveAt": "Auto saved at @date",
"postCategoriesAndTags": "Categories n' Tags",
"postPublishDate": "Publish Date",
@ -366,8 +367,9 @@
"bsPreparingData": "Preparing User Data",
"bsRegisteringPushNotify": "Enabling Push Notifications",
"bsDismissibleErrorHint": "Click anywhere to ignore this error",
"bsContinuable": "Click anywhere to continue",
"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",
"themeColorRed": "Modern Red",
"themeColorBlue": "Classic Blue",
@ -477,5 +479,20 @@
"agedTheme": "Old school style theme",
"agedThemeDesc": "Downgrade the global theme to Material Design 2. Unexpected issues may occur. For experimental use only.",
"appBackgroundImage": "Global background image",
"appBackgroundImageDesc": "The global background image will be displayed on all pages"
"appBackgroundImageDesc": "The global background image will be displayed on all pages",
"authPreferences": "Auth preferences",
"authPreferencesDesc": "Set the security behavior of your account",
"authMaximumAuthSteps": "Maximum authentication steps",
"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",
"shareImage": "Share as image",
"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",
"happyBirthday": "Happy birthday @name!",
"happyBirthdayDesc": "Today is your @count birthday"
}

View File

@ -14,6 +14,7 @@
"about": "关于",
"edit": "编辑",
"delete": "删除",
"insert": "插入",
"settings": "设置",
"settingsNotificationBgService": "常驻通知服务",
"settingsNotificationBgServiceDesc": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。",
@ -362,8 +363,9 @@
"bsPreparingData": "正在准备用户资料",
"bsRegisteringPushNotify": "正在启用推送通知",
"bsDismissibleErrorHint": "点击任意地方忽略此错误",
"bsContinuable": "点击任意处继续",
"postShareContent": "@content\n\n@username 在 Solar Network\n原帖地址@link",
"postShareSubject": "@username 在 Solar Network 上发布了一篇帖子",
"postShareSubject": "@username 在 Solar Network 发表的 @title",
"themeColor": "全局主题色",
"themeColorRed": "现代红",
"themeColorBlue": "经典蓝",
@ -473,5 +475,20 @@
"agedTheme": "过时主题",
"agedThemeDesc": "将全局主题降级为 Material Design 2可能发生意料之外的问题仅供实验使用",
"appBackgroundImage": "全局背景图片",
"appBackgroundImageDesc": "全局背景图片将会在所有页面中展示"
"appBackgroundImageDesc": "全局背景图片将会在所有页面中展示",
"authPreferences": "安全偏好设置",
"authPreferencesDesc": "调整账号的安全行为模式",
"authMaximumAuthSteps": "最大认证步数",
"authMaximumAuthStepsDesc": "登陆时最多的验证步数,值越高则越安全,反之则会相对方便;默认设置为 2",
"auditLog": "活动日志",
"shareImage": "分享图片",
"shareImageFooter": "上 Solar Network 看更多有趣帖子",
"fileSavedAt": "文件保存于 @path",
"showIp": "显示 IP 地址",
"shotOn": "由 @device 拍摄",
"unread": "未读",
"searchTook": "耗时 @time",
"searchResult": "匹配到 @count 条结果",
"happyBirthday": "生日快乐,@name!",
"happyBirthdayDesc": "今天是你的第 @count 个生日"
}

View File

@ -38,6 +38,8 @@ PODS:
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- file_saver (0.0.1):
- Flutter
- Firebase/Analytics (11.2.0):
- Firebase/Core
- Firebase/Core (11.2.0):
@ -166,6 +168,9 @@ PODS:
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_webrtc (0.11.3):
- Flutter
- WebRTC-SDK (= 125.6422.04)
@ -259,6 +264,7 @@ PODS:
- PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1):
- Flutter
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.19.7):
@ -304,6 +310,7 @@ DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/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_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_crashlytics (from `.symlinks/plugins/firebase_crashlytics/ios`)
@ -316,6 +323,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- gal (from `.symlinks/plugins/gal/darwin`)
- image_cropper (from `.symlinks/plugins/image_cropper/ios`)
@ -364,6 +372,7 @@ SPEC REPOS:
- nanopb
- PromisesObjC
- PromisesSwift
- SAMKeychain
- SDWebImage
- sqlite3
- SwiftyGif
@ -377,6 +386,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
file_saver:
:path: ".symlinks/plugins/file_saver/ios"
firebase_analytics:
:path: ".symlinks/plugins/firebase_analytics/ios"
firebase_core:
@ -401,6 +412,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
gal:
@ -449,11 +462,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
firebase_analytics: fbc57838bdb94eef1e0ff504f127d974ff2981ad
firebase_core: 2bedc3136ec7c7b8561c6123ed0239387b53f2af
@ -480,6 +494,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
GoogleAppMeasurement: 76d4f8b36b03bd8381fa9a7fe2cc7f99c0a2e93a
@ -493,7 +508,7 @@ SPEC CHECKSUMS:
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
pasteboard: 982969ebaa7c78af3e6cc7761e8f5e77565d9ce0
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
@ -501,9 +516,10 @@ SPEC CHECKSUMS:
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: a5db8abc38526ee326988b808be621e5fd568990
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: a553b1fd6fe66f53bbb0fe5b4f5bab93f08d7a13
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb

View File

@ -59,6 +59,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
showGraphicsOverview = "Yes"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:confetti/confetti.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
@ -10,9 +11,11 @@ import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/account.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
@ -22,6 +25,11 @@ import 'package:solian/widgets/sized_container.dart';
import 'package:flutter_app_update/flutter_app_update.dart';
import 'package:version/version.dart';
enum BootstrapperSpecialState {
userBirthday,
appAnniversary,
}
class BootstrapperShell extends StatefulWidget {
final Widget child;
@ -42,6 +50,9 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
int _periodCursor = 0;
// Special state is some special event triggered after bootstrapping
BootstrapperSpecialState? _specialState;
final Completer _bootCompleter = Completer();
void _requestRating() async {
@ -198,11 +209,26 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find();
try {
await Future.wait([
if (auth.isAuthorized.isTrue)
Get.find<NotificationProvider>().fetchNotification(),
if (auth.isAuthorized.isTrue)
Get.find<RelationshipProvider>().refreshRelativeList(),
if (auth.isAuthorized.isTrue)
Get.find<RealmProvider>().refreshAvailableRealms(),
]);
if (auth.isAuthorized.isTrue && auth.userProfile.value != null) {
final account = Account.fromJson(auth.userProfile.value!);
if (account.profile?.birthday != null) {
final birthDate = account.profile!.birthday!.toLocal();
final isBirthday = birthDate.day == DateTime.now().day;
if (isBirthday) {
setState(
() => _specialState = BootstrapperSpecialState.userBirthday,
);
}
}
}
} catch (e) {
context.showErrorDialog(e);
}
@ -214,7 +240,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isTrue) {
try {
Get.find<WebSocketProvider>().registerPushNotifications();
Get.find<NotificationProvider>().registerPushNotifications();
} catch (err) {
context.showSnackbar(
'pushNotifyRegisterFailed'.trParams({'reason': err.toString()}),
@ -352,8 +378,142 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
}
},
);
} else if (_specialState != null) {
return GestureDetector(
child: RootContainer(
child: switch (_specialState) {
BootstrapperSpecialState.appAnniversary => const Placeholder(),
_ => _BirthdaySpecialScreen(),
},
),
onTap: () {
setState(() => _specialState = null);
},
);
}
return widget.child;
}
}
class _BirthdaySpecialScreen extends StatefulWidget {
const _BirthdaySpecialScreen();
@override
State<_BirthdaySpecialScreen> createState() => _BirthdaySpecialScreenState();
}
class _BirthdaySpecialScreenState extends State<_BirthdaySpecialScreen> {
late final ConfettiController _confettiController =
ConfettiController(duration: const Duration(seconds: 10));
@override
void initState() {
_confettiController.play();
super.initState();
}
@override
void dispose() {
_confettiController.dispose();
super.dispose();
}
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
String _toOrdinal(int num) {
if (num >= 11 && num <= 13) {
return '${num}th';
}
switch (num % 10) {
case 1:
return '${num}st';
case 2:
return '${num}nd';
case 3:
return '${num}rd';
default:
return '${num}th';
}
}
@override
Widget build(BuildContext context) {
final AuthProvider auth = Get.find();
final account = Account.fromJson(auth.userProfile.value!);
final birthDate = account.profile!.birthday!.toLocal();
final birthdayCount = DateTime.now().difference(birthDate).inDays ~/ 365;
return Stack(
children: <Widget>[
Align(
alignment: Alignment.center,
child: ConfettiWidget(
confettiController: _confettiController,
blastDirectionality: BlastDirectionality.explosive,
shouldLoop: true,
colors: const [
Colors.green,
Colors.blue,
Colors.pink,
Colors.orange,
Colors.purple
],
maxBlastForce: 30,
minBlastForce: 15,
emissionFrequency: 0.05,
numberOfParticles: 20,
gravity: 0.2,
),
),
Align(
child: CenteredContainer(
maxWidth: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'🎂',
style: TextStyle(fontSize: 60),
),
const Gap(8),
Text(
'happyBirthday'.trParams({
'name': account.profile?.firstName != null
? [
account.profile?.firstName,
account.profile?.lastName
].join(' ')
: '@${account.name}',
}),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
Text(
'happyBirthdayDesc'.trParams({
'count': _toOrdinal(birthdayCount),
}),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const Gap(8),
Text(
'bsContinuable'.tr,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: _unFocusColor,
),
).paddingOnly(bottom: 5),
],
),
),
),
],
);
}
}

View File

@ -43,14 +43,17 @@ class PostEditorController extends GetxController {
RxBool isRestoreFromLocal = false.obs;
Rx<DateTime?> lastSaveTime = Rx(null);
Timer? _saveTimer;
Future? _saveFuture;
PostEditorController() {
SharedPreferences.getInstance().then((inst) {
_prefs = inst;
_saveTimer = Timer.periodic(
const Duration(seconds: 3),
(Timer t) {
});
contentController.addListener(() {
contentLength.value = contentController.text.length;
_saveFuture ??= Future.delayed(
const Duration(seconds: 1),
() {
if (isNotEmpty) {
localSave();
lastSaveTime.value = DateTime.now();
@ -59,12 +62,10 @@ class PostEditorController extends GetxController {
localClear();
lastSaveTime.value = null;
}
_saveFuture = null;
},
);
});
contentController.addListener(() {
contentLength.value = contentController.text.length;
});
}
Future<void> editOverview(BuildContext context) {
@ -124,6 +125,21 @@ class PostEditorController extends GetxController {
onRemove: (String value) {
attachments.remove(value);
},
onInsert: (String str) {
final text = contentController.text;
final selection = contentController.selection;
final newText = text.replaceRange(
selection.start,
selection.end,
str,
);
contentController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: selection.baseOffset + str.length,
),
);
},
),
);
}
@ -355,8 +371,6 @@ class PostEditorController extends GetxController {
@override
void dispose() {
_saveTimer?.cancel();
titleController.dispose();
descriptionController.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/link_expander.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/subscription.dart';
import 'package:solian/providers/theme_switcher.dart';
@ -138,11 +139,12 @@ class SolianApp extends StatelessWidget {
Get.put(NavigationStateProvider());
Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => RelationshipProvider());
Get.lazyPut(() => PostProvider());
Get.lazyPut(() => StickerProvider());
Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => NotificationProvider());
Get.lazyPut(() => StatusProvider());
Get.lazyPut(() => ChannelProvider());
Get.lazyPut(() => RealmProvider());
@ -154,6 +156,6 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => LastReadProvider());
Get.lazyPut(() => SubscriptionProvider());
Get.find<WebSocketProvider>().requestPermissions();
Get.find<NotificationProvider>().requestPermissions();
}
}

38
lib/models/audit_log.dart Normal file
View File

@ -0,0 +1,38 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'audit_log.g.dart';
@JsonSerializable()
class AuditEvent {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String type;
String target;
String location;
String ipAddress;
String userAgent;
Account account;
int accountId;
AuditEvent({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.target,
required this.location,
required this.ipAddress,
required this.userAgent,
required this.account,
required this.accountId,
});
static AuditEvent fromJson(Map<String, dynamic> json) =>
_$AuditEventFromJson(json);
Map<String, dynamic> toJson() => _$AuditEventToJson(this);
}

View File

@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audit_log.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuditEvent _$AuditEventFromJson(Map<String, dynamic> json) => AuditEvent(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
type: json['type'] as String,
target: json['target'] as String,
location: json['location'] as String,
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuditEventToJson(AuditEvent instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'target': instance.target,
'location': instance.location,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

@ -1,18 +1,29 @@
import 'package:flutter/material.dart';
import 'package:json_annotation/json_annotation.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()
class Notification {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
DateTime? readAt;
String topic;
String title;
String? subtitle;
String body;
String? avatar;
String? picture;
Map<String, dynamic>? metadata;
int? senderId;
int accountId;
@ -21,11 +32,14 @@ class Notification {
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.readAt,
required this.topic,
required this.title,
required this.subtitle,
required this.body,
required this.avatar,
required this.picture,
required this.metadata,
required this.senderId,
required this.accountId,
});

View File

@ -13,11 +13,16 @@ Notification _$NotificationFromJson(Map<String, dynamic> json) => Notification(
deletedAt: json['deleted_at'] == null
? null
: 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,
subtitle: json['subtitle'] as String?,
body: json['body'] as String,
avatar: json['avatar'] as String?,
picture: json['picture'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
senderId: (json['sender_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(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'read_at': instance.readAt?.toIso8601String(),
'topic': instance.topic,
'title': instance.title,
'subtitle': instance.subtitle,
'body': instance.body,
'avatar': instance.avatar,
'picture': instance.picture,
'metadata': instance.metadata,
'sender_id': instance.senderId,
'account_id': instance.accountId,
};

View File

@ -24,6 +24,7 @@ class Post {
String? alias;
String? areaAlias;
dynamic body;
int visibility;
List<Tag>? tags;
List<Category>? categories;
List<Post>? replies;
@ -55,6 +56,7 @@ class Post {
required this.areaAlias,
required this.type,
required this.body,
required this.visibility,
required this.tags,
required this.categories,
required this.replies,

View File

@ -20,6 +20,7 @@ Post _$PostFromJson(Map<String, dynamic> json) => Post(
areaAlias: json['area_alias'] as String?,
type: json['type'] as String,
body: json['body'],
visibility: (json['visibility'] as num).toInt(),
tags: (json['tags'] as List<dynamic>?)
?.map((e) => Tag.fromJson(e as Map<String, dynamic>))
.toList(),
@ -67,6 +68,7 @@ Map<String, dynamic> _$PostToJson(Post instance) => <String, dynamic>{
'alias': instance.alias,
'area_alias': instance.areaAlias,
'body': instance.body,
'visibility': instance.visibility,
'tags': instance.tags?.map((e) => e.toJson()).toList(),
'categories': instance.categories?.map((e) => e.toJson()).toList(),
'replies': instance.replies?.map((e) => e.toJson()).toList(),

View File

@ -11,6 +11,7 @@ import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/auth.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart';
@ -174,7 +175,7 @@ class AuthProvider extends GetConnect {
);
Get.find<WebSocketProvider>().connect();
Get.find<WebSocketProvider>().notifyPrefetch();
Get.find<NotificationProvider>().fetchNotification();
return credentials!;
}
@ -184,8 +185,8 @@ class AuthProvider extends GetConnect {
userProfile.value = null;
Get.find<WebSocketProvider>().disconnect();
Get.find<WebSocketProvider>().notifications.clear();
Get.find<WebSocketProvider>().notificationUnread.value = 0;
Get.find<NotificationProvider>().notifications.clear();
Get.find<NotificationProvider>().notificationUnread.value = 0;
AppDatabase.removeDatabase();
autoStopBackgroundNotificationService();

View File

@ -23,6 +23,21 @@ class AttachmentProvider extends GetConnect {
final Map<String, Attachment> _cachedResponses = {};
List<Attachment?> listMetadataFromCache(List<String> rid) {
if (rid.isEmpty) return List.empty();
List<Attachment?> result = List.filled(rid.length, null);
for (var idx = 0; idx < rid.length; idx++) {
if (_cachedResponses.containsKey(rid[idx])) {
result[idx] = _cachedResponses[rid[idx]];
} else {
result[idx] = null;
}
}
return result;
}
Future<List<Attachment?>> listMetadata(
List<String> rid, {
noCache = false,

View File

@ -44,9 +44,33 @@ class PostProvider extends GetxController {
final queries = [
'take=${10}',
'offset=$page',
'truncate=false',
];
final client = await auth.configureClient('interactive');
final resp = await client.get('/posts/drafts?${queries.join('&')}');
final resp = await client.get(
'/posts/drafts?${queries.join('&')}',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return resp;
}
Future<Response> searchPost(String probe, int page,
{String? realm, String? author, tag, category, int take = 10}) async {
final queries = [
'probe=$probe',
'take=$take',
'offset=$page',
if (tag != null) 'tag=$tag',
if (category != null) 'category=$category',
if (author != null) 'author=$author',
if (realm != null) 'realm=$realm',
];
final AuthProvider auth = Get.find();
final client = await auth.configureClient('co');
final resp = await client.get('/posts/search?${queries.join('&')}');
if (resp.statusCode != 200) {
throw RequestException(resp);
}

View File

@ -299,53 +299,71 @@ typedef $$LocalMessageEventTableTableUpdateCompanionBuilder
});
class $$LocalMessageEventTableTableFilterComposer
extends FilterComposer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableFilterComposer(super.$state);
ColumnFilters<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
extends Composer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<int> get channelId => $state.composableBuilder(
column: $state.table.channelId,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<int> get channelId => $composableBuilder(
column: $table.channelId, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<Event?, Event, String> get data =>
$state.composableBuilder(
column: $state.table.data,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column,
joinBuilders: joinBuilders));
$composableBuilder(
column: $table.data,
builder: (column) => ColumnWithTypeConverterFilters(column));
ColumnFilters<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnFilters(column));
}
class $$LocalMessageEventTableTableOrderingComposer
extends OrderingComposer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableOrderingComposer(super.$state);
ColumnOrderings<int> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
extends Composer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get channelId => $state.composableBuilder(
column: $state.table.channelId,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<int> get channelId => $composableBuilder(
column: $table.channelId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get data => $state.composableBuilder(
column: $state.table.data,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get data => $composableBuilder(
column: $table.data, builder: (column) => ColumnOrderings(column));
ColumnOrderings<DateTime> get createdAt => $state.composableBuilder(
column: $state.table.createdAt,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt, builder: (column) => ColumnOrderings(column));
}
class $$LocalMessageEventTableTableAnnotationComposer
extends Composer<_$AppDatabase, $LocalMessageEventTableTable> {
$$LocalMessageEventTableTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<int> get channelId =>
$composableBuilder(column: $table.channelId, builder: (column) => column);
GeneratedColumnWithTypeConverter<Event?, String> get data =>
$composableBuilder(column: $table.data, builder: (column) => column);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
}
class $$LocalMessageEventTableTableTableManager extends RootTableManager<
@ -354,6 +372,7 @@ class $$LocalMessageEventTableTableTableManager extends RootTableManager<
LocalMessageEventTableData,
$$LocalMessageEventTableTableFilterComposer,
$$LocalMessageEventTableTableOrderingComposer,
$$LocalMessageEventTableTableAnnotationComposer,
$$LocalMessageEventTableTableCreateCompanionBuilder,
$$LocalMessageEventTableTableUpdateCompanionBuilder,
(
@ -368,10 +387,15 @@ class $$LocalMessageEventTableTableTableManager extends RootTableManager<
: super(TableManagerState(
db: db,
table: table,
filteringComposer: $$LocalMessageEventTableTableFilterComposer(
ComposerState(db, table)),
orderingComposer: $$LocalMessageEventTableTableOrderingComposer(
ComposerState(db, table)),
createFilteringComposer: () =>
$$LocalMessageEventTableTableFilterComposer(
$db: db, $table: table),
createOrderingComposer: () =>
$$LocalMessageEventTableTableOrderingComposer(
$db: db, $table: table),
createComputedFieldComposer: () =>
$$LocalMessageEventTableTableAnnotationComposer(
$db: db, $table: table),
updateCompanionCallback: ({
Value<int> id = const Value.absent(),
Value<int> channelId = const Value.absent(),
@ -410,6 +434,7 @@ typedef $$LocalMessageEventTableTableProcessedTableManager
LocalMessageEventTableData,
$$LocalMessageEventTableTableFilterComposer,
$$LocalMessageEventTableTableOrderingComposer,
$$LocalMessageEventTableTableAnnotationComposer,
$$LocalMessageEventTableTableCreateCompanionBuilder,
$$LocalMessageEventTableTableUpdateCompanionBuilder,
(

View File

@ -4,19 +4,19 @@ import 'package:intl/intl.dart';
class ExperienceProvider extends GetxController {
static List<int> experienceToLevelRequirements = [
0, // Level 0
100, // Level 1
400, // Level 2
900, // Level 3
1600, // Level 4
2500, // Level 5
3600, // Level 6
4900, // Level 7
6400, // Level 8
8100, // Level 9
10000, // Level 10
12100, // Level 11
14400, // Level 12
36800 // Level 13
1000, // Level 1
4000, // Level 2
9000, // Level 3
16000, // Level 4
25000, // Level 5
36000, // Level 6
49000, // Level 7
64000, // Level 8
81000, // Level 9
100000, // Level 10
121000, // Level 11
144000, // Level 12
368000 // Level 13
];
static List<String> levelLabelMapping =
@ -35,7 +35,7 @@ class ExperienceProvider extends GetxController {
final idx = experienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= experienceToLevelRequirements.length) return 1;
final nextExp = experienceToLevelRequirements[idx + 1];
return exp / nextExp;
return (experience - exp).abs() / (exp - nextExp).abs();
}
static String calcLevelUpProgressLevel(int experience) {
@ -43,9 +43,9 @@ class ExperienceProvider extends GetxController {
.firstWhere((x) => x <= experience);
final idx = experienceToLevelRequirements.indexOf(exp);
if (idx + 1 >= experienceToLevelRequirements.length) return 'Infinity';
final nextExp = experienceToLevelRequirements[idx + 1];
final nextExp = exp - experienceToLevelRequirements[idx + 1];
final formatter =
NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
return '${formatter.format(exp)}/${formatter.format(nextExp)}';
return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}';
}
}

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:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/services.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
@ -21,56 +15,10 @@ class WebSocketProvider extends GetxController {
RxBool isConnected = false.obs;
RxBool isConnecting = false.obs;
RxInt notificationUnread = 0.obs;
RxList<Notification> notifications =
List<Notification>.empty(growable: true).obs;
WebSocketChannel? websocket;
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 {
if (isConnected.value) {
return;
@ -119,8 +67,9 @@ class WebSocketProvider extends GetxController {
log('Websocket incoming message: ${packet.method} ${packet.message}');
stream.sink.add(packet);
if (packet.method == 'notifications.new') {
notifications.add(Notification.fromJson(packet.payload!));
notificationUnread.value++;
final NotificationProvider nty = Get.find();
nty.notifications.add(Notification.fromJson(packet.payload!));
nty.notificationUnread.value++;
}
},
onDone: () {
@ -133,95 +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;
final deviceUuid = await _getDeviceUuid();
if (deviceUuid == null || deviceUuid.isEmpty) {
log("Unable to active push notifications, couldn't get device uuid");
} 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 {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (PlatformInfo.isWeb) {
final webInfo = await deviceInfo.webBrowserInfo;
return webInfo.vendor! +
webInfo.userAgent! +
webInfo.hardwareConcurrency.toString();
}
if (PlatformInfo.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
return androidInfo.id;
}
if (PlatformInfo.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
return iosInfo.identifierForVendor!;
}
if (PlatformInfo.isLinux) {
final linuxInfo = await deviceInfo.linuxInfo;
return linuxInfo.machineId!;
}
if (PlatformInfo.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
return windowsInfo.deviceId;
}
if (PlatformInfo.isMacOS) {
final macosInfo = await deviceInfo.macOsInfo;
return macosInfo.systemGUID;
}
return null;
}
}

View File

@ -2,11 +2,14 @@ import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/bootstrapper.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/audit_log.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/preferences/notifications.dart';
import 'package:solian/screens/account/preferences/security.dart';
import 'package:solian/screens/account/profile_edit.dart';
import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/auth/signin.dart';
@ -16,9 +19,9 @@ import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart';
import 'package:solian/screens/chat.dart';
import 'package:solian/screens/dashboard.dart';
import 'package:solian/screens/feed/search.dart';
import 'package:solian/screens/posts/post_search.dart';
import 'package:solian/screens/posts/post_detail.dart';
import 'package:solian/screens/feed/draft_box.dart';
import 'package:solian/screens/posts/draft_box.dart';
import 'package:solian/screens/realms.dart';
import 'package:solian/screens/realms/realm_detail.dart';
import 'package:solian/screens/realms/realm_organize.dart';
@ -94,7 +97,7 @@ abstract class AppRouter {
name: 'postSearch',
builder: (context, state) => TitleShell(
state: state,
child: FeedSearchScreen(
child: PostSearchScreen(
tag: state.uri.queryParameters['tag'],
category: state.uri.queryParameters['category'],
),
@ -107,6 +110,7 @@ abstract class AppRouter {
state: state,
child: PostDetailScreen(
id: state.pathParameters['id']!,
post: state.extra as Post?,
),
),
),
@ -264,6 +268,22 @@ abstract class AppRouter {
child: const NotificationPreferencesScreen(),
),
),
GoRoute(
path: '/account/preferences/auth',
name: 'authPreferences',
builder: (context, state) => TitleShell(
state: state,
child: const AuthPreferencesScreen(),
),
),
GoRoute(
path: '/account/audit',
name: 'auditLog',
builder: (context, state) => TitleShell(
state: state,
child: const AuditLogScreen(),
),
),
GoRoute(
path: '/account/view/:name',
name: 'accountProfilePage',

View File

@ -129,6 +129,24 @@ class _AccountScreenState extends State<AccountScreen> {
AppRouter.instance.pushNamed('settings');
},
),
if (auth.isAuthorized.value)
ListTile(
leading: const Icon(Icons.event_repeat),
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
title: Text('auditLog'.tr),
onTap: () {
AppRouter.instance.pushNamed('auditLog');
},
),
if (auth.isAuthorized.value)
ListTile(
leading: const Icon(Icons.lock),
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
title: Text('authPreferences'.tr),
onTap: () {
AppRouter.instance.pushNamed('authPreferences');
},
),
if (auth.isAuthorized.value)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:marquee/marquee.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/audit_log.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/relative_date.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import 'package:timeline_tile/timeline_tile.dart';
class AuditLogScreen extends StatefulWidget {
const AuditLogScreen({super.key});
@override
State<AuditLogScreen> createState() => _AuditLogScreenState();
}
class _AuditLogScreenState extends State<AuditLogScreen> {
bool _isBusy = true;
final List<AuditEvent> _events = List.empty(growable: true);
Future<void> _getEvents() async {
if (!_isBusy) setState(() => _isBusy = true);
final AuthProvider auth = Get.find();
final client = await auth.configureClient('id');
final resp =
await client.get('/users/me/events?take=15&offset=${_events.length}');
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
}
final result = PaginationResult.fromJson(resp.body);
setState(() {
_events.addAll(
result.data?.map((x) => AuditEvent.fromJson(x)).toList() ??
List.empty(),
);
_isBusy = false;
});
}
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
void initState() {
super.initState();
_getEvents();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
CheckboxListTile(
value: _showIp,
title: Text('showIp'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: const Icon(Icons.alternate_email),
tileColor:
Theme.of(context).colorScheme.surfaceContainer.withOpacity(0.5),
onChanged: (val) {
setState(() => _showIp = val ?? false);
},
),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_events.clear();
return _getEvents();
},
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),
),
).paddingSymmetric(horizontal: 18);
},
),
),
),
],
);
}
}

View File

@ -1,9 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/models/notification.dart' as notify;
import 'package:solian/models/notification.dart';
import 'package:solian/models/post.dart';
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';
class NotificationScreen extends StatefulWidget {
@ -14,57 +19,9 @@ class NotificationScreen extends StatefulWidget {
}
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
Widget build(BuildContext context) {
final WebSocketProvider ws = Get.find();
final NotificationProvider nty = Get.find();
return SizedBox(
height: MediaQuery.of(context).size.height * 0.85,
@ -77,71 +34,174 @@ class _NotificationScreenState extends State<NotificationScreen> {
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded(
child: Obx(() {
return CustomScrollView(
slivers: [
if (_isBusy)
return RefreshIndicator(
onRefresh: () => nty.fetchNotification(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: const LinearProgressIndicator().animate().scaleX(),
),
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),
),
child: LoadingIndicator(
isActive: nty.isBusy.value,
),
),
if (ws.notifications.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: _isBusy ? null : () => _markAllRead(),
if (nty.notifications
.where((x) => x.readAt == null)
.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 (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: [
Icon(
Icons.new_releases_outlined,
color: Theme.of(context)
.colorScheme
.onSurface,
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 +216,7 @@ class NotificationButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final WebSocketProvider provider = Get.find();
final NotificationProvider nty = Get.find();
final button = IconButton(
icon: const Icon(Icons.notifications),
@ -166,16 +226,16 @@ class NotificationButton extends StatelessWidget {
isScrollControlled: true,
context: context,
builder: (context) => const NotificationScreen(),
).then((_) => provider.notificationUnread.value = 0);
).then((_) => nty.notificationUnread.value = 0);
},
);
return Obx(() {
if (provider.notificationUnread.value > 0) {
if (nty.notificationUnread.value > 0) {
return Badge(
isLabelVisible: true,
offset: const Offset(-8, 2),
label: Text(provider.notificationUnread.value.toString()),
label: Text(nty.notificationUnread.value.toString()),
child: button,
);
} else {
@ -184,3 +244,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_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/loading_indicator.dart';
class NotificationPreferencesScreen extends StatefulWidget {
const NotificationPreferencesScreen({super.key});
@ -59,10 +59,10 @@ class _NotificationPreferencesScreenState
});
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
} else {
context.showSnackbar('preferencesApplied'.tr);
}
context.showSnackbar('preferencesApplied'.tr);
setState(() => _isBusy = false);
}
@ -76,7 +76,7 @@ class _NotificationPreferencesScreenState
Widget build(BuildContext context) {
return Column(
children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),

View File

@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/exceptions/exceptions.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/loading_indicator.dart';
class AuthPreferencesScreen extends StatefulWidget {
const AuthPreferencesScreen({super.key});
@override
State<AuthPreferencesScreen> createState() => _AuthPreferencesScreenState();
}
class _AuthPreferencesScreenState extends State<AuthPreferencesScreen> {
bool _isBusy = true;
Map<String, dynamic> _config = {
'maximum_auth_steps': 2,
};
Future<void> _getPreferences() async {
setState(() => _isBusy = true);
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw UnauthorizedException();
final client = await auth.configureClient('id');
final resp = await client.get('/preferences/auth');
if (resp.statusCode != 200 && resp.statusCode != 404) {
context.showErrorDialog(RequestException(resp));
}
if (resp.statusCode == 200) {
_config = resp.body;
}
setState(() => _isBusy = false);
}
Future<void> _savePreferences() async {
setState(() => _isBusy = true);
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw UnauthorizedException();
final client = await auth.configureClient('id');
final resp = await client.put('/preferences/auth', _config);
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
} else {
context.showSnackbar('preferencesApplied'.tr);
}
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
LoadingIndicator(isActive: _isBusy),
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainer,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.save),
title: Text('save'.tr),
enabled: !_isBusy,
onTap: () {
_savePreferences();
},
),
Expanded(
child: ListView(
children: [
ListTile(
title: Text('authMaximumAuthSteps'.tr),
subtitle: Text('authMaximumAuthStepsDesc'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: SizedBox(
width: 60,
child: _isBusy
? null
: TextFormField(
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
),
initialValue:
_config['maximum_auth_steps']?.toString() ?? '2',
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly
],
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
_config['maximum_auth_steps'] =
int.tryParse(value) ?? 2;
},
),
),
),
],
),
),
],
);
}
}

View File

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

View File

@ -348,7 +348,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
detail: _userinfo,
profile: _userinfo!.profile,
extraWidgets: [
if (_dailySignRecords.isNotEmpty)
if (_dailySignRecords.length > 1)
Card(
child: SizedBox(
height: 180,
@ -588,8 +588,6 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
color:
Theme.of(context).colorScheme.surfaceContainerLow,
child: PostListEntryWidget(
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
item: element,
isClickable: true,
isNestedClickable: true,

View File

@ -8,8 +8,8 @@ import 'package:solian/exts.dart';
import 'package:solian/models/auth.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/notifications.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:url_launcher/url_launcher_string.dart';
@ -178,7 +178,7 @@ class _SignInScreenState extends State<SignInScreen> {
Get.find<RealmProvider>().refreshAvailableRealms();
Get.find<RelationshipProvider>().refreshRelativeList();
Get.find<WebSocketProvider>().registerPushNotifications();
Get.find<NotificationProvider>().registerPushNotifications();
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();

View File

@ -198,7 +198,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
Widget build(BuildContext context) {
final ChatCallProvider ctrl = Get.find();
return RootContainer(
return ResponsiveRootContainer(
child: Scaffold(
appBar: widget.hideAppBar
? null

View File

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

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_resizable_container/flutter_resizable_container.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
@ -19,6 +20,7 @@ import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/channel/channel_list.dart';
import 'package:solian/widgets/chat/call/chat_call_indicator.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/sidebar/empty_placeholder.dart';
@ -41,14 +43,23 @@ class ChatListShell extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RootContainer(
child: Row(
child: ResizableContainer(
direction: Axis.horizontal,
divider: ResizableDivider(
thickness: 0.3,
color: Theme.of(context).dividerColor.withOpacity(0.3),
),
children: [
const SizedBox(
width: 360,
const ResizableChild(
minSize: 280,
maxSize: 520,
size: ResizableSize.pixels(360),
child: ChatList(),
),
const VerticalDivider(thickness: 0.3, width: 0.3),
Expanded(child: child ?? const EmptyPagePlaceholder()),
ResizableChild(
minSize: 280,
child: child ?? const EmptyPagePlaceholder(),
),
],
),
);
@ -280,26 +291,7 @@ class _ChatListState extends State<ChatList> {
return Column(
children: [
const ChatCallCurrentIndicator(),
if (_isBusy)
Container(
color: Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0.8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
const Gap(8),
Text('loading'.tr)
],
).paddingSymmetric(vertical: 8),
),
LoadingIndicator(isActive: _isBusy),
Expanded(
child: TabBarView(
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/database/services/messages.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/screens/account/notification.dart';
import 'package:solian/theme.dart';
@ -38,7 +38,7 @@ class DashboardScreen extends StatefulWidget {
class _DashboardScreenState extends State<DashboardScreen> {
late final AuthProvider _auth = 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 DailySignProvider _dailySign = Get.find();
@ -46,7 +46,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
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));
List<Post>? _currentPosts;
@ -254,7 +254,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
),
Text(
'notificationUnreadCount'.trParams({
'count': _ws.notifications.length.toString(),
'count': _pendingNotifications.length.toString(),
}),
),
],
@ -267,12 +267,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
isScrollControlled: true,
context: context,
builder: (context) => const NotificationScreen(),
).then((_) => _ws.notificationUnread.value = 0);
).then((_) => _nty.notificationUnread.value = 0);
},
),
],
).paddingOnly(left: 18, right: 18, bottom: 8),
if (_ws.notifications.isNotEmpty)
if (_pendingNotifications.isNotEmpty)
SizedBox(
height: 76,
child: ListView.separated(
@ -389,10 +389,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
onUpdate: (_) {
_pullPosts();
},
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerLow,
).paddingAll(8),
padding: EdgeInsets.symmetric(
vertical: 8,
horizontal: 4,
),
),
),
),
).paddingSymmetric(horizontal: 8),
@ -525,7 +526,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
style: TextStyle(color: _unFocusColor, fontSize: 12),
)
],
).paddingAll(8),
).paddingOnly(left: 8, right: 8, top: 8, bottom: 50),
],
),
);

View File

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
@ -6,6 +7,7 @@ import 'package:get/get.dart';
import 'package:solian/controllers/post_list_controller.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
@ -87,76 +89,89 @@ class _ExploreScreenState extends State<ExploreScreen>
final scrollProgress =
(scrollOffset / colorChangeOffset).clamp(0.0, 1.0);
final backgroundColor = Color.lerp(
Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0),
Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0.9),
scrollProgress,
);
final blurSigma = lerpDouble(0, 10, scrollProgress) ?? 0;
return SliverAppBar(
backgroundColor: backgroundColor,
flexibleSpace: SizedBox(
height: 48,
child: const Row(
children: [
RealmSwitcher(),
],
).paddingSymmetric(horizontal: 8),
).paddingOnly(top: MediaQuery.of(context).padding.top),
flexibleSpace: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: blurSigma,
sigmaY: blurSigma,
),
child: ListView(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
children: [
SizedBox(
height: 48,
child: const Row(
children: [
RealmSwitcher(),
],
).paddingSymmetric(horizontal: 8),
).paddingSymmetric(vertical: 4),
TabBar(
controller: _tabController,
dividerHeight: scrollProgress > 0 ? 0 : 0.3,
tabAlignment: TabAlignment.fill,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.feed, size: 20),
const Gap(8),
Text('postListNews'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people, size: 20),
const Gap(8),
Text('postListFriends'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.shuffle_on_outlined,
size: 20,
),
const Gap(8),
Text('postListShuffle'.tr),
],
),
),
],
),
],
).paddingOnly(top: MediaQuery.of(context).padding.top),
),
),
expandedHeight: 104,
snap: true,
floating: true,
toolbarHeight: AppTheme.toolbarHeight(context),
leading: AppBarLeadingButton.adaptive(context),
actions: [
const BackgroundStateWidget(),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
AppRouter.instance.pushNamed('postSearch');
},
),
const NotificationButton(),
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
bottom: TabBar(
controller: _tabController,
dividerHeight: 0.3,
tabAlignment: TabAlignment.fill,
tabs: [
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.feed, size: 20),
const Gap(8),
Text('postListNews'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.people, size: 20),
const Gap(8),
Text('postListFriends'.tr),
],
),
),
Tab(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.shuffle_on_outlined, size: 20),
const Gap(8),
Text('postListShuffle'.tr),
],
),
),
],
),
);
},
)
@ -180,6 +195,12 @@ class _ExploreScreenState extends State<ExploreScreen>
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
ControlledPostListWidget(
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),
@ -191,6 +212,9 @@ class _ExploreScreenState extends State<ExploreScreen>
onRefresh: () => _postController.reloadAllOver(),
child: CustomScrollView(slivers: [
ControlledPostListWidget(
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(horizontal: 16)
: EdgeInsets.zero,
controller: _postController.pagingController,
onUpdate: () => _postController.reloadAllOver(),
),

View File

@ -1,114 +0,0 @@
import 'package:flutter/material.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/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_owned_list.dart';
import 'package:solian/widgets/root_container.dart';
class DraftBoxScreen extends StatefulWidget {
const DraftBoxScreen({super.key});
@override
State<DraftBoxScreen> createState() => _DraftBoxScreenState();
}
class _DraftBoxScreenState extends State<DraftBoxScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
_getPosts(int pageKey) async {
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listDraft(pageKey);
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
if (result.count == 0) {
_pagingController.appendLastPage([]);
return;
}
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
}
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_getPosts);
}
@override
Widget build(BuildContext context) {
return RootContainer(
child: Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('draftBox'.tr),
centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context),
actions: [
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: PagedListView<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) {
return PostOwnedListEntry(
item: item,
isFullContent: true,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
onTap: () async {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(
item: item,
noReact: true,
),
).then((value) {
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

@ -1,99 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/posts/post_list.dart';
import '../../models/post.dart';
class FeedSearchScreen extends StatefulWidget {
final String? tag;
final String? category;
const FeedSearchScreen({super.key, this.tag, this.category});
@override
State<FeedSearchScreen> createState() => _FeedSearchScreenState();
}
class _FeedSearchScreenState extends State<FeedSearchScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listPost(
pageKey,
tag: widget.tag,
category: widget.category,
);
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
}
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(getPosts);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
if (widget.tag != null)
ListTile(
leading: const Icon(Icons.label),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
),
if (widget.category != null)
ListTile(
leading: const Icon(Icons.category),
tileColor: Theme.of(context).colorScheme.surfaceContainer,
title: Text('postSearchWithCategory'
.trParams({'key': widget.category!})),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
ControlledPostListWidget(
controller: _pagingController,
onUpdate: () => _pagingController.refresh(),
),
],
),
),
),
],
),
),
);
}
@override
void dispose() {
_pagingController.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.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_item.dart';
import 'package:solian/widgets/root_container.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class DraftBoxScreen extends StatefulWidget {
const DraftBoxScreen({super.key});
@override
State<DraftBoxScreen> createState() => _DraftBoxScreenState();
}
class _DraftBoxScreenState extends State<DraftBoxScreen> {
bool _isBusy = true;
int? _totalPosts;
final List<Post> _posts = List.empty(growable: true);
_getPosts() async {
setState(() => _isBusy = true);
final PostProvider posts = Get.find();
final resp = await posts.listDraft(_posts.length);
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
_totalPosts = result.count;
_posts.addAll(parsed ?? List.empty());
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
void initState() {
super.initState();
_getPosts();
}
@override
Widget build(BuildContext context) {
return RootContainer(
child: Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('draftBox'.tr),
centerTitle: false,
toolbarHeight: AppTheme.toolbarHeight(context),
actions: [
SizedBox(
width: AppTheme.isLargeScreen(context) ? 8 : 16,
),
],
),
body: Column(
children: [
LoadingIndicator(isActive: _isBusy),
Expanded(
child: RefreshIndicator(
onRefresh: () {
_posts.clear();
return _getPosts();
},
child: InfiniteList(
itemCount: _posts.length,
hasReachedMax: _totalPosts == _posts.length,
isLoading: _isBusy,
onFetchData: () => _getPosts(),
itemBuilder: (context, index) {
final item = _posts[index];
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,
onTapMore: () => _openActions(item),
).paddingSymmetric(vertical: 8),
],
),
onTap: () => _openActions(item),
),
).paddingOnly(left: 12, right: 12, bottom: 4);
},
),
),
),
],
),
),
);
}
}

View File

@ -4,6 +4,9 @@ import 'package:solian/exts.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/last_read.dart';
import 'package:solian/theme.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_replies.dart';
@ -22,73 +25,109 @@ class PostDetailScreen extends StatefulWidget {
}
class _PostDetailScreenState extends State<PostDetailScreen> {
Post? item;
bool _isBusy = true;
Future<Post?> _getDetail() async {
if (widget.post != null) {
setState(() {
item = widget.post;
});
}
Post? _item;
final PostProvider provider = Get.find();
Future<void> _getDetail() async {
final PostProvider posts = Get.find();
try {
final resp = await provider.getPost(widget.id);
item = Post.fromJson(resp.body);
final resp = await posts.getPost(widget.id);
_item = Post.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context));
}
Get.find<LastReadProvider>().feedLastReadAt = item?.id;
Get.find<LastReadProvider>().feedLastReadAt = _item?.id;
return item;
if (mounted) setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
if (widget.post != null) {
_item = widget.post;
}
_getDetail();
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _getDetail(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
if (_isBusy && _item == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PostItem(
item: item!,
isClickable: false,
isOverrideEmbedClickable: true,
isFullDate: true,
isFullContent: true,
isShowReply: false,
isContentSelectable: true,
),
),
SliverToBoxAdapter(
child:
const Divider(thickness: 0.3, height: 1).paddingOnly(top: 4),
),
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
),
PostReplyList(item: item!),
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
),
],
);
},
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: LoadingIndicator(isActive: _isBusy),
),
SliverToBoxAdapter(
child: PostItem(
key: ValueKey(_item),
item: _item!,
isClickable: false,
isOverrideEmbedClickable: true,
isFullDate: true,
isShowReply: false,
isContentSelectable: true,
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: 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(
child: const Divider(thickness: 0.3, height: 1).paddingOnly(
top: 8,
),
),
SliverToBoxAdapter(
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'postReplies'.tr,
style: Theme.of(context).textTheme.headlineSmall,
).paddingOnly(left: 24, right: 24, top: 16),
),
),
PostReplyList(
item: _item!,
padding: AppTheme.isLargeScreen(context)
? EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
)
: EdgeInsets.zero,
),
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
),
],
);
}
}

View File

@ -16,6 +16,7 @@ import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.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/posts/post_item.dart';
import 'package:badges/badges.dart' as badges;
@ -182,7 +183,10 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
tileColor: Theme.of(context).colorScheme.surfaceContainerLow,
tileColor: Theme.of(context)
.colorScheme
.surfaceContainerLow
.withOpacity(0.5),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -271,7 +275,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
),
],
),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
LoadingIndicator(isActive: _isBusy),
Expanded(
child: DefaultTabController(
length: 2,

View File

@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/loading_indicator.dart';
import 'package:solian/widgets/posts/post_list.dart';
import '../../models/post.dart';
class PostSearchScreen extends StatefulWidget {
final String? tag;
final String? category;
const PostSearchScreen({super.key, this.tag, this.category});
@override
State<PostSearchScreen> createState() => _PostSearchScreenState();
}
class _PostSearchScreenState extends State<PostSearchScreen> {
int? _totalCount;
Duration? _lastTook;
final TextEditingController _probeController = TextEditingController();
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
late bool _isBusy = widget.tag != null || widget.category != null;
_searchPosts(int pageKey) async {
if (widget.tag == null &&
widget.category == null &&
_probeController.text.isEmpty) {
_pagingController.appendLastPage([]);
return;
}
if (!_isBusy) {
setState(() => _isBusy = true);
}
if (pageKey == 0) {
_pagingController.itemList?.clear();
_pagingController.nextPageKey = 0;
}
final PostProvider posts = Get.find();
Stopwatch stopwatch = new Stopwatch()..start();
Response resp;
try {
if (_probeController.text.isEmpty) {
resp = await posts.listPost(
pageKey,
tag: widget.tag,
category: widget.category,
);
} else {
resp = await posts.searchPost(
_probeController.text,
pageKey,
tag: widget.tag,
category: widget.category,
);
}
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
_pagingController.appendLastPage(parsed);
}
stopwatch.stop();
_totalCount = result.count;
_lastTook = stopwatch.elapsed;
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener(_searchPosts);
}
@override
void dispose() {
_probeController.dispose();
_pagingController.dispose();
super.dispose();
}
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
if (widget.tag != null)
ListTile(
leading: const Icon(Icons.label),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
tileColor: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
title: Text('postSearchWithTag'.trParams({'key': widget.tag!})),
),
if (widget.category != null)
ListTile(
leading: const Icon(Icons.category),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
tileColor: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
title: Text('postSearchWithCategory'.trParams({
'key': widget.category!,
})),
),
Container(
color: Theme.of(context)
.colorScheme
.secondaryContainer
.withOpacity(0.5),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: TextField(
controller: _probeController,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: 'search'.tr,
),
onSubmitted: (_) {
_searchPosts(0);
},
),
),
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(
child: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
ControlledPostListWidget(
controller: _pagingController,
onUpdate: () => _pagingController.refresh(),
),
SliverGap(MediaQuery.of(context).padding.bottom),
],
),
),
),
],
),
);
}
}

View File

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

View File

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

View File

@ -43,7 +43,10 @@ class RootShell extends StatelessWidget {
final showRailNavigation = AppTheme.isLargeScreen(context);
final destNames = AppNavigation.destinations.map((x) => x.page).toList();
final destNames = [
'postDetail',
...AppNavigation.destinations.map((x) => x.page),
];
final showBottomNavigation =
destNames.contains(routeName) && !showRailNavigation;
@ -52,13 +55,22 @@ class RootShell extends StatelessWidget {
backgroundColor: Theme.of(context).colorScheme.surface,
bottomNavigationBar: showBottomNavigation
? AppNavigationBottom(
initialIndex: destNames.indexOf(routeName ?? 'page'),
initialIndex: AppNavigation.destinations
.map((x) => x.page)
.toList()
.indexOf(routeName ?? 'page'),
)
: null,
body: AppTheme.isLargeScreen(context)
? Row(
children: [
if (showRailNavigation) const AppNavigationRail(),
if (showRailNavigation)
AppNavigationRail(
initialIndex: AppNavigation.destinations
.map((x) => x.page)
.toList()
.indexOf(routeName ?? 'page'),
),
if (showRailNavigation)
const VerticalDivider(
width: 0.3,

View File

@ -89,8 +89,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.75,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: ListView(
children: [
AccountHeadingWidget(
avatar: _userinfo!.avatar,

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_editor_thumbnail.dart';
import 'package:solian/widgets/attachments/attachment_fullscreen.dart';
import 'package:solian/widgets/loading_indicator.dart';
class AttachmentEditorPopup extends StatefulWidget {
final String pool;
@ -32,12 +33,14 @@ class AttachmentEditorPopup extends StatefulWidget {
final List<String>? initialAttachments;
final void Function(String) onAdd;
final void Function(String) onRemove;
final void Function(String)? onInsert;
const AttachmentEditorPopup({
super.key,
required this.pool,
required this.onAdd,
required this.onRemove,
this.onInsert,
this.singleMode = false,
this.imageOnly = false,
this.autoUpload = false,
@ -228,7 +231,10 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
.listMetadata(widget.initialAttachments ?? List.empty())
.then((result) {
setState(() {
_attachments = List.from(result, growable: true);
_attachments = List.from(
result.where((x) => x != null),
growable: true,
);
_isBusy = false;
_isFirstTimeBusy = false;
});
@ -553,6 +559,22 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
setState(() => _attachments.removeAt(idx));
},
),
if (widget.onInsert != null)
PopupMenuItem(
child: ListTile(
title: Text('insert'.tr),
leading: const Icon(Icons.insert_link),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
onTap: () {
widget.onInsert!(
'![](solink://attachments/${element.rid})',
);
Navigator.pop(context);
},
),
],
),
],
@ -660,7 +682,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
),
],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
LoadingIndicator(isActive: _isBusy),
Expanded(
child: CustomScrollView(
slivers: [

View File

@ -8,6 +8,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:gal/gal.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/platform.dart';
@ -103,9 +104,10 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
@override
Widget build(BuildContext context) {
final metaTextStyle = TextStyle(
final metaTextStyle = GoogleFonts.roboto(
fontSize: 12,
color: _unFocusColor,
height: 1,
);
return DismissiblePage(
@ -239,25 +241,58 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
child: Wrap(
spacing: 6,
children: [
Text(
'#${widget.item.rid}',
style: metaTextStyle,
),
if (widget.item.metadata?['width'] != null &&
widget.item.metadata?['height'] != null)
if (widget.item.metadata?['exif'] == null)
Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
'#${widget.item.rid}',
style: metaTextStyle,
),
if (widget.item.metadata?['exif']?['Model'] != null)
Text(
'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']?['Aperture'] !=
null)
Text(
'f/${widget.item.metadata?['exif']?['Aperture']}',
style: metaTextStyle,
).paddingOnly(right: 2),
if (widget.item.metadata?['exif']?['Megapixels'] !=
null &&
widget.item.metadata?['exif']?['Model'] != null)
Text(
'${widget.item.metadata?['exif']?['Megapixels']}MP',
style: metaTextStyle,
)
else
Text(
widget.item.size.formatBytes(),
style: metaTextStyle,
),
Text(
'${widget.item.metadata?['width']}x${widget.item.metadata?['height']}',
style: metaTextStyle,
),
if (widget.item.metadata?['ratio'] != null)
Text(
'${_getRatio().toPrecision(2)}',
(widget.item.metadata?['ratio'] as num)
.toStringAsFixed(2),
style: metaTextStyle,
),
Text(
widget.item.size.formatBytes(),
style: metaTextStyle,
),
Text(
widget.item.mimetype,
style: metaTextStyle,

View File

@ -155,11 +155,18 @@ class _AttachmentItemImage extends StatelessWidget {
),
if (showBadge && badge != null)
Positioned(
right: 12,
bottom: 8,
right: 8,
bottom: 4,
child: Material(
color: Colors.transparent,
child: Chip(label: Text(badge!)),
child: Chip(
label: Text(badge!),
labelStyle: GoogleFonts.robotoMono(),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
),
),
),
if (showHideButton && item.isMature)

View File

@ -1,7 +1,6 @@
import 'dart:math' as math;
import 'dart:ui';
import 'package:carousel_slider/carousel_slider.dart';
import 'package:dismissible_page/dismissible_page.dart';
import 'package:flutter/material.dart' hide CarouselController;
import 'package:flutter_animate/flutter_animate.dart';
@ -23,6 +22,7 @@ class AttachmentList extends StatefulWidget {
final bool autoload;
final double columnMaxWidth;
final EdgeInsets? padding;
final double? width;
final double? viewport;
@ -36,6 +36,7 @@ class AttachmentList extends StatefulWidget {
this.isFullWidth = false,
this.autoload = false,
this.columnMaxWidth = 480,
this.padding,
this.width,
this.viewport,
});
@ -48,6 +49,7 @@ class _AttachmentListState extends State<AttachmentList> {
bool _isLoading = true;
bool _showMature = false;
// ignore: unused_field
double _aspectRatio = 1;
List<Attachment?> _attachments = List.empty();
@ -133,7 +135,17 @@ class _AttachmentListState extends State<AttachmentList> {
super.initState();
assert(widget.attachmentIds != null || widget.attachments != null);
if (widget.attachments == null) {
_getMetadataList();
final AttachmentProvider attach = Get.find();
final cachedResult = attach.listMetadataFromCache(widget.attachmentIds!);
if (cachedResult.every((x) => x != null)) {
setState(() {
_attachments = cachedResult;
_isLoading = false;
});
_calculateAspectRatio();
} else {
_getMetadataList();
}
} else {
setState(() {
_attachments = widget.attachments!;
@ -161,9 +173,7 @@ class _AttachmentListState extends State<AttachmentList> {
color: _unFocusColor,
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': _attachments.toString()},
),
'attachmentHint'.trParams({'count': _attachments.toString()}),
style: TextStyle(color: _unFocusColor, fontSize: 12),
)
],
@ -177,16 +187,22 @@ class _AttachmentListState extends State<AttachmentList> {
if (widget.isFullWidth && _attachments.length == 1) {
final element = _attachments.first;
double ratio = element!.metadata?['ratio']?.toDouble() ?? 16 / 9;
final isImage = element!.mimetype.split('/').firstOrNull == 'image';
double ratio =
element.metadata?['ratio']?.toDouble() ?? (isImage ? 1 : 16 / 9);
return Container(
width: MediaQuery.of(context).size.width,
constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth,
maxHeight: 640,
),
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.symmetric(
horizontal: BorderSide(
color: Theme.of(context).dividerColor,
@ -219,7 +235,10 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachments[idx];
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
@ -244,7 +263,9 @@ class _AttachmentListState extends State<AttachmentList> {
final element = _attachments[idx];
idx++;
if (element == null) return const SizedBox.shrink();
double ratio = element.metadata?['ratio']?.toDouble() ?? 16 / 9;
final isImage = element.mimetype.split('/').firstOrNull == 'image';
double ratio =
element.metadata?['ratio']?.toDouble() ?? (isImage ? 1 : 16 / 9);
return Container(
constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth,
@ -254,6 +275,10 @@ class _AttachmentListState extends State<AttachmentList> {
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
@ -271,31 +296,37 @@ class _AttachmentListState extends State<AttachmentList> {
);
}
return SizedBox(
width: math.min(MediaQuery.of(context).size.width, widget.columnMaxWidth),
child: CarouselSlider.builder(
options: CarouselOptions(
disableCenter: true,
animateToClosest: true,
aspectRatio: _aspectRatio,
enlargeCenterPage: true,
viewportFraction: widget.viewport ?? 0.95,
enableInfiniteScroll: false,
),
return Container(
constraints: BoxConstraints(
maxHeight: 320,
),
child: ListView.separated(
padding: widget.padding,
scrollDirection: Axis.horizontal,
shrinkWrap: true,
itemCount: _attachments.length,
itemBuilder: (context, idx, _) {
itemBuilder: (context, idx) {
final element = _attachments[idx];
if (element == null) const SizedBox.shrink();
double ratio = element!.metadata?['ratio']?.toDouble() ?? 16 / 9;
final isImage = element!.mimetype.split('/').firstOrNull == 'image';
double ratio =
element.metadata?['ratio']?.toDouble() ?? (isImage ? 1 : 16 / 9);
return Container(
constraints: BoxConstraints(
maxWidth: widget.columnMaxWidth,
maxHeight: 640,
maxWidth: math.min(
widget.columnMaxWidth,
MediaQuery.of(context).size.width -
(widget.padding?.horizontal ?? 0),
),
),
child: AspectRatio(
aspectRatio: ratio,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainer
.withOpacity(0.5),
border: Border.all(
color: Theme.of(context).dividerColor,
width: 1,
@ -310,6 +341,7 @@ class _AttachmentListState extends State<AttachmentList> {
),
);
},
separatorBuilder: (context, _) => const Gap(8),
),
);
}
@ -388,11 +420,13 @@ class AttachmentListEntry extends StatelessWidget {
},
),
if (item!.isMature && !showMature)
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 100, sigmaY: 100),
child: Container(
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
),
),
),
),

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
@ -34,9 +35,28 @@ class ChatEventList extends StatelessWidget {
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
Widget build(BuildContext context) {
return CustomScrollView(
cacheExtent: 100,
reverse: true,
slivers: [
Obx(() {
@ -64,50 +84,45 @@ class ChatEventList extends StatelessWidget {
final item = chatController.currentEvents[index].data;
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Builder(builder: (context) {
final widget = ChatEvent(
key: Key('m${item!.uuid}'),
item: item,
isMerged: isMerged,
chatController: chatController,
).paddingOnly(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
);
return TapRegion(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
child: Builder(builder: (context) {
final widget = ChatEvent(
key: Key('m${item!.uuid}'),
item: item,
isMerged: isMerged,
chatController: chatController,
).paddingOnly(
top: !isMerged ? 8 : 0,
bottom: !hasMerged ? 8 : 0,
);
if (noAnimated) {
return widget;
} else {
return widget
.animate(
key: Key('animated-m${item.uuid}'),
)
.slideY(
curve: Curves.fastLinearToSlowEaseIn,
duration: 250.ms,
begin: 0.5,
end: 0,
);
if (noAnimated) {
return widget;
} else {
return widget
.animate(
key: Key('animated-m${item.uuid}'),
)
.slideY(
curve: Curves.fastLinearToSlowEaseIn,
duration: 250.ms,
begin: 0.5,
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

@ -2,15 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_svg/svg.dart';
import 'package:get/get.dart';
import 'package:solian/models/link.dart';
import 'package:solian/providers/link_expander.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:url_launcher/url_launcher_string.dart';
class LinkExpansion extends StatelessWidget {
class LinkExpansion extends StatefulWidget {
final String content;
const LinkExpansion({super.key, required this.content});
@override
State<LinkExpansion> createState() => _LinkExpansionState();
}
class _LinkExpansionState extends State<LinkExpansion> {
Widget _buildImage(String url, {double? width, double? height}) {
if (url.endsWith('svg')) {
return SvgPicture.network(url, width: width, height: height);
@ -22,61 +28,74 @@ class LinkExpansion extends StatelessWidget {
);
}
@override
Widget build(BuildContext context) {
List<LinkMeta>? _meta;
Future<void> _doExpand() async {
final linkRegex = RegExp(
r'(?<!\()(?:(?:https?):\/\/|www\.)(?:[-_a-z0-9]+\.)*(?:[-a-z0-9]+\.[-a-z0-9]+)[^\s<]*[^\s<?!.,:*_~]',
);
final matches = linkRegex.allMatches(content);
if (matches.isEmpty) {
return const SizedBox.shrink();
}
final matches = linkRegex.allMatches(widget.content);
if (matches.isEmpty) return;
final LinkExpandProvider expandController = Get.find();
if (matches.isEmpty) return;
List<LinkMeta> out = List.empty(growable: true);
for (final x in matches) {
final result = await expandController.expandLink(x.group(0)!);
if (result != null) out.add(result);
}
setState(() => _meta = out);
}
@override
void initState() {
super.initState();
_doExpand();
}
@override
Widget build(BuildContext context) {
if (_meta?.isEmpty ?? true) return const SizedBox.shrink();
return Wrap(
children: matches.map((x) {
children: _meta!.map((x) {
return Container(
constraints: BoxConstraints(
maxWidth: matches.length == 1 ? 480 : 340,
maxWidth: _meta!.length == 1 ? 480 : 340,
),
child: FutureBuilder(
future: expandController.expandLink(x.group(0)!),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox.shrink();
}
child: Builder(
builder: (context) {
final isRichDescription = [
'solsynth.dev',
].contains(Uri.parse(snapshot.data!.url).host);
].contains(Uri.parse(x.url).host);
return GestureDetector(
child: Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if ([
(snapshot.data!.icon?.isNotEmpty ?? false),
snapshot.data!.siteName != null
].any((x) => x))
if ([(x.icon?.isNotEmpty ?? false), x.siteName != null]
.any((x) => x))
Row(
children: [
if (snapshot.data!.icon?.isNotEmpty ?? false)
if (x.icon?.isNotEmpty ?? false)
ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: _buildImage(
snapshot.data!.icon!,
x.icon!,
width: 32,
height: 32,
),
).paddingOnly(right: 8),
if (snapshot.data!.siteName != null)
if (x.siteName != null)
Expanded(
child: Text(
snapshot.data!.siteName!,
x.siteName!,
style: Theme.of(context).textTheme.labelLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
@ -84,32 +103,27 @@ class LinkExpansion extends StatelessWidget {
),
],
).paddingOnly(
bottom: (snapshot.data!.icon?.isNotEmpty ?? false)
? 8
: 4,
bottom: (x.icon?.isNotEmpty ?? false) ? 8 : 4,
),
if (snapshot.data!.image != null &&
(snapshot.data!.image?.startsWith('http') ?? false))
if (x.image != null &&
(x.image?.startsWith('http') ?? false))
ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: _buildImage(
snapshot.data!.image!,
),
child: _buildImage(x.image!),
).paddingOnly(bottom: 8),
Text(
snapshot.data!.title ?? 'No Title',
x.title ?? 'No Title',
maxLines: 1,
overflow: TextOverflow.fade,
style: Theme.of(context).textTheme.bodyLarge,
),
if (snapshot.data!.description != null &&
isRichDescription)
MarkdownBody(data: snapshot.data!.description!)
else if (snapshot.data!.description != null)
if (x.description != null && isRichDescription)
MarkdownBody(data: x.description!)
else if (x.description != null)
Text(
snapshot.data!.description!,
x.description!,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
@ -117,7 +131,7 @@ class LinkExpansion extends StatelessWidget {
).paddingAll(12),
),
onTap: () {
launchUrlString(x.group(0)!);
launchUrlString(x.url);
},
);
},

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:gap/gap.dart';
class LoadingIndicator extends StatefulWidget {
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
Widget build(BuildContext context) {
return SizeTransition(
sizeFactor: _animation,
axisAlignment: -1, // Align animation from the top
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
color: widget.backgroundColor ??
Theme.of(context).colorScheme.surfaceContainerLow.withOpacity(0.5),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.5),
),
const Gap(8),
Text('loading'.tr),
],
),
),
);
}
}

View File

@ -2,12 +2,13 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:path/path.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/widgets/attachments/attachment_item.dart';
import 'package:solian/widgets/attachments/attachment_list.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:syntax_highlight/syntax_highlight.dart';
@ -15,9 +16,10 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'account/account_profile_popup.dart';
class MarkdownTextContent extends StatelessWidget {
class MarkdownTextContent extends StatefulWidget {
final String content;
final String parentId;
final List<Attachment>? attachments;
final bool isSelectable;
final bool isLargeText;
final bool isAutoWarp;
@ -26,195 +28,221 @@ class MarkdownTextContent extends StatelessWidget {
super.key,
required this.content,
required this.parentId,
this.attachments,
this.isSelectable = false,
this.isLargeText = false,
this.isAutoWarp = false,
});
Widget _buildContent(BuildContext context) {
@override
State<MarkdownTextContent> createState() => _MarkdownTextContentState();
}
class _MarkdownTextContentState extends State<MarkdownTextContent> {
final List<int> _stickerSizes = [];
@override
initState() {
super.initState();
final stickerRegex = RegExp(r':([-\w]+):');
// Split the content into paragraphs
final paragraphs = content.split(RegExp(r'\n\s*\n'));
final paragraphs = widget.content.split(RegExp(r'\n\s*\n'));
// Iterate over each paragraph to process stickers individually
List<Widget> contentWidgets = [];
for (var idx = 0; idx < paragraphs.length; idx++) {
// Getting paragraph
var paragraph = paragraphs[idx];
// Matching stickers
final stickerMatch = stickerRegex.allMatches(paragraph);
final isOnlySticker =
paragraph.replaceAll(stickerRegex, '').trim().isEmpty;
contentWidgets.add(
Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: paragraph,
padding: EdgeInsets.zero,
styleSheet: MarkdownStyleSheet.fromTheme(
Theme.of(context),
).copyWith(
textScaler: TextScaler.linear(isLargeText ? 1.1 : 1),
blockquote: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
blockquoteDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).dividerColor,
),
),
),
codeblockDecoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 0.3,
),
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
)),
builders: {
'code': _MarkdownTextCodeElement(),
},
softLineBreak: true,
extensionSet: markdown.ExtensionSet(
<markdown.BlockSyntax>[
markdown.CodeBlockSyntax(),
...markdown.ExtensionSet.commonMark.blockSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
],
<markdown.InlineSyntax>[
if (isAutoWarp) markdown.LineBreakSyntax(),
_UserNameCardInlineSyntax(),
_CustomEmoteInlineSyntax(),
markdown.AutolinkSyntax(),
markdown.AutolinkExtensionSyntax(),
markdown.CodeSyntax(),
...markdown.ExtensionSet.commonMark.inlineSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
],
),
onTapLink: (text, href, title) async {
if (href == null) return;
if (href.startsWith('solink://')) {
final segments = href.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'users':
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: segments[1],
),
);
}
return;
}
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
imageBuilder: (uri, title, alt) {
var url = uri.toString();
double? width, height;
BoxFit? fit;
if (url.startsWith('solink://')) {
final segments = url.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'stickers':
double radius = 8;
final StickerProvider sticker = Get.find();
// Adjust sticker size based on the sticker count in this paragraph
if (stickerMatch.length <= 1 && isOnlySticker) {
width = 128;
height = 128;
} else if (stickerMatch.length <= 3 && isOnlySticker) {
width = 32;
height = 32;
} else {
radius = 4;
width = 16;
height = 16;
}
fit = BoxFit.contain;
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(radius)),
child: Container(
width: width,
height: height,
color: Theme.of(context).colorScheme.surfaceContainer,
child: FutureBuilder(
future: sticker.getStickerByAlias(segments[1]),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator());
}
return AutoCacheImage(
snapshot.data!.imageUrl,
width: width,
height: height,
fit: fit,
noErrorWidget: true,
);
},
),
),
).paddingSymmetric(vertical: 4);
case 'attachments':
const radius = BorderRadius.all(Radius.circular(8));
return LimitedBox(
maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect(
borderRadius: radius,
child: AttachmentSelfContainedEntry(
isDense: true,
parentId: parentId,
rid: segments[1],
),
),
).paddingSymmetric(vertical: 4);
}
}
return AutoCacheImage(
url,
width: width,
height: height,
fit: fit,
);
},
),
);
if (idx < paragraphs.length - 1) {
contentWidgets.add(isAutoWarp ? const Gap(4) : const Gap(8));
if (stickerMatch.length > 3) {
_stickerSizes.addAll(List.filled(stickerMatch.length, 16));
} else if (stickerMatch.length > 1) {
_stickerSizes.addAll(List.filled(stickerMatch.length, 32));
} else {
_stickerSizes.addAll(List.filled(stickerMatch.length, 128));
}
}
}
// Return the list of widgets for the paragraphs
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: contentWidgets,
Widget _buildContent(BuildContext context) {
var stickerIdx = 0;
return Markdown(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: widget.content,
padding: EdgeInsets.zero,
styleSheet: MarkdownStyleSheet.fromTheme(
Theme.of(context),
).copyWith(
textScaler: TextScaler.linear(widget.isLargeText ? 1.1 : 1),
blockquote: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
blockquoteDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
horizontalRuleDecoration: BoxDecoration(
border: Border(
top: BorderSide(
width: 1.0,
color: Theme.of(context).dividerColor,
),
),
),
codeblockDecoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).dividerColor,
width: 0.3,
),
borderRadius: const BorderRadius.all(Radius.circular(4)),
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
)),
builders: {
'code': _MarkdownTextCodeElement(),
},
softLineBreak: true,
extensionSet: markdown.ExtensionSet(
<markdown.BlockSyntax>[
markdown.CodeBlockSyntax(),
...markdown.ExtensionSet.commonMark.blockSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.blockSyntaxes,
],
<markdown.InlineSyntax>[
if (widget.isAutoWarp) markdown.LineBreakSyntax(),
_UserNameCardInlineSyntax(),
_CustomEmoteInlineSyntax(),
markdown.AutolinkSyntax(),
markdown.AutolinkExtensionSyntax(),
markdown.CodeSyntax(),
...markdown.ExtensionSet.commonMark.inlineSyntaxes,
...markdown.ExtensionSet.gitHubFlavored.inlineSyntaxes
],
),
onTapLink: (text, href, title) async {
if (href == null) return;
if (href.startsWith('solink://')) {
final segments = href.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'users':
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
name: segments[1],
),
);
}
return;
}
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
imageBuilder: (uri, title, alt) {
var url = uri.toString();
double? width, height;
BoxFit? fit;
if (url.startsWith('solink://')) {
final segments = url.replaceFirst('solink://', '').split('/');
switch (segments[0]) {
case 'stickers':
double radius = 4;
final StickerProvider sticker = Get.find();
// Adjust sticker size based on the sticker count in this paragraph
width =
_stickerSizes.elementAtOrNull(stickerIdx)?.toDouble() ?? 16;
height =
_stickerSizes.elementAtOrNull(stickerIdx)?.toDouble() ?? 16;
if (width > 16) {
radius = 8;
}
stickerIdx++;
fit = BoxFit.contain;
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(radius)),
child: Container(
width: width,
height: height,
color: Theme.of(context).colorScheme.surfaceContainer,
child: FutureBuilder(
future: sticker.getStickerByAlias(segments[1]),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
return AutoCacheImage(
snapshot.data!.imageUrl,
width: width,
height: height,
fit: fit,
noErrorWidget: true,
);
},
),
),
).paddingSymmetric(vertical: 4);
case 'attachments':
final match = widget.attachments
?.where((x) => x.rid == segments[1])
.firstOrNull;
const radius = BorderRadius.all(Radius.circular(8));
if (match != null) {
final isImage =
match.mimetype.split('/').firstOrNull == 'image';
double ratio = match.metadata?['ratio']?.toDouble() ??
(isImage ? 1 : 16 / 9);
return LimitedBox(
maxWidth: 480,
maxHeight: 640,
child: AspectRatio(
aspectRatio: ratio,
child: ClipRRect(
borderRadius: radius,
child: AttachmentItem(
parentId: widget.parentId,
item: match,
),
),
),
).paddingSymmetric(vertical: 4);
} else {
return LimitedBox(
maxHeight: MediaQuery.of(context).size.width,
child: ClipRRect(
borderRadius: radius,
child: AttachmentSelfContainedEntry(
isDense: true,
parentId: widget.parentId,
rid: segments[1],
),
),
).paddingSymmetric(vertical: 4);
}
}
}
return AutoCacheImage(
url,
width: width,
height: height,
fit: fit,
);
},
);
}
@override
Widget build(BuildContext context) {
if (isSelectable) {
if (widget.isSelectable) {
return SelectionArea(child: _buildContent(context));
}
return _buildContent(context);

View File

@ -1,17 +1,21 @@
import 'dart:math';
import 'package:file_saver/file_saver.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart';
import 'package:screenshot/screenshot.dart';
import 'package:share_plus/share_plus.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/post.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/router.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/reports/abuse_report.dart';
class PostAction extends StatefulWidget {
@ -25,20 +29,14 @@ class PostAction extends StatefulWidget {
}
class _PostActionState extends State<PostAction> {
bool _isBusy = true;
bool _isBusy = false;
bool _canModifyContent = false;
void _checkAbleToModifyContent() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
setState(() => _isBusy = true);
setState(() {
_canModifyContent =
auth.userProfile.value!['id'] == widget.item.author.id;
_isBusy = false;
});
_canModifyContent = auth.userProfile.value!['id'] == widget.item.author.id;
}
Future<void> _doShare({bool noUri = false}) async {
@ -69,7 +67,8 @@ class _PostActionState extends State<PostAction> {
'link': 'https://solsynth.dev/posts/$id',
}),
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,
);
@ -84,6 +83,78 @@ class _PostActionState extends State<PostAction> {
}
}
Future<void> _shareImage() async {
final List<String> attachments = widget.item.body['attachments'] is List
? List.from(widget.item.body['attachments']?.whereType<String>())
: List.empty();
final hasMultipleAttachment = attachments.length > 1;
setState(() => _isBusy = true);
final double width = hasMultipleAttachment ? 640 : 480;
final screenshot = ScreenshotController();
final image = await screenshot.captureFromLongWidget(
MediaQuery(
data: MediaQuery.of(context).copyWith(
size: Size(width, double.infinity),
),
child: PostShareImage(item: widget.item),
),
context: context,
pixelRatio: 2,
constraints: BoxConstraints(
minWidth: 480,
maxWidth: width,
minHeight: 640,
maxHeight: double.infinity,
),
);
final filename = 'share_post#${widget.item.id}';
if (PlatformInfo.isAndroid || PlatformInfo.isIOS) {
final box = context.findRenderObject() as RenderBox?;
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
void initState() {
super.initState();
@ -127,7 +198,13 @@ class _PostActionState extends State<PostAction> {
),
],
).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(
child: ListView(
children: [
@ -135,16 +212,30 @@ class _PostActionState extends State<PostAction> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.share),
title: Text('share'.tr),
trailing: PlatformInfo.isIOS || PlatformInfo.isAndroid
? IconButton(
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (PlatformInfo.isIOS || PlatformInfo.isAndroid)
IconButton(
icon: const Icon(Icons.link_off),
tooltip: 'shareNoUri'.tr,
onPressed: () async {
await _doShare(noUri: true);
Navigator.pop(context);
},
)
: null,
),
IconButton(
icon: const Icon(Icons.image),
tooltip: 'shareImage'.tr,
onPressed: _isBusy
? null
: () async {
await _shareImage();
Navigator.pop(context);
},
),
],
),
onTap: () async {
await _doShare();
Navigator.pop(context);
@ -221,15 +312,23 @@ class _PostActionState extends State<PostAction> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.edit),
title: Text('edit'.tr),
onTap: () async {
Navigator.pop(
context,
AppRouter.instance.pushNamed(
'postEditor',
extra: PostPublishArguments(edit: widget.item),
),
);
},
onTap: _isBusy
? null
: () async {
setState(() => _isBusy = true);
var item = 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)
ListTile(

View File

@ -1,6 +1,5 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:gap/gap.dart';
@ -8,6 +7,7 @@ import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/posts/post_detail.dart';
import 'package:solian/shells/title_shell.dart';
import 'package:solian/theme.dart';
@ -30,12 +30,15 @@ class PostItem extends StatefulWidget {
final bool isShowEmbed;
final bool isOverrideEmbedClickable;
final bool isFullDate;
final bool isFullContent;
final bool isContentSelectable;
final bool isNonScrollAttachment;
final bool showFeaturedReply;
final String? attachmentParent;
final Color? backgroundColor;
final EdgeInsets? padding;
final Function? onComment;
final Function? onTapMore;
const PostItem({
super.key,
@ -47,12 +50,13 @@ class PostItem extends StatefulWidget {
this.isShowEmbed = true,
this.isOverrideEmbedClickable = false,
this.isFullDate = false,
this.isFullContent = false,
this.isContentSelectable = false,
this.isNonScrollAttachment = false,
this.showFeaturedReply = false,
this.attachmentParent,
this.backgroundColor,
this.padding,
this.onComment,
this.onTapMore,
});
@override
@ -65,14 +69,20 @@ class _PostItemState extends State<PostItem> {
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
static final visibilityIcons = [
Icons.public,
Icons.group,
Icons.visibility,
Icons.visibility_off,
Icons.lock,
];
@override
void initState() {
item = widget.item;
super.initState();
}
double _contentHeight = 0;
@override
Widget build(BuildContext context) {
final List<String> attachments = item.body['attachments'] is List
@ -90,32 +100,26 @@ class _PostItemState extends State<PostItem> {
).paddingOnly(bottom: 8),
_PostHeaderWidget(
isCompact: widget.isCompact,
isFullDate: widget.isFullDate,
onTapMore: widget.onTapMore,
item: item,
).paddingSymmetric(horizontal: 12),
_PostHeaderDividerWidget(item: item).paddingSymmetric(horizontal: 12),
SizedContainer(
maxWidth: 640,
maxHeight: widget.isFullContent ? double.infinity : 80,
child: _MeasureSize(
onChange: (size) {
setState(() => _contentHeight = size.height);
},
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MarkdownTextContent(
parentId: 'p${item.id}',
content: item.body['content'],
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
),
).paddingOnly(
left: 12,
right: 12,
bottom: hasAttachment ? 4 : 0,
),
child: MarkdownTextContent(
parentId: 'p${item.id}',
content: item.body['content'],
attachments: item.preload?.attachments,
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
),
).paddingOnly(
left: 12,
right: 12,
bottom: hasAttachment ? 4 : 0,
),
if (_contentHeight >= 80 && !widget.isFullContent)
if (widget.item.body?['content_truncated'] == true)
Opacity(
opacity: 0.8,
child: InkWell(child: Text('readMore'.tr)),
@ -126,32 +130,20 @@ class _PostItemState extends State<PostItem> {
LinkExpansion(content: item.body['content']).paddingOnly(
left: 8,
right: 8,
top: 4,
),
_PostFooterWidget(item: item).paddingOnly(left: 12),
if (attachments.isNotEmpty)
Row(
children: [
Icon(
Icons.file_copy,
size: 15,
color: _unFocusColor,
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': attachments.length.toString()},
),
style: TextStyle(color: _unFocusColor),
)
],
).paddingOnly(left: 14, top: 4),
_PostAttachmentWidget(
item: item,
padding: widget.padding,
isCompact: true,
isNonScrollAttachment: widget.isNonScrollAttachment,
).paddingOnly(top: 4),
],
);
}
return OpenContainer(
tappable: widget.isClickable,
closedBuilder: (_, openContainer) => Column(
return GestureDetector(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_PostThumbnail(
@ -163,30 +155,22 @@ class _PostItemState extends State<PostItem> {
children: [
_PostHeaderWidget(
isCompact: widget.isCompact,
isFullDate: widget.isFullDate,
onTapMore: widget.onTapMore,
item: item,
),
_PostHeaderDividerWidget(item: item),
SizedContainer(
maxWidth: 640,
maxHeight: widget.isFullContent ? double.infinity : 320,
child: _MeasureSize(
onChange: (size) {
setState(() => _contentHeight = size.height);
},
child: SingleChildScrollView(
physics: const NeverScrollableScrollPhysics(),
child: MarkdownTextContent(
parentId: 'p${item.id}-embed',
content: item.body['content'],
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
isLargeText:
item.type == 'article' && widget.isFullContent,
),
),
child: MarkdownTextContent(
parentId: 'p${item.id}-embed',
content: item.body['content'],
attachments: item.preload?.attachments,
isAutoWarp: item.type == 'story',
isSelectable: widget.isContentSelectable,
),
),
if (_contentHeight >= 320 && !widget.isFullContent)
if (widget.item.body?['content_truncated'] == true)
Opacity(
opacity: 0.8,
child: InkWell(child: Text('readMore'.tr)),
@ -220,18 +204,22 @@ class _PostItemState extends State<PostItem> {
),
),
_PostFooterWidget(item: item),
LinkExpansion(content: item.body['content']).paddingOnly(top: 4),
LinkExpansion(content: item.body['content']),
],
).paddingOnly(
right: 16,
left: 16,
).paddingSymmetric(
horizontal: (widget.padding?.horizontal ?? 0) + 16,
),
if (hasAttachment) const Gap(8),
_PostAttachmentWidget(
item: item,
padding: widget.padding,
isCompact: item.type == 'article',
isNonScrollAttachment: widget.isNonScrollAttachment,
),
_PostAttachmentWidget(item: item),
if (widget.showFeaturedReply)
_PostFeaturedReplyWidget(item: item).paddingSymmetric(
horizontal: 12,
horizontal: (widget.padding?.horizontal ?? 0) + 12,
),
if (widget.showFeaturedReply) const Gap(8),
if (widget.isShowReply || widget.isReactable)
PostQuickAction(
isShowReply: widget.isShowReply,
@ -249,22 +237,24 @@ class _PostItemState extends State<PostItem> {
}
},
).paddingOnly(
left: 14,
right: 14,
top: 8,
left: (widget.padding?.left ?? 0) + 14,
right: (widget.padding?.right ?? 0) + 14,
)
],
).paddingOnly(
top: widget.padding?.top ?? 0,
bottom: widget.padding?.bottom ?? 0,
),
openBuilder: (_, __) => TitleShell(
title: 'postDetail'.tr,
child: PostDetailScreen(
id: item.id.toString(),
post: item,
),
),
closedElevation: 0,
openElevation: 0,
closedColor: Colors.transparent,
openColor: Theme.of(context).colorScheme.surface,
onTap: () {
if (widget.isClickable) {
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {'id': item.id.toString()},
extra: item,
);
}
},
);
}
}
@ -293,6 +283,7 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
}
return Container(
padding: EdgeInsets.only(top: 8),
constraints: const BoxConstraints(maxWidth: 480),
child: Card(
margin: EdgeInsets.zero,
@ -389,8 +380,16 @@ class _PostFeaturedReplyWidget extends StatelessWidget {
class _PostAttachmentWidget extends StatelessWidget {
final Post item;
final EdgeInsets? padding;
final bool isNonScrollAttachment;
final bool isCompact;
const _PostAttachmentWidget({required this.item});
const _PostAttachmentWidget({
required this.item,
required this.padding,
required this.isNonScrollAttachment,
this.isCompact = false,
});
@override
Widget build(BuildContext context) {
@ -400,16 +399,40 @@ class _PostAttachmentWidget extends StatelessWidget {
? List.from(item.body['attachments']?.whereType<String>())
: List.empty();
final unFocusColor =
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
if (attachments.isEmpty) return const SizedBox.shrink();
if (attachments.length == 1) {
if (isCompact) {
return Row(
children: [
Icon(
Icons.file_copy,
size: 13,
color: unFocusColor,
).paddingOnly(right: 5),
Text(
'attachmentHint'.trParams(
{'count': attachments.length.toString()},
),
style: TextStyle(color: unFocusColor, fontSize: 13),
)
],
).paddingOnly(
left: (padding?.left ?? 0) + 17,
right: (padding?.right ?? 0) + 17,
);
}
if (attachments.length == 1 && !isLargeScreen) {
return AttachmentList(
parentId: item.id.toString(),
attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
autoload: false,
isFullWidth: true,
).paddingOnly(top: 4);
);
} else if (attachments.length > 1 &&
attachments.length % 3 == 0 &&
!isLargeScreen) {
@ -419,14 +442,31 @@ class _PostAttachmentWidget extends StatelessWidget {
attachments: item.preload?.attachments,
autoload: false,
isGrid: true,
).paddingSymmetric(horizontal: 14, vertical: 8);
} else {
).paddingOnly(
left: (padding?.left ?? 0) + 14,
right: (padding?.right ?? 0) + 14,
);
} else if (attachments.length == 1 || isNonScrollAttachment) {
return AttachmentList(
parentId: item.id.toString(),
attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
autoload: false,
).paddingOnly(bottom: 8, top: 4);
isColumn: true,
).paddingOnly(
left: (padding?.left ?? 0) + 14,
right: (padding?.right ?? 0) + 14,
);
} else {
return AttachmentList(
parentId: item.id.toString(),
attachmentIds: item.preload == null ? attachments : null,
attachments: item.preload?.attachments,
padding: EdgeInsets.symmetric(
horizontal: (padding?.horizontal ?? 0) + 14,
),
autoload: false,
);
}
}
}
@ -512,7 +552,7 @@ class _PostHeaderDividerWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (item.body['description'] != null || item.body['title'] != null) {
return const Gap(8);
return const SizedBox(height: 8);
}
return const SizedBox.shrink();
}
@ -568,18 +608,23 @@ class _PostFooterWidget extends StatelessWidget {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
).paddingOnly(top: 4);
).paddingSymmetric(vertical: 4);
}
}
}
class _PostHeaderWidget extends StatelessWidget {
final bool isCompact;
final bool isFullDate;
final Post item;
final Function? onTapMore;
const _PostHeaderWidget({
required this.isCompact,
required this.isFullDate,
required this.item,
required this.onTapMore,
});
@override
@ -611,18 +656,43 @@ class _PostHeaderWidget extends StatelessWidget {
if (isCompact)
RelativeDate(
item.publishedAt?.toLocal() ?? DateTime.now(),
isFull: isFullDate,
).paddingOnly(top: 1),
],
),
if (!isCompact)
RelativeDate(item.publishedAt?.toLocal() ?? DateTime.now()),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
RelativeDate(
item.publishedAt?.toLocal() ?? DateTime.now(),
isFull: isFullDate,
),
const Gap(4),
Icon(
_PostItemState.visibilityIcons[item.visibility],
size: 16,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.75),
),
],
),
],
),
),
if (item.type == 'article')
Badge(
label: Text('article'.tr),
).paddingOnly(top: 3),
if (onTapMore != null)
IconButton(
color: Theme.of(context).colorScheme.primary,
icon: const Icon(Icons.more_vert),
padding: const EdgeInsets.symmetric(horizontal: 4),
visualDensity: const VisualDensity(
horizontal: -4,
vertical: -2,
),
onPressed: () => onTapMore!(),
),
],
),
const Gap(8),
@ -666,45 +736,3 @@ class _PostThumbnail extends StatelessWidget {
);
}
}
typedef _OnWidgetSizeChange = void Function(Size size);
class _MeasureSizeRenderObject extends RenderProxyBox {
Size? oldSize;
_OnWidgetSizeChange onChange;
_MeasureSizeRenderObject(this.onChange);
@override
void performLayout() {
super.performLayout();
Size newSize = child!.size;
if (oldSize == newSize) return;
oldSize = newSize;
WidgetsBinding.instance.addPostFrameCallback((_) {
onChange(newSize);
});
}
}
class _MeasureSize extends SingleChildRenderObjectWidget {
final _OnWidgetSizeChange onChange;
const _MeasureSize({
required this.onChange,
required Widget super.child,
});
@override
RenderObject createRenderObject(BuildContext context) {
return _MeasureSizeRenderObject(onChange);
}
@override
void updateRenderObject(
BuildContext context, covariant _MeasureSizeRenderObject renderObject) {
renderObject.onChange = onChange;
}
}

View File

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
@ -41,7 +42,6 @@ class PostListWidget extends StatelessWidget {
isClickable: isClickable,
showFeaturedReply: true,
item: item,
backgroundColor: backgroundColor,
onUpdate: () {
controller.refresh();
},
@ -60,8 +60,8 @@ class PostListEntryWidget extends StatelessWidget {
final bool isClickable;
final bool showFeaturedReply;
final Post item;
final EdgeInsets? padding;
final Function onUpdate;
final Color? backgroundColor;
const PostListEntryWidget({
super.key,
@ -70,54 +70,64 @@ class PostListEntryWidget extends StatelessWidget {
required this.isClickable,
required this.showFeaturedReply,
required this.item,
this.padding,
required this.onUpdate,
this.backgroundColor,
});
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
Widget build(BuildContext context) {
return GestureDetector(
child: PostItem(
key: Key('p${item.id}'),
item: item,
isShowEmbed: isShowEmbed,
isClickable: isNestedClickable,
showFeaturedReply: showFeaturedReply,
backgroundColor: backgroundColor,
onComment: () {
AppRouter.instance
.pushNamed(
'postEditor',
extra: PostPublishArguments(reply: item),
)
.then((value) {
if (value is Future) {
value.then((_) {
return TapRegion(
child: GestureDetector(
onLongPress: () => _openActions(context),
child: PostItem(
key: Key('p${item.id}'),
item: item,
isShowEmbed: isShowEmbed,
isClickable: isNestedClickable,
showFeaturedReply: showFeaturedReply,
padding: padding,
onTapMore: () => _openActions(context),
onComment: () {
AppRouter.instance
.pushNamed(
'postEditor',
extra: PostPublishArguments(reply: item),
)
.then((value) {
if (value is Future) {
value.then((_) {
onUpdate();
});
} else if (value != null) {
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);
}
},
);
}
@ -129,6 +139,7 @@ class ControlledPostListWidget extends StatelessWidget {
final bool isNestedClickable;
final bool isPinned;
final PagingController<int, Post> controller;
final EdgeInsets? padding;
final Function? onUpdate;
const ControlledPostListWidget({
@ -138,6 +149,7 @@ class ControlledPostListWidget extends StatelessWidget {
this.isClickable = true,
this.isNestedClickable = true,
this.isPinned = true,
this.padding,
this.onUpdate,
});
@ -156,6 +168,7 @@ class ControlledPostListWidget extends StatelessWidget {
isNestedClickable: isNestedClickable,
isClickable: isClickable,
showFeaturedReply: true,
padding: padding,
item: item,
onUpdate: onUpdate ?? () {},
);

View File

@ -1,43 +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,
isFullContent: isFullContent,
backgroundColor: backgroundColor,
).paddingSymmetric(vertical: 8),
],
),
onTap: () => onTap(),
),
);
}
}

View File

@ -8,11 +8,13 @@ import 'package:solian/widgets/posts/post_list.dart';
class PostReplyList extends StatefulWidget {
final Post item;
final EdgeInsets? padding;
final Color? backgroundColor;
const PostReplyList({
super.key,
required this.item,
this.padding,
this.backgroundColor,
});
@ -53,7 +55,7 @@ class _PostReplyListState extends State<PostReplyList> {
@override
Widget build(BuildContext context) {
return PostListWidget(
padding: EdgeInsets.symmetric(horizontal: 10),
padding: widget.padding,
isShowEmbed: false,
controller: _pagingController,
backgroundColor: widget.backgroundColor,
@ -93,6 +95,7 @@ class PostReplyListPopup extends StatelessWidget {
slivers: [
PostReplyList(
item: item,
padding: EdgeInsets.symmetric(horizontal: 10),
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerLow,
),

View File

@ -0,0 +1,103 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/root_container.dart';
class PostShareImage extends StatelessWidget {
final Post item;
const PostShareImage({super.key, required this.item});
@override
Widget build(BuildContext context) {
final textColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.3);
return RootContainer(
child: Wrap(
alignment: WrapAlignment.spaceBetween,
runAlignment: WrapAlignment.center,
children: [
const SizedBox(height: 40),
Material(
color: Colors.transparent,
child: Card(
margin: EdgeInsets.zero,
child: PostItem(
item: item,
isShowEmbed: true,
isClickable: false,
showFeaturedReply: false,
isReactable: false,
isShowReply: false,
isNonScrollAttachment: true,
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 16,
),
onComment: () {},
),
),
).paddingOnly(bottom: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset(
'assets/logo.png',
width: 48,
height: 48,
),
),
const Gap(16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'shareImageFooter'.tr,
style: TextStyle(
fontSize: 13,
color: textColor,
),
),
Text(
'Solsynth LLC © ${DateTime.now().year}',
style: TextStyle(
fontSize: 11,
color: textColor,
),
),
],
),
],
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Material(
color: Theme.of(context).colorScheme.surface,
child: QrImageView(
data: 'https://solsynth.dev/posts/${item.id}',
version: QrVersions.auto,
padding: const EdgeInsets.all(4),
size: 48,
dataModuleStyle: QrDataModuleStyle(
color: Theme.of(context).colorScheme.onSurface,
),
eyeStyle: QrEyeStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
),
),
],
),
],
).paddingSymmetric(horizontal: 36, vertical: 24),
);
}
}

View File

@ -25,7 +25,6 @@ class PostSingleDisplay extends StatelessWidget {
isNestedClickable: true,
showFeaturedReply: true,
onUpdate: onUpdate,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerLow,
),
),
),

View File

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

View File

@ -4,20 +4,25 @@ import 'package:timeago/timeago.dart';
class RelativeDate extends StatelessWidget {
final DateTime date;
final TextStyle? style;
final bool isFull;
const RelativeDate(this.date, {super.key, this.isFull = false});
const RelativeDate(this.date, {super.key, this.style, this.isFull = false});
@override
Widget build(BuildContext context) {
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(
format(
date,
locale: 'en_short',
),
style: style,
);
}
}

View File

@ -7,9 +7,11 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_saver/file_saver_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_udid/flutter_udid_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
#include <media_kit_video/media_kit_video_plugin.h>
@ -21,6 +23,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
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 =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
@ -30,6 +35,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_udid_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterUdidPlugin");
flutter_udid_plugin_register_with_registrar(flutter_udid_registrar);
g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);

View File

@ -4,9 +4,11 @@
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
file_saver
file_selector_linux
flutter_acrylic
flutter_secure_storage_linux
flutter_udid
flutter_webrtc
media_kit_libs_linux
media_kit_video

View File

@ -8,6 +8,7 @@ import Foundation
import connectivity_plus
import desktop_drop
import device_info_plus
import file_saver
import file_selector_macos
import firebase_analytics
import firebase_core
@ -15,6 +16,7 @@ import firebase_crashlytics
import firebase_messaging
import flutter_local_notifications
import flutter_secure_storage_macos
import flutter_udid
import flutter_webrtc
import gal
import in_app_review
@ -38,6 +40,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSaverPlugin.register(with: registry.registrar(forPlugin: "FileSaverPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin"))
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
@ -45,6 +48,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterUdidPlugin.register(with: registry.registrar(forPlugin: "FlutterUdidPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin"))

View File

@ -6,6 +6,8 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_saver (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
- FlutterMacOS
- Firebase/Analytics (11.2.0):
@ -101,6 +103,9 @@ PODS:
- FlutterMacOS
- flutter_secure_storage_macos (6.1.1):
- FlutterMacOS
- flutter_udid (0.0.1):
- FlutterMacOS
- SAMKeychain
- flutter_webrtc (0.11.3):
- FlutterMacOS
- WebRTC-SDK (= 125.6422.04)
@ -188,6 +193,7 @@ PODS:
- PromisesObjC (= 2.4.0)
- protocol_handler_macos (0.0.1):
- FlutterMacOS
- SAMKeychain (1.5.3)
- screen_brightness_macos (0.1.0):
- FlutterMacOS
- share_plus (0.0.1):
@ -226,6 +232,7 @@ DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`)
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/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`)
- firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`)
- firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`)
@ -233,6 +240,7 @@ DEPENDENCIES:
- firebase_messaging (from `Flutter/ephemeral/.symlinks/plugins/firebase_messaging/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_udid (from `Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos`)
- flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- gal (from `Flutter/ephemeral/.symlinks/plugins/gal/darwin`)
@ -272,6 +280,7 @@ SPEC REPOS:
- nanopb
- PromisesObjC
- PromisesSwift
- SAMKeychain
- sqlite3
- WebRTC-SDK
@ -282,6 +291,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_saver:
:path: Flutter/ephemeral/.symlinks/plugins/file_saver/macos
file_selector_macos:
:path: Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos
firebase_analytics:
@ -296,6 +307,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
flutter_udid:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_udid/macos
flutter_webrtc:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos
FlutterMacOS:
@ -338,9 +351,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
connectivity_plus: 4c41c08fc6d7c91f63bc7aec70ffe3730b04f563
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: f1aae8670672f75c4c8850ecbe0b2ddef62b0a22
device_info_plus: 74e614483d05c89290d30a4c8feae15d555f7427
file_saver: 44e6fbf666677faf097302460e214e977fdd977b
file_selector_macos: cc3858c981fe6889f364731200d6232dac1d812d
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
firebase_analytics: 30ff72f6d4847ff0b479d8edd92fc8582e719072
@ -358,6 +372,7 @@ SPEC CHECKSUMS:
FirebaseSessions: 655ff17f3cc1a635cbdc2d69b953878001f9e25b
flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4
flutter_secure_storage_macos: 59459653abe1adb92abbc8ea747d79f8d19866c9
flutter_udid: 6b2b89780c3dfeecf0047bdf93f622d6416b1c07
flutter_webrtc: 2b4e4a2de70a1485836e40fd71a7a94c77d49bd9
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
gal: 61e868295d28fe67ffa297fae6dacebf56fd53e1
@ -371,14 +386,15 @@ SPEC CHECKSUMS:
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
package_info_plus: d2f71247aab4b6521434f887276093acc70d214c
package_info_plus: f5790acc797bf17c3e959e9d6cf162cc68ff7523
pasteboard: 9b69dba6fedbb04866be632205d532fe2f6b1d99
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_macos: d10a6c01d6373389ffd2278013ab4c47ed6d6daa
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda
share_plus: a182a58e04e51647c0481aadabbc4de44b3a2bce
share_plus: fd717ef89a2801d3491e737630112b80c310640e
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: a553b1fd6fe66f53bbb0fe5b4f5bab93f08d7a13
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb

View File

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

View File

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

View File

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

View File

@ -74,10 +74,10 @@ packages:
dependency: transitive
description:
name: args
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6
url: "https://pub.dev"
source: hosted
version: "2.5.0"
version: "2.6.0"
async:
dependency: "direct main"
description:
@ -198,14 +198,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.1"
carousel_slider:
dependency: "direct main"
description:
name: carousel_slider
sha256: "7b006ec356205054af5beaef62e2221160ea36b90fb70a35e4deacd49d0349ae"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
characters:
dependency: transitive
description:
@ -262,14 +254,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.18.0"
confetti:
dependency: "direct main"
description:
name: confetti
sha256: "79376a99648efbc3f23582f5784ced0fe239922bd1a0fb41f582051eba750751"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: "2056db5241f96cdc0126bd94459fc4cdc13876753768fc7a31c425e50a7177d0"
sha256: "876849631b0c7dc20f8b471a2a03142841b482438e3b707955464f5ffca3e4c3"
url: "https://pub.dev"
source: hosted
version: "6.0.5"
version: "6.1.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
@ -282,10 +282,10 @@ packages:
dependency: transitive
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.2"
cross_file:
dependency: "direct main"
description:
@ -298,10 +298,10 @@ packages:
dependency: "direct main"
description:
name: crypto
sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.6"
csslib:
dependency: transitive
description:
@ -346,10 +346,10 @@ packages:
dependency: "direct main"
description:
name: device_info_plus
sha256: db03b2d2a3fa466a4627709e1db58692c3f7f658e36a5942d342d86efedc4091
sha256: c4af09051b4f0508f6c1dc0a5c085bf014d5c9a4a0678ce1799c2b4d716387a0
url: "https://pub.dev"
source: hosted
version: "11.0.0"
version: "11.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
@ -386,26 +386,26 @@ packages:
dependency: "direct main"
description:
name: drift
sha256: d6ff1ec6a0f3fa097dda6b776cf601f1f3d88b53b287288e09c1306f394fb1b3
sha256: df027d168a2985a2e9da900adeba2ab0136f0d84436592cf3cd5135f82c8579c
url: "https://pub.dev"
source: hosted
version: "2.20.3"
version: "2.21.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
sha256: "3ee987578ca2281b5ff91eadd757cd6dd36001458d6e33784f990d67ff38f756"
sha256: "27bab15e7869b69259663590381180117873b9b273a1ea9ebb21bb73133d1233"
url: "https://pub.dev"
source: hosted
version: "2.20.3"
version: "2.21.0"
drift_flutter:
dependency: "direct main"
description:
name: drift_flutter
sha256: c670c947fe17ad149678a43fdbbfdb69321f0c83d315043e34e8ad2729e11f49
sha256: fec503e9d408f36bb345f9f6d24bc9d62b7b5f970db49760253d9e8d3acd48d5
url: "https://pub.dev"
source: hosted
version: "0.2.0"
version: "0.2.1"
dropdown_button2:
dependency: "direct main"
description:
@ -422,6 +422,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -462,6 +470,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -610,10 +626,10 @@ packages:
dependency: transitive
description:
name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
@ -791,10 +807,10 @@ packages:
dependency: "direct main"
description:
name: flutter_markdown
sha256: e17575ca576a34b46c58c91f9948891117a1bd97815d2e661813c7f90c647a78
sha256: bd9c475d9aae256369edacafc29d1e74c81f78a10cdcdacbbbc9e3c43d009e4a
url: "https://pub.dev"
source: hosted
version: "0.7.3+2"
version: "0.7.4"
flutter_native_splash:
dependency: "direct dev"
description:
@ -811,6 +827,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.23"
flutter_resizable_container:
dependency: "direct main"
description:
name: flutter_resizable_container
sha256: "5b15c79c6cc338ed79640c706bb5176baa3333d92fd3627ad279aa3e25d2f0e7"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
@ -896,6 +920,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.2.0"
flutter_udid:
dependency: "direct main"
description:
name: flutter_udid
sha256: "63384bd96203aaefccfd7137fab642edda18afede12b0e9e1a2c96fe2589fd07"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_web_plugins:
dependency: "direct main"
description: flutter
@ -1025,10 +1057,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "2237616a36c0d69aef7549ab439b833fb7f9fb9fc861af2cc9ac3eedddd69ca8"
sha256: f31d52537dc417fdcde36088fdf11d191026fd5e4fae742491ebd40e5a8bea7d
url: "https://pub.dev"
source: hosted
version: "4.2.0"
version: "4.3.0"
image_cropper:
dependency: "direct main"
description:
@ -1065,26 +1097,26 @@ packages:
dependency: transitive
description:
name: image_picker_android
sha256: d3e5e00fdfeca8fd4ffb3227001264d449cc8950414c2ff70b0e06b9c628e643
sha256: d34e0d9e024e81321b2aeed7b202ec6181cc282e6a1c0c0b4e6ad07ef1065d82
url: "https://pub.dev"
source: hosted
version: "0.8.12+15"
version: "0.8.12+16"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "65d94623e15372c5c51bebbcb820848d7bcb323836e12dfdba60b5d3a8b39e50"
sha256: "717eb042ab08c40767684327be06a5d8dbb341fe791d514e4b92c7bbe1b7bb83"
url: "https://pub.dev"
source: hosted
version: "3.0.5"
version: "3.0.6"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: "6703696ad49f5c3c8356d576d7ace84d1faf459afb07accbb0fae780753ff447"
sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b"
url: "https://pub.dev"
source: hosted
version: "0.8.12"
version: "0.8.12+1"
image_picker_linux:
dependency: transitive
description:
@ -1225,10 +1257,10 @@ packages:
dependency: transitive
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "1.3.0"
macos_window_utils:
dependency: transitive
description:
@ -1261,6 +1293,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
marquee:
dependency: "direct main"
description:
name: marquee
sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862
url: "https://pub.dev"
source: hosted
version: "2.3.0"
matcher:
dependency: transitive
description:
@ -1361,10 +1401,10 @@ packages:
dependency: transitive
description:
name: mime
sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a"
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "1.0.6"
version: "2.0.0"
nested:
dependency: transitive
description:
@ -1401,10 +1441,10 @@ packages:
dependency: "direct main"
description:
name: package_info_plus
sha256: "894f37107424311bdae3e476552229476777b8752c5a2a2369c0cb9a2d5442ef"
sha256: df3eb3e0aed5c1107bb0fdb80a8e82e778114958b1c5ac5644fb1ac9cae8a998
url: "https://pub.dev"
source: hosted
version: "8.0.3"
version: "8.1.0"
package_info_plus_platform_interface:
dependency: transitive
description:
@ -1497,10 +1537,10 @@ packages:
dependency: transitive
description:
name: permission_handler_android
sha256: "76e4ab092c1b240d31177bb64d2b0bea43f43d0e23541ec866151b9f7b2490fa"
sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1"
url: "https://pub.dev"
source: hosted
version: "12.0.12"
version: "12.0.13"
permission_handler_apple:
dependency: transitive
description:
@ -1545,10 +1585,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
version: "3.1.6"
platform_detect:
dependency: transitive
description:
@ -1685,6 +1725,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
recase:
dependency: transitive
description:
@ -1757,6 +1813,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.3"
screenshot:
dependency: "direct main"
description:
name: screenshot
sha256: "63817697a7835e6ce82add4228e15d233b74d42975c143ad8cfe07009fab866b"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sdp_transform:
dependency: transitive
description:
@ -1769,10 +1833,10 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: fec12c3c39f01e4df1ec6ad92b6e85503c5ca64ffd6e28d18c9ffe53fcc4cb11
sha256: "334fcdf0ef9c0df0e3b428faebcac9568f35c747d59831474b2fc56e156d244e"
url: "https://pub.dev"
source: hosted
version: "10.0.3"
version: "10.1.0"
share_plus_platform_interface:
dependency: transitive
description:
@ -1958,10 +2022,10 @@ packages:
dependency: transitive
description:
name: sqlparser
sha256: "852cf80f9e974ac8e1b613758a8aa640215f7701352b66a7f468e95711eb570b"
sha256: c5f63dff8677407ddcddfa4744c176ea6dc44286c47ba9e69e76d8071398034d
url: "https://pub.dev"
source: hosted
version: "0.38.1"
version: "0.39.1"
stack_trace:
dependency: transitive
description:
@ -2034,6 +2098,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.7.0"
timeline_tile:
dependency: "direct main"
description:
name: timeline_tile
sha256: "85ec2023c67137397c2812e3e848b2fb20b410b67cd9aff304bb5480c376fc0c"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
timezone:
dependency: transitive
description:
@ -2054,10 +2126,10 @@ packages:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.3.2"
version: "1.4.0"
universal_io:
dependency: transitive
description:
@ -2142,10 +2214,10 @@ packages:
dependency: transitive
description:
name: url_launcher_windows
sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185"
sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
version: "3.1.3"
uuid:
dependency: "direct main"
description:
@ -2278,10 +2350,10 @@ packages:
dependency: transitive
description:
name: win32
sha256: "4d45dc9069dba4619dc0ebd93c7cec5e66d8482cb625a370ac806dcc8165f2ec"
sha256: "2294c64768987ea280b43a3d8357d42d5679f3e2b5b69b602be45b2abbd165b0"
url: "https://pub.dev"
source: hosted
version: "5.5.5"
version: "5.6.1"
win32_registry:
dependency: transitive
description:

View File

@ -2,7 +2,7 @@ name: solian
description: "The Solar Network App"
publish_to: "none"
version: 1.3.7+8
version: 1.4.0+17
environment:
sdk: ">=3.3.4 <4.0.0"
@ -18,7 +18,6 @@ dependencies:
flutter_markdown: ^0.7.1
flutter_animate: ^4.5.0
flutter_secure_storage: ^9.2.1
carousel_slider: ^5.0.0
url_launcher: ^6.2.6
infinite_scroll_pagination: ^4.0.0
image_picker: ^1.1.1
@ -84,6 +83,14 @@ dependencies:
action_slider: ^0.7.0
in_app_review: ^2.0.9
syntax_highlight: ^0.4.0
flutter_udid: ^3.0.0
timeline_tile: ^2.0.0
screenshot: ^3.0.0
qr_flutter: ^4.1.0
flutter_resizable_container: ^3.0.0
file_saver: ^0.2.14
marquee: ^2.3.0
confetti: ^0.8.0
dev_dependencies:
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">
</head>
<body>
<body oncontextmenu="return false;">
<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/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="">
</picture>
<script src="flutter_bootstrap.js" async=""></script>
<script src="flutter_bootstrap.js" async=""></script>
</body></html>

View File

@ -8,10 +8,12 @@
#include <connectivity_plus/connectivity_plus_windows_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 <firebase_core/firebase_core_plugin_c_api.h>
#include <flutter_acrylic/flutter_acrylic_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_udid/flutter_udid_plugin_c_api.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
#include <gal/gal_plugin_c_api.h>
#include <livekit_client/live_kit_plugin.h>
@ -30,6 +32,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DesktopDropPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DesktopDropPlugin"));
FileSaverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSaverPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FirebaseCorePluginCApiRegisterWithRegistrar(
@ -38,6 +42,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("FlutterAcrylicPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterUdidPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterUdidPluginCApi"));
FlutterWebRTCPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
GalPluginCApiRegisterWithRegistrar(

View File

@ -5,10 +5,12 @@
list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
desktop_drop
file_saver
file_selector_windows
firebase_core
flutter_acrylic
flutter_secure_storage_windows
flutter_udid
flutter_webrtc
gal
livekit_client