Compare commits

..

30 Commits

Author SHA1 Message Date
e4b741ff0c 🚀 Launch 1.2.1+40 2024-09-17 16:02:13 +08:00
e69abb7f9d Notification preferences 2024-09-17 15:59:17 +08:00
565a8e41cc Realm avatar, banner 2024-09-17 14:21:37 +08:00
c9fbe47337 Channel isPublic and isCommunity 2024-09-17 13:50:04 +08:00
01db63e297 🐛 Fix compability on iOS 18 and macOS 15 2024-09-17 13:39:08 +08:00
d87e67bd17 Subscriptions 2024-09-17 02:14:23 +08:00
06aa1fb359 🐛 Fix post last read at 2024-09-17 01:23:49 +08:00
62733bf29f 💄 Optimize featured reply style 2024-09-16 23:39:15 +08:00
ce16de9c71 Featured replies on post 2024-09-16 23:35:44 +08:00
47eb6cbc66 Chat list will also show wild group channel 2024-09-16 21:09:19 +08:00
029e72fb0b Improve sticker loading 2024-09-16 21:00:19 +08:00
152efd97a0 💄 Unified design of single attachment uploader 2024-09-16 20:33:34 +08:00
ad1dc064e6 🚀 Launch 1.2.1+39 2024-09-16 20:15:36 +08:00
675b5dea5d 💫 Optimize region animations 2024-09-16 20:06:15 +08:00
5941cb9fd5 🐛 Fix messages loading 2024-09-16 19:50:49 +08:00
e11bf204af 🐛 Fix web login error by the cors issue 2024-09-16 18:12:30 +08:00
8a2d94cedf 🚀 Launch 1.2.1+38 2024-09-16 12:04:21 +08:00
780f7c22bc 💄 Better user agent 2024-09-16 11:57:16 +08:00
c18ce88993 Brand new sign in flow 2024-09-16 02:37:20 +08:00
73456fcff6 ♻️ Full screen signin and signup 2024-09-15 23:32:15 +08:00
8e8be52658 🐛 Fix web uploading 2024-09-15 22:52:20 +08:00
df22b65777 💄 Fix style issue 2024-09-15 18:31:04 +08:00
1437414b7f Improve chat loading speed 2024-09-15 18:25:04 +08:00
c1ff317c66 🚑 Able to use database on web 2024-09-15 18:02:27 +08:00
f3375070a0 🚀 Launch 1.2.1+37 2024-09-15 17:46:48 +08:00
204df3306e 🐛 Fix notification services 2024-09-15 17:19:55 +08:00
aeaade9590 🐛 Fix unauthorized things 2024-09-15 16:54:07 +08:00
306ce9e2b4 Optimize notification background service 2024-09-15 16:02:56 +08:00
a487924300 Android background notification service 2024-09-15 15:55:14 +08:00
ad66c11593 ♻️ Implement delete (recreate) local database 2024-09-15 12:25:50 +08:00
133 changed files with 16315 additions and 1205 deletions

View File

@@ -3,6 +3,7 @@
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -84,6 +85,11 @@
android:screenOrientation="portrait"
android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
android:foregroundServiceType="remoteMessaging"
/>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>

View File

@@ -3,6 +3,7 @@
"hide": "Hide",
"okay": "Okay",
"next": "Next",
"prev": "Previous",
"reset": "Reset",
"page": "Page",
"home": "Home",
@@ -54,6 +55,8 @@
"edit": "Edit",
"delete": "Delete",
"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.",
"search": "Search",
"post": "Post",
"article": "Article",
@@ -68,8 +71,12 @@
"forgotPassword": "Forgot password",
"email": "Email",
"username": "Username",
"usernameInputHint": "Also supports email and phone number",
"nickname": "Nickname",
"password": "Password",
"passwordOneTime": "One-time-password",
"passwordInputHint": "Forgot your password? Go back to the first step to reset your password",
"passwordOneTimeInputHint": "Check your inbox or authorizer for a verification code",
"title": "Title",
"description": "Description",
"birthday": "Birthday",
@@ -101,6 +108,11 @@
"signinRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signinResetPasswordHint": "Please enter username to request reset password.",
"signinResetPasswordSent": "Reset password request sent, check your inbox!",
"signinPickFactor": "Pick a way\nfor verification",
"signinEnterPassword": "Enter your\npassword",
"signinMultiFactor": "@n step(s) verifications",
"authFactorEmail": "Email One-time-password",
"authFactorPassword": "Password",
"signup": "Sign up",
"signupGreeting": "Welcome onboard",
"signupCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
@@ -145,6 +157,9 @@
"postListNews": "News",
"postListFriends": "Friends",
"postListShuffle": "Random",
"attachmentThumbnail": "Thumbnail",
"attachmentThumbnailAttachmentNew": "Upload thumbnail",
"attachmentThumbnailAttachment": "Attachment serial number",
"postEditorModeStory": "Post a post",
"postEditorModeArticle": "Post an article",
"postEditor": "Post editor",
@@ -213,6 +228,8 @@
"realmDescription": "Description",
"realmPublic": "Public Realm",
"realmCommunity": "Community Realm",
"realmAvatar": "Realm avatar",
"realmBanner": "Realm banner",
"realmDetail": "Realm detail",
"realmMember": "Realm member",
"realmMembers": "Realm members",
@@ -238,7 +255,8 @@
"channelName": "Name",
"channelDescription": "Description",
"channelDirectDescription": "Direct message with @username",
"channelEncrypted": "Encrypted Channel",
"channelPublic": "Public channel",
"channelCommunity": "Community channel",
"channelMember": "Channel member",
"channelMembers": "Channel members",
"channelMembersAdd": "Add channel members",
@@ -373,7 +391,8 @@
"callStatusReconnected": "Reconnecting",
"messageOutOfSync": "May Out of Sync with Server",
"messageOutOfSyncCaption": "Since the App has entered the background, there may be a time difference between the message list and the server. Click to Refresh.",
"messageHistoryWipe": "Wipe local message history",
"localDatabaseWipe": "Wipe local database",
"localDatabaseSize": "Overall database size: @size",
"unknown": "Unknown",
"collapse": "Collapse",
"expand": "Expand",
@@ -392,5 +411,16 @@
"userLevel11": "Legend",
"userLevel12": "Mythic",
"userLevel13": "Immortal",
"postBrowsingIn": "Browsing in @region"
"postBrowsingIn": "Browsing in @region",
"needRestartToApply": "Restart the application to take effect",
"holdToSeeDetail": "Long press / Mouse hover to see detail",
"subscribe": "Subscribe",
"subscribed": "Subscribed",
"unsubscribe": "Unsubscribe",
"preferences": "Preferences",
"notificationPreferences": "Notification preferences",
"notificationTopicPostFeedback": "Post feedbacks",
"notificationTopicPostSubscription": "Post subscriptions",
"preferencesApplied": "Preferences has been applied.",
"save": "Save"
}

View File

@@ -4,6 +4,7 @@
"okay": "确认",
"home": "首页",
"next": "下一步",
"prev": "上一步",
"reset": "重置",
"cancel": "取消",
"confirm": "确认",
@@ -14,6 +15,8 @@
"edit": "编辑",
"delete": "删除",
"settings": "设置",
"settingsNotificationBgService": "常驻通知服务",
"settingsNotificationBgServiceDesc": "在设备常驻一个通知服务,使得部分不支持推送通知的设备可以在后台收到通知;启用该功能的情况下不会向服务器注册推送通知,并且你会始终在他人眼中成为在线(隐身除外);可能需要在设置中关闭电量与流量优化。",
"page": "页面",
"draft": "草稿",
"draftSave": "存为草稿",
@@ -73,8 +76,12 @@
"forgotPassword": "忘记密码",
"email": "邮件地址",
"username": "用户名",
"usernameInputHint": "同时支持邮箱 / 电话号码",
"nickname": "显示名",
"password": "密码",
"passwordOneTime": "一次性验证码",
"passwordInputHint": "忘记密码了?回到第一步以重置密码",
"passwordOneTimeInputHint": "检查你的收件箱或是授权器获得以验证码",
"title": "标题",
"description": "简介",
"birthday": "生日",
@@ -106,6 +113,11 @@
"signinRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signinResetPasswordHint": "请先填写用户名以发送重置密码请求。",
"signinResetPasswordSent": "重置密码请求已发送,在绑定邮件收件箱可收取一份包含重置密码链接的邮件。",
"signinPickFactor": "选择一个\n验证方式",
"signinEnterPassword": "输入密码\n或验证码",
"signinMultiFactor": "@n 步验证",
"authFactorEmail": "邮箱一次性密码",
"authFactorPassword": "账户密码",
"signup": "注册",
"signupGreeting": "欢迎加入\nSolar Network",
"signupCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
@@ -156,6 +168,9 @@
"postListNews": "新鲜事",
"postListFriends": "好友圈",
"postListShuffle": "打乱看",
"attachmentThumbnail": "附件缩略图",
"attachmentThumbnailAttachmentNew": "上传附件作为缩略图",
"attachmentThumbnailAttachment": "附件序列号",
"postNew": "创建新帖子",
"postNewInRealmHint": "在领域 @realm 里发表新帖子",
"postAction": "发表",
@@ -214,6 +229,8 @@
"realmDescription": "领域简介",
"realmPublic": "公开领域",
"realmCommunity": "社区领域",
"realmAvatar": "领域头像",
"realmBanner": "领域横幅",
"realmDetail": "领域详情",
"realmMember": "领域成员",
"realmMembers": "领域成员",
@@ -239,7 +256,8 @@
"channelName": "显示名称",
"channelDescription": "频道简介",
"channelDirectDescription": "与 @username 的私聊",
"channelEncrypted": "加密频道",
"channelPublic": "公开频道",
"channelCommunity": "社区频道",
"channelMember": "频道成员",
"channelMembers": "频道成员",
"channelMembersAdd": "添加频道成员",
@@ -374,7 +392,8 @@
"callStatusReconnected": "重连中",
"messageOutOfSync": "消息可能与服务器脱节",
"messageOutOfSyncCaption": "由于 App 进入后台,消息列表可能与服务器存在时差,点击刷新。",
"messageHistoryWipe": "清除消息记录",
"localDatabaseWipe": "清除本地数据库",
"localDatabaseSize": "本地数据库大小:@size",
"unknown": "未知",
"collapse": "折叠",
"expand": "展开",
@@ -393,5 +412,16 @@
"userLevel11": "名垂千古",
"userLevel12": "独占鳌头",
"userLevel13": "万古流芳",
"postBrowsingIn": "浏览 @region 内的帖子中"
"postBrowsingIn": "浏览 @region 内的帖子中",
"needRestartToApply": "需要重启应用来生效",
"holdToSeeDetail": "长按 / 鼠标悬浮来查看详情",
"subscribe": "订阅",
"subscribed": "已订阅",
"unsubscribe": "取消订阅",
"preferences": "偏好设置",
"notificationPreferences": "通知偏好设置",
"notificationTopicPostFeedback": "帖子反馈",
"notificationTopicPostSubscription": "订阅源",
"preferencesApplied": "偏好设置已应用",
"save": "保存"
}

View File

@@ -73,7 +73,7 @@ PODS:
- Firebase/Performance (= 11.0.0)
- firebase_core
- Flutter
- FirebaseABTesting (11.1.0):
- FirebaseABTesting (11.2.0):
- FirebaseCore (~> 11.0)
- FirebaseAnalytics (11.0.0):
- FirebaseAnalytics/AdIdSupport (= 11.0.0)
@@ -97,9 +97,9 @@ PODS:
- FirebaseCoreInternal (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/Logger (~> 8.0)
- FirebaseCoreExtension (11.1.0):
- FirebaseCoreExtension (11.2.0):
- FirebaseCore (~> 11.0)
- FirebaseCoreInternal (11.1.0):
- FirebaseCoreInternal (11.2.0):
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseCrashlytics (11.0.0):
- FirebaseCore (~> 11.0)
@@ -110,7 +110,7 @@ PODS:
- GoogleUtilities/Environment (~> 8.0)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (11.1.0):
- FirebaseInstallations (11.2.0):
- FirebaseCore (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
@@ -134,7 +134,7 @@ PODS:
- GoogleUtilities/MethodSwizzler (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- FirebaseRemoteConfig (11.1.0):
- FirebaseRemoteConfig (11.2.0):
- FirebaseABTesting (~> 11.0)
- FirebaseCore (~> 11.0)
- FirebaseInstallations (~> 11.0)
@@ -142,8 +142,8 @@ PODS:
- FirebaseSharedSwift (~> 11.0)
- GoogleUtilities/Environment (~> 8.0)
- "GoogleUtilities/NSData+zlib (~> 8.0)"
- FirebaseRemoteConfigInterop (11.1.0)
- FirebaseSessions (11.1.0):
- FirebaseRemoteConfigInterop (11.2.0)
- FirebaseSessions (11.2.0):
- FirebaseCore (~> 11.0)
- FirebaseCoreExtension (~> 11.0)
- FirebaseInstallations (~> 11.0)
@@ -152,10 +152,14 @@ PODS:
- GoogleUtilities/UserDefaults (~> 8.0)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- FirebaseSharedSwift (11.1.0)
- FirebaseSharedSwift (11.2.0)
- Flutter (1.0.0)
- flutter_background_service_ios (0.0.3):
- Flutter
- flutter_keyboard_visibility (0.0.1):
- Flutter
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (6.0.0):
@@ -302,7 +306,9 @@ DEPENDENCIES:
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- firebase_performance (from `.symlinks/plugins/firebase_performance/ios`)
- Flutter (from `Flutter`)
- flutter_background_service_ios (from `.symlinks/plugins/flutter_background_service_ios/ios`)
- flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`)
- 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_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
@@ -377,8 +383,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/firebase_performance/ios"
Flutter:
:path: Flutter
flutter_background_service_ios:
:path: ".symlinks/plugins/flutter_background_service_ios/ios"
flutter_keyboard_visibility:
:path: ".symlinks/plugins/flutter_keyboard_visibility/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
@@ -440,21 +450,23 @@ SPEC CHECKSUMS:
firebase_crashlytics: 4111f8198b78c99471c955af488cecd8224967e6
firebase_messaging: c40f84e7a98da956d5262fada373b5c458edcf13
firebase_performance: 8b7b9ca5adf3a9b3afa12b4eb96b9cabefc2c248
FirebaseABTesting: c2e22c3aab99afa81d0561708b2c1c356c556976
FirebaseABTesting: 2104d957ce33888a3d6f3bde298cdee376dde8f1
FirebaseAnalytics: 27eb78b97880ea4a004839b9bac0b58880f5a92a
FirebaseCore: 3cf438f431f18c12cdf2aaf64434648b63f7e383
FirebaseCoreExtension: aa5c9779c2d0d39d83f1ceb3fdbafe80c4feecfa
FirebaseCoreInternal: adefedc9a88dbe393c4884640a73ec9e8e790f8c
FirebaseCoreExtension: cda74ddfb001224bd8fd1d6e74698b4ed07803de
FirebaseCoreInternal: 0c569513412da9f3b31bd0b340013bbee8f295c5
FirebaseCrashlytics: 745d8f0221fe49c62865391d1bf56f5a12eeec0b
FirebaseInstallations: d0a8fea5a6fa91abc661591cf57c0f0d70863e57
FirebaseInstallations: 771177d89d6c451dc6e50085ec82e2fc77ed0a4a
FirebaseMessaging: d2d1d9c62c46dd2db49a952f7deb5b16ad2c9742
FirebasePerformance: efdc02bacb1b4710588c9f867011605c081cdf79
FirebaseRemoteConfig: 05521e937b72e01847a7128da5a492327364c705
FirebaseRemoteConfigInterop: abf8b1bbc0bf1b84abd22b66746926410bf91a87
FirebaseSessions: 78f137e68dc01ca71606169ba4ac73b98c13752a
FirebaseSharedSwift: 260a35e08943ec810d820a70bc0359136351d0c5
FirebaseRemoteConfig: fca0b2d017fc1de52b28a4e5bcf2007c1a840457
FirebaseRemoteConfigInterop: 477b26fdeb8fb5fbaf22fa9db5343b42289dc7db
FirebaseSessions: adcec8b72d0066a385e3affcd1bcb1ebb3908ce6
FirebaseSharedSwift: 7a0d78d155ede78407f0fdc89fbc914014c7c540
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac
flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_webrtc: 75b868e4f9e817c7a9a42ca4b6169063de4eec9f

View File

@@ -616,6 +616,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -920,6 +921,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
@@ -947,6 +949,7 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES = YES;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";

109
lib/background.dart Normal file
View File

@@ -0,0 +1,109 @@
import 'dart:ui';
import 'package:flutter/material.dart' hide Notification;
import 'package:flutter_background_service/flutter_background_service.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/models/notification.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/websocket.dart';
FlutterBackgroundService? bgNotificationService;
void autoConfigureBackgroundNotificationService() async {
if (bgNotificationService != null) return;
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') != true) return;
bgNotificationService = FlutterBackgroundService();
await bgNotificationService!.configure(
androidConfiguration: AndroidConfiguration(
onStart: onBackgroundNotificationServiceStart,
autoStart: true,
autoStartOnBoot: true,
isForegroundMode: false,
),
// This feature won't be able to use on iOS
// We got APNs support covered
iosConfiguration: IosConfiguration(
autoStart: false,
),
);
}
void autoStartBackgroundNotificationService() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.getBool('service_background_notification') != true) return;
if (bgNotificationService == null) return;
bgNotificationService!.startService();
}
void autoStopBackgroundNotificationService() async {
if (bgNotificationService == null) return;
if (await bgNotificationService!.isRunning()) {
bgNotificationService?.invoke('stopService');
}
}
@pragma('vm:entry-point')
void onBackgroundNotificationServiceStart(ServiceInstance service) async {
WidgetsFlutterBinding.ensureInitialized();
DartPluginRegistrant.ensureInitialized();
Get.put(AuthProvider());
Get.put(WebSocketProvider());
service.on('stopService').listen((event) {
service.stopSelf();
});
final auth = Get.find<AuthProvider>();
await auth.refreshAuthorizeStatus();
await auth.ensureCredentials();
if (!auth.isAuthorized.value) {
debugPrint(
'Background notification do nothing due to user didn\'t sign in.',
);
return;
}
const notificationChannelId = 'solian_notification_service';
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final ws = Get.find<WebSocketProvider>();
await ws.connect();
debugPrint('Background notification has been started');
ws.stream.stream.listen(
(event) {
debugPrint(
'Background notification service incoming message: ${event.method} ${event.message}',
);
if (event.method == 'notifications.new' && event.payload != null) {
final data = Notification.fromJson(event.payload!);
debugPrint(
'Background notification service got a notification id=${data.id}',
);
flutterLocalNotificationsPlugin.show(
data.id,
data.title,
[data.subtitle, data.body].where((x) => x != null).join('\n'),
const NotificationDetails(
android: AndroidNotificationDetails(
notificationChannelId,
'Solian Notification Service',
channelDescription: 'Notifications that sent via Solar Network',
importance: Importance.high,
icon: 'mipmap/ic_launcher',
),
),
);
}
},
);
}

View File

@@ -9,7 +9,6 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart';
@@ -71,7 +70,7 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
(
label: 'bsCheckingServer',
action: () async {
final client = ServiceFinder.configureClient('dealer');
final client = await ServiceFinder.configureClient('dealer');
final resp = await client.get('/.well-known');
if (resp.statusCode != null && resp.statusCode != 200) {
setState(() {
@@ -115,7 +114,6 @@ class _BootstrapperShellState extends State<BootstrapperShell> {
final AuthProvider auth = Get.find();
try {
await Future.wait([
Get.find<StickerProvider>().refreshAvailableStickers(),
if (auth.isAuthorized.isTrue)
Get.find<ChannelProvider>().refreshAvailableChannel(),
if (auth.isAuthorized.isTrue)

View File

@@ -1,7 +1,8 @@
import 'dart:math' as math;
import 'package:get/get.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/event.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/database/services/messages.dart';
@@ -31,80 +32,56 @@ class ChatEventController {
this.channel = channel;
this.scope = scope;
syncLocal(channel);
const firstTake = 20;
const furtherTake = 100;
isLoading.value = true;
if (PlatformInfo.isWeb) {
final result = await src.fetchRemoteEvents(
channel,
scope,
depth: 1,
offset: 0,
);
totalEvents.value = result?.$2 ?? 0;
if (result != null) {
for (final x in result.$1.reversed) {
final entry = LocalMessageEventTableData(
id: x.id,
channelId: x.channelId,
createdAt: x.createdAt,
data: x,
);
insertEvent(entry);
applyEvent(entry);
}
}
} else {
final result = await src.pullRemoteEvents(
channel,
scope: scope,
depth: 1,
);
totalEvents.value = result?.$2 ?? 0;
await syncLocal(channel);
}
await syncLocal(channel, take: firstTake);
isLoading.value = false;
// Take a small range of messages to check is local database up to date
var isUpToDate = true;
final result =
await src.pullRemoteEvents(channel, scope: scope, take: firstTake);
totalEvents.value = result?.$2 ?? 0;
if ((result?.$1.length ?? 0) > 0) {
final minId = result!.$1.map((x) => x.id).reduce(math.min);
isUpToDate = await src.getEventFromLocal(minId) != null;
}
syncLocal(channel, take: firstTake);
if (!isUpToDate) {
// Loading more content due to isn't up to date
final result =
await src.pullRemoteEvents(channel, scope: scope, take: furtherTake);
totalEvents.value = result?.$2 ?? 0;
syncLocal(channel, take: furtherTake);
}
}
Future<void> loadEvents(Channel channel, String scope) async {
const take = 20;
final offset = currentEvents.length;
isLoading.value = true;
if (PlatformInfo.isWeb) {
final result = await src.fetchRemoteEvents(
channel,
scope,
depth: 3,
offset: currentEvents.length,
);
if (result != null) {
totalEvents.value = result.$2;
for (final x in result.$1.reversed) {
final entry = LocalMessageEventTableData(
id: x.id,
channelId: x.channelId,
createdAt: x.createdAt,
data: x,
);
currentEvents.add(entry);
applyEvent(entry);
}
}
} else {
final result = await src.pullRemoteEvents(
channel,
depth: 3,
scope: scope,
offset: currentEvents.length,
);
await syncLocal(channel, take: take, offset: offset);
src
.pullRemoteEvents(channel, scope: scope, take: take, offset: offset)
.then((result) {
totalEvents.value = result?.$2 ?? 0;
await syncLocal(channel);
}
syncLocal(channel, take: take, offset: offset);
});
isLoading.value = false;
}
Future<bool> syncLocal(Channel channel) async {
if (PlatformInfo.isWeb) return false;
final data = await src.listEvents(channel);
currentEvents.replaceRange(0, currentEvents.length, data);
Future<bool> syncLocal(Channel channel,
{required int take, int offset = 0}) async {
final data = await src.listEvents(channel, take: take, offset: offset);
if (currentEvents.length >= offset + take) {
currentEvents.replaceRange(offset, offset + take, data);
} else {
currentEvents.insertAll(currentEvents.length, data);
}
for (final x in data.reversed) {
applyEvent(x);
}
@@ -113,16 +90,7 @@ class ChatEventController {
receiveEvent(Event remote) async {
LocalMessageEventTableData entry;
if (PlatformInfo.isWeb) {
entry = LocalMessageEventTableData(
id: remote.id,
channelId: remote.channelId,
createdAt: remote.createdAt,
data: remote,
);
} else {
entry = await src.receiveEvent(remote);
}
entry = await src.receiveEvent(remote);
totalEvents.value++;
insertEvent(entry);

View File

@@ -1,9 +1,11 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
extension SolianExtenions on BuildContext {
extension AppExtensions on BuildContext {
void showSnackbar(String content, {SnackBarAction? action}) {
ScaffoldMessenger.of(this).showSnackBar(SnackBar(
content: Text(content),
@@ -102,3 +104,24 @@ extension SolianExtenions on BuildContext {
);
}
}
extension ByteFormatter on int {
String formatBytes({int decimals = 2}) {
if (this == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(this) / math.log(k)).floor().toInt();
return '${(this / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
}

View File

@@ -2,12 +2,13 @@ import 'dart:ui';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Notification;
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:provider/provider.dart';
import 'package:solian/background.dart';
import 'package:solian/bootstrapper.dart';
import 'package:solian/firebase_options.dart';
import 'package:solian/platform.dart';
@@ -19,6 +20,7 @@ import 'package:solian/providers/last_read.dart';
import 'package:solian/providers/link_expander.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/providers/subscription.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/providers/auth.dart';
@@ -41,6 +43,7 @@ void main() async {
await Future.wait([
_initializeFirebase(),
_initializePlatformComponents(),
_initializeBackgroundNotificationService(),
]);
GoRouter.optionURLReflectsImperativeAPIs = true;
@@ -64,6 +67,11 @@ Future<void> _initializeFirebase() async {
};
}
Future<void> _initializeBackgroundNotificationService() async {
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
}
Future<void> _initializePlatformComponents() async {
if (!PlatformInfo.isWeb) {
await protocolHandler.register('solink');
@@ -144,5 +152,8 @@ class SolianApp extends StatelessWidget {
Get.lazyPut(() => LinkExpandProvider());
Get.lazyPut(() => DailySignProvider());
Get.lazyPut(() => LastReadProvider());
Get.lazyPut(() => SubscriptionProvider());
Get.find<WebSocketProvider>().requestPermissions();
}
}

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'account.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'account_status.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'attachment.g.dart';

103
lib/models/auth.dart Normal file
View File

@@ -0,0 +1,103 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'auth.g.dart';
@JsonSerializable()
class AuthResult {
bool isFinished;
AuthTicket ticket;
AuthResult({
required this.isFinished,
required this.ticket,
});
factory AuthResult.fromJson(Map<String, dynamic> json) =>
_$AuthResultFromJson(json);
Map<String, dynamic> toJson() => _$AuthResultToJson(this);
}
@JsonSerializable()
class AuthTicket {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String location;
String ipAddress;
String userAgent;
int stepRemain;
List<String> claims;
List<String> audiences;
@JsonKey(defaultValue: [])
List<int> factorTrail;
String? grantToken;
String? accessToken;
String? refreshToken;
DateTime? expiredAt;
DateTime? availableAt;
DateTime? lastGrantAt;
String? nonce;
int? clientId;
Account account;
int accountId;
AuthTicket({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.location,
required this.ipAddress,
required this.userAgent,
required this.stepRemain,
required this.claims,
required this.audiences,
required this.factorTrail,
required this.grantToken,
required this.accessToken,
required this.refreshToken,
required this.expiredAt,
required this.availableAt,
required this.lastGrantAt,
required this.nonce,
required this.clientId,
required this.account,
required this.accountId,
});
factory AuthTicket.fromJson(Map<String, dynamic> json) =>
_$AuthTicketFromJson(json);
Map<String, dynamic> toJson() => _$AuthTicketToJson(this);
}
@JsonSerializable()
class AuthFactor {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int type;
Map<String, dynamic>? config;
Account account;
int accountId;
AuthFactor({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.type,
required this.config,
required this.account,
required this.accountId,
});
factory AuthFactor.fromJson(Map<String, dynamic> json) =>
_$AuthFactorFromJson(json);
Map<String, dynamic> toJson() => _$AuthFactorToJson(this);
}

105
lib/models/auth.g.dart Normal file
View File

@@ -0,0 +1,105 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthResult _$AuthResultFromJson(Map<String, dynamic> json) => AuthResult(
isFinished: json['is_finished'] as bool,
ticket: AuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AuthResultToJson(AuthResult instance) =>
<String, dynamic>{
'is_finished': instance.isFinished,
'ticket': instance.ticket.toJson(),
};
AuthTicket _$AuthTicketFromJson(Map<String, dynamic> json) => AuthTicket(
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),
location: json['location'] as String,
ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String,
stepRemain: (json['step_remain'] as num).toInt(),
claims:
(json['claims'] as List<dynamic>).map((e) => e as String).toList(),
audiences:
(json['audiences'] as List<dynamic>).map((e) => e as String).toList(),
factorTrail: (json['factor_trail'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList() ??
[],
grantToken: json['grant_token'] as String?,
accessToken: json['access_token'] as String?,
refreshToken: json['refresh_token'] as String?,
expiredAt: json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
availableAt: json['available_at'] == null
? null
: DateTime.parse(json['available_at'] as String),
lastGrantAt: json['last_grant_at'] == null
? null
: DateTime.parse(json['last_grant_at'] as String),
nonce: json['nonce'] as String?,
clientId: (json['client_id'] as num?)?.toInt(),
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuthTicketToJson(AuthTicket instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'location': instance.location,
'ip_address': instance.ipAddress,
'user_agent': instance.userAgent,
'step_remain': instance.stepRemain,
'claims': instance.claims,
'audiences': instance.audiences,
'factor_trail': instance.factorTrail,
'grant_token': instance.grantToken,
'access_token': instance.accessToken,
'refresh_token': instance.refreshToken,
'expired_at': instance.expiredAt?.toIso8601String(),
'available_at': instance.availableAt?.toIso8601String(),
'last_grant_at': instance.lastGrantAt?.toIso8601String(),
'nonce': instance.nonce,
'client_id': instance.clientId,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};
AuthFactor _$AuthFactorFromJson(Map<String, dynamic> json) => AuthFactor(
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 num).toInt(),
config: json['config'] as Map<String, dynamic>?,
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
);
Map<String, dynamic> _$AuthFactorToJson(AuthFactor instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'type': instance.type,
'config': instance.config,
'account': instance.account.toJson(),
'account_id': instance.accountId,
};

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/channel.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart';
@@ -19,7 +19,8 @@ class Channel {
int accountId;
Realm? realm;
int? realmId;
bool isEncrypted;
bool isPublic;
bool isCommunity;
@JsonKey(includeFromJson: false, includeToJson: true)
bool isAvailable = false;
@@ -36,7 +37,8 @@ class Channel {
required this.members,
required this.account,
required this.accountId,
required this.isEncrypted,
required this.isPublic,
required this.isCommunity,
required this.realm,
required this.realmId,
});

View File

@@ -22,7 +22,8 @@ Channel _$ChannelFromJson(Map<String, dynamic> json) => Channel(
.toList(),
account: Account.fromJson(json['account'] as Map<String, dynamic>),
accountId: (json['account_id'] as num).toInt(),
isEncrypted: json['is_encrypted'] as bool,
isPublic: json['is_public'] as bool,
isCommunity: json['is_community'] as bool,
realm: json['realm'] == null
? null
: Realm.fromJson(json['realm'] as Map<String, dynamic>),
@@ -43,7 +44,8 @@ Map<String, dynamic> _$ChannelToJson(Channel instance) => <String, dynamic>{
'account_id': instance.accountId,
'realm': instance.realm?.toJson(),
'realm_id': instance.realmId,
'is_encrypted': instance.isEncrypted,
'is_public': instance.isPublic,
'is_community': instance.isCommunity,
'is_available': instance.isAvailable,
};

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:get/get.dart';
import 'package:solian/models/account.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/channel.dart';
part 'event.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'link.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'notification.g.dart';

View File

@@ -1,10 +1,10 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'packet.g.dart';
@JsonSerializable()
class NetworkPackage {
@JsonKey(name: 'w')
@JsonKey(name: 'w', defaultValue: 'unknown')
String method;
@JsonKey(name: 'e')
String? endpoint;

View File

@@ -8,7 +8,7 @@ part of 'packet.dart';
NetworkPackage _$NetworkPackageFromJson(Map<String, dynamic> json) =>
NetworkPackage(
method: json['w'] as String,
method: json['w'] as String? ?? 'unknown',
endpoint: json['e'] as String?,
message: json['m'] as String?,
payload: json['p'] as Map<String, dynamic>?,

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'pagination.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/post_categories.dart';
import 'package:solian/models/realm.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'post_categories.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'realm.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
part 'relations.g.dart';

View File

@@ -1,4 +1,4 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/services.dart';

View File

@@ -0,0 +1,41 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/post_categories.dart';
part 'subscription.g.dart';
@JsonSerializable()
class Subscription {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int followerId;
Account follower;
int? accountId;
Account? account;
int? tagId;
Tag? tag;
int? categoryId;
Category? category;
Subscription({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.followerId,
required this.follower,
required this.accountId,
required this.account,
required this.tagId,
required this.tag,
required this.categoryId,
required this.category,
});
factory Subscription.fromJson(Map<String, dynamic> json) =>
_$SubscriptionFromJson(json);
Map<String, dynamic> toJson() => _$SubscriptionToJson(this);
}

View File

@@ -0,0 +1,46 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'subscription.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
Subscription _$SubscriptionFromJson(Map<String, dynamic> json) => Subscription(
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),
followerId: (json['follower_id'] as num).toInt(),
follower: Account.fromJson(json['follower'] as Map<String, dynamic>),
accountId: (json['account_id'] as num?)?.toInt(),
account: json['account'] == null
? null
: Account.fromJson(json['account'] as Map<String, dynamic>),
tagId: (json['tag_id'] as num?)?.toInt(),
tag: json['tag'] == null
? null
: Tag.fromJson(json['tag'] as Map<String, dynamic>),
categoryId: (json['category_id'] as num?)?.toInt(),
category: json['category'] == null
? null
: Category.fromJson(json['category'] as Map<String, dynamic>),
);
Map<String, dynamic> _$SubscriptionToJson(Subscription instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),
'follower_id': instance.followerId,
'follower': instance.follower.toJson(),
'account_id': instance.accountId,
'account': instance.account?.toJson(),
'tag_id': instance.tagId,
'tag': instance.tag?.toJson(),
'category_id': instance.categoryId,
'category': instance.category?.toJson(),
};

View File

@@ -37,7 +37,7 @@ class StatusProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
return await client.get('/users/me/status');
}
@@ -56,7 +56,7 @@ class StatusProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final payload = {
'type': type,
@@ -85,7 +85,7 @@ class StatusProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.delete('/users/me/status');
if (resp.statusCode != 200) {

View File

@@ -6,8 +6,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:get/get.dart';
import 'package:get/get_connect/http/src/request/request.dart';
import 'package:solian/background.dart';
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/websocket.dart';
import 'package:solian/services.dart';
@@ -112,14 +115,14 @@ class AuthProvider extends GetConnect {
return request;
}
GetConnect configureClient(
Future<GetConnect> configureClient(
String service, {
timeout = const Duration(seconds: 5),
}) {
}) async {
final client = GetConnect(
maxAuthRetries: 3,
timeout: timeout,
userAgent: 'Solian/1.1',
userAgent: await ServiceFinder.getUserAgent(),
sendUserAgent: true,
);
client.httpClient.addAuthenticator(requestAuthenticator);
@@ -146,27 +149,13 @@ class AuthProvider extends GetConnect {
Future<TokenSet> signin(
BuildContext context,
String username,
String password,
AuthTicket ticket,
) async {
userProfile.value = null;
final client = ServiceFinder.configureClient('auth');
// Create ticket
final resp = await client.post('/auth', {
'username': username,
'password': password,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
} else if (resp.body['is_finished'] == false) {
throw RiskyAuthenticateException(resp.body['ticket']['id']);
}
// Assign token
final tokenResp = await post('/auth/token', {
'code': resp.body['ticket']['grant_token'],
'code': ticket.grantToken!,
'grant_type': 'grant_token',
});
if (tokenResp.statusCode != 200) {
@@ -198,6 +187,9 @@ class AuthProvider extends GetConnect {
Get.find<WebSocketProvider>().notifications.clear();
Get.find<WebSocketProvider>().notificationUnread.value = 0;
AppDatabase.removeDatabase();
autoStopBackgroundNotificationService();
storage.deleteAll();
}
@@ -211,7 +203,8 @@ class AuthProvider extends GetConnect {
}
Future<void> refreshUserProfile() async {
final client = configureClient('auth');
if (!isAuthorized.value) return;
final client = await configureClient('auth');
final resp = await client.get('/users/me');
if (resp.statusCode != 200) {
throw RequestException(resp);

View File

@@ -92,7 +92,7 @@ class ChatCallProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.post(
'/channels/global/${channel.value!.alias}/calls/ongoing/token',

View File

@@ -93,7 +93,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient(
final client = await auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
@@ -135,7 +135,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('uc');
final client = await auth.configureClient('uc');
final fileAlt = basename(path).contains('.')
? basename(path).substring(0, basename(path).lastIndexOf('.'))
@@ -173,7 +173,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient(
final client = await auth.configureClient(
'uc',
timeout: const Duration(minutes: 3),
);
@@ -198,7 +198,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files');
final client = await auth.configureClient('files');
var resp = await client.put('/attachments/$id', {
'alt': alt,
@@ -217,7 +217,7 @@ class AttachmentProvider extends GetConnect {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('files');
final client = await auth.configureClient('files');
var resp = await client.delete('/attachments/$id');
if (resp.statusCode != 200) {

View File

@@ -33,7 +33,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias');
if (resp.statusCode != 200) {
@@ -48,7 +48,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias/me');
if (resp.statusCode != 200) {
@@ -63,7 +63,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/$alias/calls/ongoing');
if (resp.statusCode == 404) {
@@ -79,7 +79,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$scope');
if (resp.statusCode != 200) {
@@ -93,7 +93,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get('/channels/$realm/me/available');
if (resp.statusCode != 200) {
@@ -107,7 +107,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.post('/channels/$scope', payload);
if (resp.statusCode != 200) {
@@ -132,7 +132,7 @@ class ChannelProvider extends GetxController {
if (related == null) return null;
final prof = auth.userProfile.value!;
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.post('/channels/$scope/dm', {
'alias': const Uuid().v4().replaceAll('-', '').substring(0, 12),
@@ -153,7 +153,7 @@ class ChannelProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.put('/channels/$scope/$id', payload);
if (resp.statusCode != 200) {

View File

@@ -1,6 +1,7 @@
import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
@@ -14,9 +15,9 @@ class PostProvider extends GetConnect {
GetConnect client;
final AuthProvider auth = Get.find();
if (auth.isAuthorized.value) {
client = auth.configureClient('co');
client = await auth.configureClient('co');
} else {
client = ServiceFinder.configureClient('co');
client = await ServiceFinder.configureClient('co');
}
final resp = await client.get('/whats-new?pivot=$pivot');
if (resp.statusCode != 200) {
@@ -36,9 +37,9 @@ class PostProvider extends GetConnect {
if (realm != null) 'realm=$realm',
];
if (auth.isAuthorized.value) {
client = auth.configureClient('co');
client = await auth.configureClient('co');
} else {
client = ServiceFinder.configureClient('co');
client = await ServiceFinder.configureClient('co');
}
final resp = await client.get(
channel == null
@@ -60,7 +61,7 @@ class PostProvider extends GetConnect {
'take=${10}',
'offset=$page',
];
final client = auth.configureClient('interactive');
final client = await auth.configureClient('interactive');
final resp = await client.get('/posts/drafts?${queries.join('&')}');
if (resp.statusCode != 200) {
throw RequestException(resp);
@@ -96,6 +97,15 @@ class PostProvider extends GetConnect {
return resp;
}
Future<List<Post>> listPostFeaturedReply(String alias, {int take = 1}) async {
final resp = await get('/posts/$alias/replies/featured?take=$take');
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return List<Post>.from(resp.body.map((x) => Post.fromJson(x)));
}
Future<Response> getPost(String alias) async {
final resp = await get('/posts/$alias');
if (resp.statusCode != 200) {

View File

@@ -25,7 +25,7 @@ class RealmProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.get('/realms/$alias');
if (resp.statusCode != 200) {
@@ -39,7 +39,7 @@ class RealmProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.get('/realms/me/available');
if (resp.statusCode != 200) {

View File

@@ -10,7 +10,7 @@ class DailySignProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('id');
final client = await auth.configureClient('id');
final resp = await client.get('/daily?take=$take');
if (resp.statusCode != 200 && resp.statusCode != 404) {
@@ -30,7 +30,7 @@ class DailySignProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('id');
final client = await auth.configureClient('id');
final resp = await client.get('/daily/today');
if (resp.statusCode != 200 && resp.statusCode != 404) {
@@ -46,7 +46,7 @@ class DailySignProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) throw const UnauthorizedException();
final client = auth.configureClient('id');
final client = await auth.configureClient('id');
final resp = await client.post('/daily', {});
if (resp.statusCode != 200) {

View File

@@ -1,6 +1,11 @@
import 'dart:io';
import 'package:drift/drift.dart';
import 'package:drift_flutter/drift_flutter.dart';
import 'package:get/get.dart' hide Value;
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/database/tables/messages.dart';
import 'package:solian/models/event.dart';
@@ -15,10 +20,32 @@ class AppDatabase extends _$AppDatabase {
int get schemaVersion => 1;
static QueryExecutor _openConnection() {
return driftDatabase(name: 'solar_network_local_db');
return driftDatabase(
name: 'solar_network_local_db',
web: DriftWebOptions(
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
driftWorker: Uri.parse('drift_worker.dart.js'),
),
);
}
static Future<int> getDatabaseSize() async {
if (PlatformInfo.isWeb) return 0;
final basepath = await getApplicationDocumentsDirectory();
return await File(join(basepath.path, 'solar_network_local_db.sqlite'))
.length();
}
static Future<void> removeDatabase() async {
if (PlatformInfo.isWeb) return;
final basepath = await getApplicationDocumentsDirectory();
final file = File(join(basepath.path, 'solar_network_local_db.sqlite'));
await Get.find<DatabaseProvider>().database.close();
await file.delete();
Get.find<DatabaseProvider>().database = AppDatabase();
}
}
class DatabaseProvider extends GetxController {
final database = AppDatabase();
var database = AppDatabase();
}

View File

@@ -12,7 +12,7 @@ class MessagesFetchingProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get(
'/whats-new?pivot=$pivot&take=$take',
@@ -33,7 +33,7 @@ class MessagesFetchingProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get(
'/channels/$scope/${channel.alias}/events/$id',
@@ -51,19 +51,13 @@ class MessagesFetchingProvider extends GetxController {
Future<(List<Event>, int)?> fetchRemoteEvents(
Channel channel,
String scope, {
required int depth,
bool Function(List<Event> items)? onBrake,
take = 10,
offset = 0,
}) async {
if (depth <= 0) {
return null;
}
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return null;
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client.get(
'/channels/$scope/${channel.alias}/events?take=$take&offset=$offset',
@@ -77,21 +71,7 @@ class MessagesFetchingProvider extends GetxController {
final result =
response.data?.map((e) => Event.fromJson(e)).toList() ?? List.empty();
if (onBrake != null && onBrake(result)) {
return (result, response.count);
}
final expandResult = (await fetchRemoteEvents(
channel,
scope,
depth: depth - 1,
take: take,
offset: offset + result.length,
))
?.$1 ??
List.empty();
return ([...result, ...expandResult], response.count);
return (result, response.count);
}
Future<LocalMessageEventTableData> receiveEvent(Event remote) async {
@@ -149,24 +129,24 @@ class MessagesFetchingProvider extends GetxController {
return await receiveEvent(remoteRecord);
}
Future<LocalMessageEventTableData?> getEventFromLocal(int id) async {
final database = Get.find<DatabaseProvider>().database;
final localRecord = await (database.select(database.localMessageEventTable)
..where((x) => x.id.equals(id)))
.getSingleOrNull();
return localRecord;
}
/// Pull the remote events to local database
Future<(List<Event>, int)?> pullRemoteEvents(Channel channel,
{String scope = 'global', depth = 10, offset = 0}) async {
{String scope = 'global', take = 10, offset = 0}) async {
final database = Get.find<DatabaseProvider>().database;
final lastOne = await (database.select(database.localMessageEventTable)
..where((x) => x.channelId.equals(channel.id))
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(1))
.getSingleOrNull();
final data = await fetchRemoteEvents(
channel,
scope,
depth: depth,
offset: offset,
onBrake: (items) {
return items.any((x) => x.id == lastOne?.id);
},
take: take,
);
if (data != null) {
await database.batch((batch) {
@@ -185,11 +165,13 @@ class MessagesFetchingProvider extends GetxController {
return data;
}
Future<List<LocalMessageEventTableData>> listEvents(Channel channel) async {
Future<List<LocalMessageEventTableData>> listEvents(Channel channel,
{required int take, int offset = 0}) async {
final database = Get.find<DatabaseProvider>().database;
return await (database.select(database.localMessageEventTable)
..where((x) => x.channelId.equals(channel.id))
..orderBy([(t) => OrderingTerm.desc(t.id)]))
..orderBy([(t) => OrderingTerm.desc(t.id)])
..limit(take, offset: offset))
.get();
}

View File

@@ -12,7 +12,7 @@ class LinkExpandProvider extends GetxController {
log('[LinkExpander] Expanding link... $url');
final target = utf8.fuse(base64).encode(url);
if (_cachedResponse.containsKey(target)) return _cachedResponse[target];
final client = ServiceFinder.configureClient('dealer');
final client = await ServiceFinder.configureClient('dealer');
final resp = await client.get('/api/links/$target');
if (resp.statusCode != 200) {
log('Unable to expand link ($url), status: ${resp.statusCode}, response: ${resp.body}');

View File

@@ -26,21 +26,21 @@ class RelationshipProvider extends GetxController {
return _friends.any((x) => x.relatedId == account.id);
}
Future<Response> listRelation() {
Future<Response> listRelation() async {
final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
return client.get('/users/me/relations');
}
Future<Response> listRelationWithStatus(int status) {
Future<Response> listRelationWithStatus(int status) async {
final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
return client.get('/users/me/relations?status=$status');
}
Future<Response> makeFriend(String username) async {
final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.post('/users/me/relations?related=$username', {});
if (resp.statusCode != 200) {
throw RequestException(resp);
@@ -52,7 +52,7 @@ class RelationshipProvider extends GetxController {
Future<Response> handleRelation(
Relationship relationship, bool doAccept) async {
final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.post(
'/users/me/relations/${relationship.relatedId}/${doAccept ? 'accept' : 'decline'}',
{},
@@ -66,7 +66,7 @@ class RelationshipProvider extends GetxController {
Future<Response> editRelation(Relationship relationship, int status) async {
final AuthProvider auth = Get.find();
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.patch(
'/users/me/relations/${relationship.relatedId}',
{'status': status},

View File

@@ -1,34 +1,48 @@
import 'dart:async';
import 'package:get/get.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/models/stickers.dart';
import 'package:solian/services.dart';
class StickerProvider extends GetxController {
final RxMap<String, String> aliasImageMapping = RxMap();
final RxList<Sticker> availableStickers = RxList.empty(growable: true);
final RxMap<String, FutureOr<Sticker?>> stickerCache = RxMap();
Future<void> refreshAvailableStickers() async {
availableStickers.clear();
aliasImageMapping.clear();
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=100',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out == null) return;
for (final pack in out) {
for (final sticker in (pack.stickers ?? List<Sticker>.empty())) {
sticker.pack = pack;
aliasImageMapping[sticker.textPlaceholder.toUpperCase()] =
sticker.imageUrl;
availableStickers.add(sticker);
}
}
Future<Sticker?> getStickerByAlias(String alias) {
if (stickerCache.containsKey(alias)) {
return Future.value(stickerCache[alias]);
}
availableStickers.refresh();
stickerCache[alias] = Future(() async {
final client = await ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/lookup/$alias',
);
if (resp.statusCode != 200) {
if (resp.statusCode == 404) {
stickerCache[alias] = null;
}
throw RequestException(resp);
}
return Sticker.fromJson(resp.body);
}).then((result) {
stickerCache[alias] = result;
return result;
});
return Future.value(stickerCache[alias]);
}
Future<List<Sticker>> searchStickerByAlias(String alias) async {
final client = await ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/lookup?probe=$alias',
);
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return List<Sticker>.from(resp.body.map((x) => Sticker.fromJson(x)));
}
}

View File

@@ -0,0 +1,46 @@
import 'package:get/get.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exceptions/unauthorized.dart';
import 'package:solian/models/subscription.dart';
import 'package:solian/providers/auth.dart';
class SubscriptionProvider extends GetxController {
Future<Subscription?> getSubscriptionOnUser(int userId) async {
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw const UnauthorizedException();
final client = await auth.configureClient('co');
final resp = await client.get('/subscriptions/users/$userId');
if (resp.statusCode == 404) {
return null;
} else if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Subscription.fromJson(resp.body);
}
Future<Subscription> subscribeToUser(int userId) async {
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw const UnauthorizedException();
final client = await auth.configureClient('co');
final resp = await client.post('/subscriptions/users/$userId', {});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
return Subscription.fromJson(resp.body);
}
Future<void> unsubscribeFromUser(int userId) async {
final auth = Get.find<AuthProvider>();
if (!auth.isAuthorized.value) throw const UnauthorizedException();
final client = await auth.configureClient('co');
final resp = await client.delete('/subscriptions/users/$userId');
if (resp.statusCode != 200) {
throw RequestException(resp);
}
}
}

View File

@@ -5,7 +5,9 @@ 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';
@@ -29,20 +31,46 @@ class WebSocketProvider extends GetxController {
@override
onInit() {
FirebaseMessaging.instance
.requestPermission(
alert: true,
announcement: true,
carPlay: true,
badge: true,
sound: true)
.then((status) {
notifyPrefetch();
});
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;
@@ -90,6 +118,10 @@ class WebSocketProvider extends GetxController {
final packet = NetworkPackage.fromJson(jsonDecode(event));
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++;
}
},
onDone: () {
isConnected.value = false;
@@ -106,7 +138,7 @@ class WebSocketProvider extends GetxController {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.get('/notifications?skip=0&take=100');
if (resp.statusCode == 200) {
@@ -120,6 +152,14 @@ class WebSocketProvider extends GetxController {
}
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;
@@ -142,7 +182,7 @@ class WebSocketProvider extends GetxController {
}
log('Device Push Token is $token');
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.post('/notifications/subscribe', {
'provider': provider,

View File

@@ -5,9 +5,11 @@ import 'package:solian/models/realm.dart';
import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/account/preferences/notifications.dart';
import 'package:solian/screens/account/profile_edit.dart';
import 'package:solian/screens/account/profile_page.dart';
import 'package:solian/screens/account/stickers.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart';
@@ -236,14 +238,6 @@ abstract class AppRouter {
name: 'accountFriend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/account/stickers',
name: 'accountStickers',
builder: (context, state) => TitleShell(
state: state,
child: const StickerScreen(),
),
),
GoRoute(
path: '/account/personalize',
name: 'accountProfile',
@@ -252,6 +246,14 @@ abstract class AppRouter {
child: const PersonalizeScreen(),
),
),
GoRoute(
path: '/account/preferences/notifications',
name: 'notificationPreferences',
builder: (context, state) => TitleShell(
state: state,
child: const NotificationPreferencesScreen(),
),
),
GoRoute(
path: '/account/view/:name',
name: 'accountProfilePage',
@@ -259,6 +261,24 @@ abstract class AppRouter {
name: state.pathParameters['name']!,
),
),
GoRoute(
path: '/auth/sign-in',
name: 'signin',
builder: (context, state) => TitleShell(
state: state,
isCenteredTitle: true,
child: const SignInScreen(),
),
),
GoRoute(
path: '/auth/sign-up',
name: 'signup',
builder: (context, state) => TitleShell(
state: state,
isCenteredTitle: true,
child: const SignUpScreen(),
),
),
],
);
}

View File

@@ -6,8 +6,6 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/widgets/account/account_heading.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:badges/badges.dart' as badges;
@@ -47,11 +45,6 @@ class _AccountScreenState extends State<AccountScreen> {
'accountFriend'.tr,
'accountFriend',
),
(
const Icon(Icons.emoji_symbols),
'accountStickers'.tr,
'accountStickers',
),
];
final AuthProvider auth = Get.find();
@@ -73,13 +66,7 @@ class _AccountScreenState extends State<AccountScreen> {
title: 'signin'.tr,
caption: 'signinCaption'.tr,
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isDismissible: false,
isScrollControlled: true,
context: context,
builder: (context) => const SignInPopup(),
).then((val) async {
AppRouter.instance.pushNamed('signin').then((val) async {
if (val == true) {
await auth.refreshUserProfile();
}
@@ -94,13 +81,7 @@ class _AccountScreenState extends State<AccountScreen> {
title: 'signup'.tr,
caption: 'signupCaption'.tr,
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isDismissible: false,
isScrollControlled: true,
context: context,
builder: (context) => const SignUpPopup(),
).then((_) {
AppRouter.instance.pushNamed('signup').then((_) {
setState(() {});
});
},
@@ -150,6 +131,15 @@ class _AccountScreenState extends State<AccountScreen> {
AppRouter.instance.pushNamed('settings');
},
),
if (auth.isAuthorized.value)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.edit_notifications),
title: Text('notificationPreferences'.tr),
onTap: () {
AppRouter.instance.pushNamed('notificationPreferences');
},
),
const Divider(thickness: 0.3, height: 1)
.paddingSymmetric(vertical: 4),
ListTile(
@@ -219,7 +209,6 @@ class _ActionCard extends StatelessWidget {
final Function onTap;
const _ActionCard({
super.key,
required this.onTap,
required this.title,
required this.caption,

View File

@@ -31,7 +31,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
if (markList.isNotEmpty) {
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
await client.put('/notifications/read', {'messages': markList});
}
@@ -53,7 +53,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
setState(() => _isBusy = true);
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
await client.put('/notifications/read/${element.id}', {});

View File

@@ -0,0 +1,118 @@
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';
class NotificationPreferencesScreen extends StatefulWidget {
const NotificationPreferencesScreen({super.key});
@override
State<NotificationPreferencesScreen> createState() =>
_NotificationPreferencesScreenState();
}
class _NotificationPreferencesScreenState
extends State<NotificationPreferencesScreen> {
bool _isBusy = true;
Map<String, bool> _config = {};
final Map<String, String> _topicMap = {
'interactive.feedback': 'notificationTopicPostFeedback'.tr,
'interactive.subscription': 'notificationTopicPostSubscription'.tr,
};
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/notifications');
if (resp.statusCode != 200 && resp.statusCode != 404) {
context.showErrorDialog(RequestException(resp));
}
if (resp.statusCode == 200) {
_config = resp.body['config']
.map((k, v) => MapEntry(k, v as bool))
.cast<String, bool>();
}
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/notifications', {
'config': _config,
});
if (resp.statusCode != 200) {
context.showErrorDialog(RequestException(resp));
}
context.showSnackbar('preferencesApplied'.tr);
setState(() => _isBusy = false);
}
@override
void initState() {
super.initState();
_getPreferences();
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
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.builder(
itemCount: _topicMap.length,
itemBuilder: (context, index) {
final element = _topicMap.entries.elementAt(index);
return CheckboxListTile(
title: Text(element.value),
subtitle: Text(
element.key,
style: GoogleFonts.robotoMono(fontSize: 12),
),
value: _config[element.key] ?? true,
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (value) {
setState(() {
_config[element.key] = value ?? false;
});
},
);
},
),
),
],
),
);
}
}

View File

@@ -126,7 +126,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
return;
}
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.put(
'/users/me/$position',
@@ -148,7 +148,7 @@ class _PersonalizeScreenState extends State<PersonalizeScreen> {
setState(() => _isBusy = true);
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
_birthday?.toIso8601String();
final resp = await client.put(

View File

@@ -8,8 +8,10 @@ import 'package:solian/models/account.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/subscription.dart';
import 'package:solian/providers/account_status.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/subscription.dart';
import 'package:solian/services.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
@@ -37,16 +39,25 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
bool _isBusy = true;
bool _isMakingFriend = false;
bool _isSubscribing = false;
bool _showMature = false;
Account? _userinfo;
Subscription? _subscription;
List<Post> _pinnedPosts = List.empty();
int _totalUpvote = 0, _totalDownvote = 0;
Future<void> _getSubscription() async {
setState(() => _isSubscribing = true);
_subscription = await Get.find<SubscriptionProvider>()
.getSubscriptionOnUser(_userinfo!.id);
setState(() => _isSubscribing = false);
}
Future<void> _getUserinfo() async {
setState(() => _isBusy = true);
var client = ServiceFinder.configureClient('auth');
var client = await ServiceFinder.configureClient('auth');
var resp = await client.get('/users/${widget.name}');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
@@ -56,7 +67,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
_userinfo = Account.fromJson(resp.body);
}
client = ServiceFinder.configureClient('interactive');
client = await ServiceFinder.configureClient('interactive');
resp = await client.get('/users/${widget.name}');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
@@ -70,8 +81,8 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
setState(() => _isBusy = false);
}
Future<void> getPinnedPosts() async {
final client = ServiceFinder.configureClient('interactive');
Future<void> _getPinnedPosts() async {
final client = await ServiceFinder.configureClient('interactive');
final resp = await client.get('/users/${widget.name}/pin');
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString).then((_) {
@@ -95,7 +106,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
_relationshipProvider = Get.find();
_postController = PostListController(author: widget.name);
_albumPagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files');
final client = await ServiceFinder.configureClient('files');
final resp = await client.get(
'/attachments?take=10&offset=$pageKey&author=${widget.name}&original=true',
);
@@ -115,8 +126,10 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
}
});
_getUserinfo();
getPinnedPosts();
_getUserinfo().then((_) {
_getSubscription();
_getPinnedPosts();
});
}
Widget _buildStatisticsEntry(String label, String content) {
@@ -180,6 +193,40 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
],
),
),
if (_userinfo != null && _subscription == null)
OutlinedButton(
style: const ButtonStyle(
visualDensity:
VisualDensity(horizontal: -4, vertical: -2),
),
onPressed: _isSubscribing
? null
: () async {
setState(() => _isSubscribing = true);
_subscription =
await Get.find<SubscriptionProvider>()
.subscribeToUser(_userinfo!.id);
setState(() => _isSubscribing = false);
},
child: Text('subscribe'.tr),
)
else if (_userinfo != null)
OutlinedButton(
style: const ButtonStyle(
visualDensity:
VisualDensity(horizontal: -4, vertical: -2),
),
onPressed: _isSubscribing
? null
: () async {
setState(() => _isSubscribing = true);
await Get.find<SubscriptionProvider>()
.unsubscribeFromUser(_userinfo!.id);
_subscription = null;
setState(() => _isSubscribing = false);
},
child: Text('unsubscribe'.tr),
),
if (_userinfo != null &&
!_relationshipProvider.hasFriend(_userinfo!))
IconButton(
@@ -245,7 +292,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
RefreshIndicator(
onRefresh: () => Future.wait([
_postController.reloadAllOver(),
getPinnedPosts(),
_getPinnedPosts(),
]),
child: CustomScrollView(slivers: [
SliverToBoxAdapter(
@@ -302,6 +349,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
isClickable: true,
isNestedClickable: true,
isShowEmbed: true,
showFeaturedReply: true,
onUpdate: () {
_postController.reloadAllOver();
},
@@ -352,7 +400,7 @@ class _AccountProfilePageState extends State<AccountProfilePage> {
child: AttachmentListEntry(
item: item,
isDense: true,
parentId: 'album',
parentId: 'album-$index',
showMature: _showMature,
onReveal: (value) {
setState(() => _showMature = value);

View File

@@ -1,186 +0,0 @@
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/models/stickers.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/stickers.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/auto_cache_image.dart';
import 'package:solian/widgets/stickers/sticker_uploader.dart';
class StickerScreen extends StatefulWidget {
const StickerScreen({super.key});
@override
State<StickerScreen> createState() => _StickerScreenState();
}
class _StickerScreenState extends State<StickerScreen> {
final PagingController<int, StickerPack> _pagingController =
PagingController(firstPageKey: 0);
Future<bool> _promptDelete(Sticker item, String prefix) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return false;
final confirm = await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('stickerDeletionConfirm'.tr),
content: Text(
'stickerDeletionConfirmCaption'.trParams({
'name': ':${'$prefix${item.alias}'.camelCase}:',
}),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: Text('confirm'.tr),
),
],
),
);
if (confirm != true) return false;
final client = auth.configureClient('files');
final resp = await client.delete('/stickers/${item.id}');
return resp.statusCode == 200;
}
Future<bool?> _promptUploadSticker({Sticker? edit}) {
return showDialog(
context: context,
builder: (context) => StickerUploadDialog(
edit: edit,
),
);
}
Widget _buildEmoteEntry(Sticker item, String prefix) {
final imageUrl = ServiceFinder.buildUrl(
'files',
'/attachments/${item.attachment.rid}',
);
return ListTile(
title: Text(item.name),
subtitle: Text(item.textWarpedPlaceholder),
contentPadding: const EdgeInsets.only(left: 16, right: 14),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit_square),
onPressed: () {
_promptUploadSticker(edit: item).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
_promptDelete(item, prefix).then((value) {
if (value == true) _pagingController.refresh();
});
},
),
],
),
leading: AutoCacheImage(
imageUrl,
width: 28,
height: 28,
noErrorWidget: true,
),
);
}
@override
void initState() {
final AuthProvider auth = Get.find();
final name = auth.userProfile.value!['name'];
_pagingController.addPageRequestListener((pageKey) async {
final client = ServiceFinder.configureClient('files');
final resp = await client.get(
'/stickers/manifest?take=10&offset=$pageKey&author=$name',
);
if (resp.statusCode == 200) {
final result = PaginationResult.fromJson(resp.body);
final out = result.data?.map((e) => StickerPack.fromJson(e)).toList();
if (out != null && result.data!.length >= 10) {
_pagingController.appendPage(out, pageKey + out.length);
} else if (out != null) {
_pagingController.appendLastPage(out);
}
} else {
_pagingController.error = resp.bodyString;
}
});
super.initState();
}
@override
void dispose() {
final StickerProvider sticker = Get.find();
sticker.refreshAvailableStickers();
_pagingController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () {
_promptUploadSticker().then((value) {
if (value == true) _pagingController.refresh();
});
},
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: CustomScrollView(
slivers: [
PagedSliverList<int, StickerPack>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (BuildContext context, item, int index) {
return ExpansionTile(
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(item.name),
const Gap(6),
Badge(
label: Text('#${item.id}'),
)
],
),
subtitle: Text(
item.description,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
children: item.stickers?.map((x) {
x.pack = item;
return _buildEmoteEntry(x, item.prefix);
}).toList() ??
List.empty(),
);
},
),
),
],
),
),
);
}
}

View File

@@ -1,28 +1,49 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:protocol_handler/protocol_handler.dart';
import 'package:solian/background.dart';
import 'package:solian/exceptions/request.dart';
import 'package:solian/exts.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/models/auth.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/relation.dart';
import 'package:solian/providers/websocket.dart';
import 'package:solian/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:solian/widgets/sized_container.dart';
class SignInPopup extends StatefulWidget {
const SignInPopup({super.key});
class SignInScreen extends StatefulWidget {
const SignInScreen({super.key});
@override
State<SignInPopup> createState() => _SignInPopupState();
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
class _SignInScreenState extends State<SignInScreen> {
bool _isBusy = false;
AuthTicket? _currentTicket;
List<AuthFactor>? _factors;
int? _factorPicked;
int? _factorPickedType;
int _period = 0;
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
void requestResetPassword() async {
final Map<int, (String label, IconData icon, bool isOtp)> _factorLabelMap = {
0: ('authFactorPassword'.tr, Icons.password, false),
1: ('authFactorEmail'.tr, Icons.email, true),
};
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
void _requestResetPassword() async {
final username = _usernameController.value.text;
if (username.isEmpty) {
context.showErrorDialog('signinResetPasswordHint'.tr);
@@ -31,7 +52,7 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
setState(() => _isBusy = true);
final client = ServiceFinder.configureClient('auth');
final client = await ServiceFinder.configureClient('auth');
final lookupResp = await client.get('/users/lookup?probe=$username');
if (lookupResp.statusCode != 200) {
context.showErrorDialog(lookupResp.bodyString);
@@ -52,154 +73,387 @@ class _SignInPopupState extends State<SignInPopup> with ProtocolListener {
context.showModalDialog('done'.tr, 'signinResetPasswordSent'.tr);
}
void performAction() async {
final AuthProvider auth = Get.find();
void _performNewTicket() async {
final username = _usernameController.value.text;
final password = _passwordController.value.text;
if (username.isEmpty || password.isEmpty) return;
if (username.isEmpty) return;
final client = await ServiceFinder.configureClient('auth');
setState(() => _isBusy = true);
try {
await auth.signin(context, username, password);
await Future.delayed(const Duration(milliseconds: 250), () async {
await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile();
// Create ticket
final resp = await client.post('/auth', {
'username': username,
});
} on RiskyAuthenticateException catch (e) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text('riskDetection'.tr),
content: Text('signinRiskDetected'.tr),
actions: [
TextButton(
child: Text('next'.tr),
onPressed: () {
const redirect = 'solink://auth?status=done';
launchUrlString(
ServiceFinder.buildUrl('capital',
'/auth/mfa?redirect_uri=$redirect&ticketId=${e.ticketId}'),
mode: LaunchMode.inAppWebView,
);
Navigator.pop(context);
},
)
],
);
},
);
return;
if (resp.statusCode != 200) {
throw RequestException(resp);
} else {
final result = AuthResult.fromJson(resp.body);
_currentTicket = result.ticket;
}
// Pull factors
final factorResp = await client.get('/auth/factors',
query: {'ticketId': _currentTicket!.id.toString()});
if (factorResp.statusCode != 200) {
throw RequestException(factorResp);
} else {
final result = List<AuthFactor>.from(
factorResp.body.map((x) => AuthFactor.fromJson(x)),
);
_factors = result;
}
setState(() => _period++);
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
}
Get.find<WebSocketProvider>().registerPushNotifications();
Navigator.pop(context, true);
}
@override
void initState() {
protocolHandler.addListener(this);
super.initState();
void _performGetFactorCode() async {
if (_factorPicked == null) return;
final client = await ServiceFinder.configureClient('auth');
setState(() => _isBusy = true);
try {
// Request one-time-password code
final resp = await client.post('/auth/factors/$_factorPicked', {});
if (resp.statusCode != 200 && resp.statusCode != 204) {
throw RequestException(resp);
} else {
_factorPickedType = _factors!
.where(
(x) => x.id == _factorPicked,
)
.first
.type;
}
setState(() => _period++);
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
}
}
@override
void dispose() {
protocolHandler.removeListener(this);
super.dispose();
void _performCheckTicket() async {
final AuthProvider auth = Get.find();
final password = _passwordController.value.text;
if (password.isEmpty) return;
final client = await ServiceFinder.configureClient('auth');
setState(() => _isBusy = true);
try {
// Check ticket
final resp = await client.request('/auth', 'PATCH', body: {
'ticket_id': _currentTicket!.id,
'factor_id': _factorPicked!,
'code': password,
});
if (resp.statusCode != 200) {
throw RequestException(resp);
}
final result = AuthResult.fromJson(resp.body);
_currentTicket = result.ticket;
_passwordController.clear();
// Finish sign in if possible
if (result.isFinished) {
await auth.signin(context, _currentTicket!);
await Future.delayed(const Duration(milliseconds: 250), () async {
await auth.refreshAuthorizeStatus();
await auth.refreshUserProfile();
Get.find<ChannelProvider>().refreshAvailableChannel();
Get.find<RealmProvider>().refreshAvailableRealms();
Get.find<RelationshipProvider>().refreshRelativeList();
Get.find<WebSocketProvider>().registerPushNotifications();
autoConfigureBackgroundNotificationService();
autoStartBackgroundNotificationService();
Navigator.pop(context, true);
});
} else {
// Skip the first step
_factorPicked = null;
_factorPickedType = null;
setState(() => _period += 2);
}
} catch (e) {
context.showErrorDialog(e);
return;
} finally {
setState(() => _isBusy = false);
}
}
@override
void onProtocolUrlReceived(String url) {
final uri = url.replaceFirst('solink://', '');
if (uri == 'auth?status=done') {
closeInAppWebView();
performAction();
void _previousStep() {
assert(_period > 0);
switch (_period % 3) {
case 1:
_currentTicket = null;
_factors = null;
_factorPicked = null;
case 2:
_passwordController.clear();
_factorPickedType = null;
default:
setState(() => _period--);
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.9,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 4),
Text(
'signinGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performAction(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
return Material(
color: Theme.of(context).colorScheme.surface,
child: CenteredContainer(
maxWidth: 360,
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
child: child,
);
},
child: switch (_period % 3) {
1 => Column(
key: const ValueKey<int>(1),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextButton(
onPressed: _isBusy ? null : () => requestResetPassword(),
style: TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr),
),
TextButton(
onPressed: _isBusy ? null : () => performAction(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
Text(
'signinPickFactor'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: _factors
?.map(
(x) => CheckboxListTile(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
secondary: Icon(
_factorLabelMap[x.type]?.$2 ??
Icons.question_mark,
),
title: Text(
_factorLabelMap[x.type]?.$1 ?? 'unknown'.tr,
),
enabled: !_currentTicket!.factorTrail
.contains(x.id),
value: _factorPicked == x.id,
onChanged: (value) {
if (value == true) {
setState(() => _factorPicked = x.id);
}
},
),
)
.toList() ??
List.empty(),
),
),
Text(
'signinMultiFactor'.trParams(
{'n': _currentTicket!.stepRemain.toString()},
),
style: TextStyle(color: _unFocusColor, fontSize: 12),
).paddingOnly(left: 16, right: 16),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: (_isBusy || _period > 1)
? null
: () => _previousStep(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chevron_left),
Text('prev'.tr),
],
),
),
TextButton(
onPressed:
_isBusy ? null : () => _performGetFactorCode(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
),
],
),
2 => Column(
key: const ValueKey<int>(2),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
Text(
'signinEnterPassword'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _passwordController,
obscureText: true,
autofillHints: [
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? AutofillHints.password
: AutofillHints.oneTimeCode
],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText:
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? 'passwordOneTime'.tr
: 'password'.tr,
helperText:
(_factorLabelMap[_factorPickedType]?.$3 ?? true)
? 'passwordOneTimeInputHint'.tr
: 'passwordInputHint'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performCheckTicket(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _isBusy ? null : () => _previousStep(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.chevron_left),
Text('prev'.tr),
],
),
),
TextButton(
onPressed: _isBusy ? null : () => _performCheckTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
),
_ => Column(
key: const ValueKey<int>(0),
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child:
Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
Text(
'signinGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
helperText: 'usernameInputHint'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: _isBusy ? null : (_) => _performNewTicket(),
),
const Gap(12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed:
_isBusy ? null : () => _requestResetPassword(),
style:
TextButton.styleFrom(foregroundColor: Colors.grey),
child: Text('forgotPassword'.tr),
),
TextButton(
onPressed: _isBusy ? null : () => _performNewTicket(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
),
],
),
],
),
},
),
),
);

View File

@@ -3,15 +3,16 @@ import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/services.dart';
import 'package:solian/widgets/sized_container.dart';
class SignUpPopup extends StatefulWidget {
const SignUpPopup({super.key});
class SignUpScreen extends StatefulWidget {
const SignUpScreen({super.key});
@override
State<SignUpPopup> createState() => _SignUpPopupState();
State<SignUpScreen> createState() => _SignUpScreenState();
}
class _SignUpPopupState extends State<SignUpPopup> {
class _SignUpScreenState extends State<SignUpScreen> {
final _emailController = TextEditingController();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
@@ -27,7 +28,7 @@ class _SignUpPopupState extends State<SignUpPopup> {
nickname.isEmpty ||
password.isEmpty) return;
final client = ServiceFinder.configureClient('auth');
final client = await ServiceFinder.configureClient('auth');
final resp = await client.post('/users', {
'name': username,
@@ -61,100 +62,97 @@ class _SignUpPopupState extends State<SignUpPopup> {
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.9,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 4),
Text(
'signupGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
return Material(
color: Theme.of(context).colorScheme.surface,
child: CenteredContainer(
maxWidth: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Image.asset('assets/logo.png', width: 64, height: 64),
).paddingOnly(bottom: 8, left: 4),
Text(
'signupGreeting'.tr,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w900,
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'nickname'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).paddingOnly(left: 4, bottom: 16),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _usernameController,
autofillHints: const [AutofillHints.username],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'username'.tr,
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'email'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _nicknameController,
autofillHints: const [AutofillHints.nickname],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'nickname'.tr,
),
const Gap(12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performAction(context),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
autocorrect: false,
enableSuggestions: false,
controller: _emailController,
autofillHints: const [AutofillHints.email],
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'email'.tr,
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
onPressed: () => performAction(context),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
const Gap(12),
TextField(
obscureText: true,
autocorrect: false,
enableSuggestions: false,
autofillHints: const [AutofillHints.password],
controller: _passwordController,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'password'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performAction(context),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: TextButton(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('next'.tr),
const Icon(Icons.chevron_right),
],
),
)
],
),
onPressed: () => performAction(context),
),
)
],
),
),
);

View File

@@ -97,7 +97,6 @@ class _ChannelChatScreenState extends State<ChannelChatScreen>
setState(() => _ongoingCall = Call.fromJson(resp.body));
}
} catch (e) {
print((e as dynamic).stackTrace);
context.showErrorDialog(e);
}

View File

@@ -79,7 +79,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
setState(() => _isBusy = true);
final client = auth.configureClient('messaging');
final client = await auth.configureClient('messaging');
final resp = await client
.put('/channels/${widget.realm}/${widget.channel.alias}/members/me', {
@@ -114,7 +114,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
ListTile(
leading: const Icon(Icons.settings),
trailing: const Icon(Icons.chevron_right),
title: Text('channelSettings'.tr.capitalize!),
title: Text('channelSettings'.tr),
onTap: () async {
AppRouter.instance
.pushNamed(
@@ -173,7 +173,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
children: [
ListTile(
leading: const Icon(Icons.notifications_active),
title: Text('channelNotifyLevel'.tr.capitalize!),
title: Text('channelNotifyLevel'.tr),
trailing: DropdownButtonHideUnderline(
child: DropdownButton2<int>(
isExpanded: true,
@@ -208,7 +208,7 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
ListTile(
leading: const Icon(Icons.supervisor_account),
trailing: const Icon(Icons.chevron_right),
title: Text('channelMembers'.tr.capitalize!),
title: Text('channelMembers'.tr),
onTap: () => showMemberList(),
),
...(_isOwned ? ownerActions : List.empty()),

View File

@@ -35,13 +35,14 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
bool _isPublic = false;
bool _isCommunity = false;
void applyChannel() async {
void _applyChannel() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
if (_aliasController.value.text.isEmpty) randomizeAlias();
if (_aliasController.value.text.isEmpty) _randomizeAlias();
setState(() => _isBusy = true);
@@ -52,7 +53,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
'is_encrypted': _isPublic,
};
Response? resp;
@@ -71,35 +72,44 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
setState(() => _isBusy = false);
}
void randomizeAlias() {
void _randomizeAlias() {
_aliasController.text =
const Uuid().v4().replaceAll('-', '').substring(0, 12);
}
void syncWidget() {
void _syncWidget() {
if (widget.edit != null) {
_aliasController.text = widget.edit!.alias;
_nameController.text = widget.edit!.name;
_descriptionController.text = widget.edit!.description;
_isEncrypted = widget.edit!.isEncrypted;
_isPublic = widget.edit!.isPublic;
_isCommunity = widget.edit!.isCommunity;
}
}
void cancelAction() {
void _cancelAction() {
AppRouter.instance.pop();
}
@override
void initState() {
syncWidget();
_syncWidget();
super.initState();
}
@override
void dispose() {
_aliasController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final notifyBannerActions = [
TextButton(
onPressed: cancelAction,
onPressed: _cancelAction,
child: Text('cancel'.tr),
),
];
@@ -113,7 +123,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
toolbarHeight: AppTheme.toolbarHeight(context),
actions: [
TextButton(
onPressed: _isBusy ? null : () => applyChannel(),
onPressed: _isBusy ? null : () => _applyChannel(),
child: Text('apply'.tr.toUpperCase()),
)
],
@@ -164,7 +174,7 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
onPressed: () => _randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
@@ -196,12 +206,17 @@ class _ChannelOrganizeScreenState extends State<ChannelOrganizeScreen> {
),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text('channelEncrypted'.tr),
value: _isEncrypted,
onChanged: (widget.edit?.isEncrypted ?? false)
? null
: (newValue) =>
setState(() => _isEncrypted = newValue ?? false),
title: Text('channelPublic'.tr),
value: _isPublic,
onChanged: (value) =>
setState(() => _isPublic = value ?? false),
controlAffinity: ListTileControlAffinity.leading,
),
CheckboxListTile(
title: Text('channelCommunity'.tr),
value: _isCommunity,
onChanged: (value) =>
setState(() => _isCommunity = value ?? false),
controlAffinity: ListTileControlAffinity.leading,
),
],

View File

@@ -102,7 +102,7 @@ class _ChatScreenState extends State<ChatScreen> {
body: Obx(() {
if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay(
onSignedIn: () => _channels.refreshAvailableChannel(),
onDone: () => _channels.refreshAvailableChannel(),
);
}
@@ -125,7 +125,11 @@ class _ChatScreenState extends State<ChatScreen> {
child: Obx(
() => ChannelListWidget(
noCategory: true,
channels: _channels.directChannels,
channels: List.from([
..._channels.groupChannels
.where((x) => x.realmId == null),
..._channels.directChannels
]),
selfId: selfId,
useReplace: true,
),

View File

@@ -379,6 +379,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
isClickable: true,
isShowEmbed: true,
isNestedClickable: true,
showFeaturedReply: true,
onUpdate: (_) {
_pullPosts();
},

View File

@@ -151,7 +151,7 @@ class _FeedScreenState extends State<FeedScreen>
);
} else {
return SigninRequiredOverlay(
onSignedIn: () => _postController.reloadAllOver(),
onDone: () => _postController.reloadAllOver(),
);
}
}),

View File

@@ -3,6 +3,7 @@ import 'package:get/get.dart';
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/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart';
@@ -26,6 +27,7 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Future<Post?> getDetail() async {
if (widget.post != null) {
item = widget.post;
Get.find<LastReadProvider>().feedLastReadAt = item?.id;
return widget.post;
}
@@ -38,6 +40,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
context.showErrorDialog(e).then((_) => Navigator.pop(context));
}
Get.find<LastReadProvider>().feedLastReadAt = item?.id;
return item;
}

View File

@@ -75,7 +75,7 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
setState(() => _isBusy = true);
final client = auth.configureClient('interactive');
final client = await auth.configureClient('interactive');
Response resp;
if (widget.edit != null) {

View File

@@ -7,10 +7,13 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/services.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/signin_required_overlay.dart';
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/sized_container.dart';
@@ -84,7 +87,7 @@ class _RealmListScreenState extends State<RealmListScreen> {
body: Obx(() {
if (auth.isAuthorized.isFalse) {
return SigninRequiredOverlay(
onSignedIn: () => _getRealms(),
onDone: () => _getRealms(),
);
}
@@ -128,19 +131,34 @@ class _RealmListScreenState extends State<RealmListScreen> {
children: [
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
child: (element.banner?.isEmpty ?? true)
? const SizedBox.shrink()
: AutoCacheImage(
ServiceFinder.buildUrl(
'uc',
'/attachments/${element.banner}',
),
fit: BoxFit.cover,
),
),
const Positioned(
Positioned(
bottom: -30,
left: 18,
child: CircleAvatar(
radius: 24,
backgroundColor: Colors.indigo,
child: FaIcon(
FontAwesomeIcons.globe,
color: Colors.white,
size: 18,
),
),
child: (element.avatar?.isEmpty ?? true)
? CircleAvatar(
radius: 24,
backgroundColor:
Theme.of(context).colorScheme.primary,
child: const FaIcon(
FontAwesomeIcons.globe,
color: Colors.white,
size: 18,
),
)
: AccountAvatar(
content: element.avatar!,
bgColor: Theme.of(context).colorScheme.primary,
),
),
],
),

View File

@@ -69,7 +69,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
ListTile(
leading: const Icon(Icons.settings),
trailing: const Icon(Icons.chevron_right),
title: Text('realmSettings'.tr.capitalize!),
title: Text('realmSettings'.tr),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () async {
AppRouter.instance
.pushNamed(
@@ -120,14 +121,16 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
child: ListView(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.supervisor_account),
trailing: const Icon(Icons.chevron_right),
title: Text('realmMembers'.tr.capitalize!),
title: Text('realmMembers'.tr),
onTap: () => showMemberList(),
),
...(_isOwned ? ownerActions : List.empty()),
const Divider(thickness: 0.3),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: _isOwned
? const Icon(Icons.delete)
: const Icon(Icons.exit_to_app),

View File

@@ -1,9 +1,15 @@
import 'dart:io';
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';
import 'package:solian/exts.dart';
import 'package:solian/models/attachment.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
@@ -29,26 +35,30 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
bool _isBusy = false;
final _aliasController = TextEditingController();
final _avatarController = TextEditingController();
final _bannerController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isCommunity = false;
bool _isPublic = false;
void applyRealm() async {
void _applyRealm() async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
if (_aliasController.value.text.isEmpty) randomizeAlias();
if (_aliasController.value.text.isEmpty) _randomizeAlias();
setState(() => _isBusy = true);
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final payload = {
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'avatar': _avatarController.value.text,
'banner': _bannerController.value.text,
'is_public': _isPublic,
'is_community': _isCommunity,
};
@@ -68,31 +78,110 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
setState(() => _isBusy = false);
}
void randomizeAlias() {
final _imagePicker = ImagePicker();
Future<void> _editImage(String position) async {
final AuthProvider auth = Get.find();
if (auth.isAuthorized.isFalse) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
CroppedFile? croppedFile = await ImageCropper().cropImage(
sourcePath: image.path,
uiSettings: [
AndroidUiSettings(
toolbarTitle: 'cropImage'.tr,
toolbarColor: Theme.of(context).colorScheme.primary,
toolbarWidgetColor: Theme.of(context).colorScheme.onPrimary,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
IOSUiSettings(
title: 'cropImage'.tr,
aspectRatioPresets: [
if (position == 'avatar') CropAspectRatioPreset.square,
if (position == 'banner') _BannerCropAspectRatioPreset(),
],
),
WebUiSettings(
context: context,
),
],
);
if (croppedFile == null) return;
final file = File(croppedFile.path);
setState(() => _isBusy = true);
final AttachmentProvider attach = Get.find();
Attachment? attachResult;
try {
attachResult = await attach.createAttachmentDirectly(
await file.readAsBytes(),
file.path,
'avatar',
null,
);
} catch (e) {
setState(() => _isBusy = false);
context.showErrorDialog(e);
return;
}
switch (position) {
case 'avatar':
_avatarController.text = attachResult.rid;
break;
case 'banner':
_bannerController.text = attachResult.rid;
break;
}
setState(() => _isBusy = false);
}
void _randomizeAlias() {
_aliasController.text =
const Uuid().v4().replaceAll('-', '').substring(0, 12);
}
void syncWidget() {
void _syncWidget() {
if (widget.edit != null) {
_aliasController.text = widget.edit!.alias;
_nameController.text = widget.edit!.name;
_descriptionController.text = widget.edit!.description;
_avatarController.text = widget.edit!.avatar ?? '';
_bannerController.text = widget.edit!.banner ?? '';
_isPublic = widget.edit!.isPublic;
_isCommunity = widget.edit!.isCommunity;
}
}
void cancelAction() {
void _cancelAction() {
AppRouter.instance.pop();
}
@override
void initState() {
syncWidget();
_syncWidget();
super.initState();
}
@override
void dispose() {
_aliasController.dispose();
_avatarController.dispose();
_bannerController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Material(
@@ -105,7 +194,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
toolbarHeight: AppTheme.toolbarHeight(context),
actions: [
TextButton(
onPressed: _isBusy ? null : () => applyRealm(),
onPressed: _isBusy ? null : () => _applyRealm(),
child: Text('apply'.tr.toUpperCase()),
)
],
@@ -126,7 +215,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
),
actions: [
TextButton(
onPressed: cancelAction,
onPressed: _cancelAction,
child: Text('cancel'.tr),
),
],
@@ -150,7 +239,7 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
onPressed: () => _randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
@@ -166,6 +255,55 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
FocusManager.instance.primaryFocus?.unfocus(),
).paddingSymmetric(horizontal: 16, vertical: 8),
const Divider(thickness: 0.3),
Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _avatarController,
decoration: InputDecoration.collapsed(
hintText: 'realmAvatar'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: _isBusy ? null : () => _editImage('avatar'),
child: const Icon(Icons.upload),
)
],
).paddingSymmetric(horizontal: 16, vertical: 2),
Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _bannerController,
decoration: InputDecoration.collapsed(
hintText: 'realmBanner'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: _isBusy ? null : () => _editImage('banner'),
child: const Icon(Icons.upload),
)
],
).paddingSymmetric(horizontal: 16, vertical: 2),
const Divider(thickness: 0.3),
Expanded(
child: TextField(
minLines: 5,
@@ -202,3 +340,11 @@ class _RealmOrganizeScreenState extends State<RealmOrganizeScreen> {
);
}
}
class _BannerCropAspectRatioPreset extends CropAspectRatioPresetData {
@override
(int, int)? get data => (16, 7);
@override
String get name => '16x7';
}

View File

@@ -3,6 +3,8 @@ import 'package:get/get.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:solian/exts.dart';
import 'package:solian/platform.dart';
import 'package:solian/providers/database/database.dart';
import 'package:solian/providers/theme_switcher.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
@@ -15,7 +17,7 @@ class SettingScreen extends StatefulWidget {
}
class _SettingScreenState extends State<SettingScreen> {
late final SharedPreferences _prefs;
SharedPreferences? _prefs;
Widget _buildCaptionHeader(String title) {
return Container(
@@ -41,7 +43,7 @@ class _SettingScreenState extends State<SettingScreen> {
seedColor: color,
),
);
_prefs.setInt('global_theme_color', color.value);
_prefs?.setInt('global_theme_color', color.value);
context.clearSnackbar();
context.showSnackbar('themeColorApplied'.tr);
},
@@ -61,6 +63,9 @@ class _SettingScreenState extends State<SettingScreen> {
super.initState();
SharedPreferences.getInstance().then((inst) {
_prefs = inst;
if (mounted) {
setState(() {});
}
});
}
@@ -80,7 +85,60 @@ class _SettingScreenState extends State<SettingScreen> {
.toList(),
).paddingSymmetric(horizontal: 12, vertical: 8),
),
_buildCaptionHeader('notification'.tr),
Tooltip(
message: 'settingsNotificationBgServiceDesc'.tr,
child: CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
secondary: const Icon(Icons.system_security_update_warning),
enabled: PlatformInfo.isAndroid,
title: Text('settingsNotificationBgService'.tr),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('holdToSeeDetail'.tr),
Text(
'needRestartToApply'.tr,
style: const TextStyle(fontWeight: FontWeight.bold),
)
],
),
value:
_prefs?.getBool('service_background_notification') ?? false,
onChanged: (value) {
_prefs
?.setBool('service_background_notification', value ?? false)
.then((_) {
setState(() {});
});
},
),
),
_buildCaptionHeader('more'.tr),
ListTile(
leading: const Icon(Icons.delete_sweep),
trailing: const Icon(Icons.chevron_right),
subtitle: FutureBuilder(
future: AppDatabase.getDatabaseSize(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return Text('localDatabaseSize'.trParams(
{'size': 'unknown'.tr},
));
}
return Text('localDatabaseSize'.trParams(
{'size': snapshot.data!.formatBytes()},
));
},
),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('localDatabaseWipe'.tr),
onTap: () {
AppDatabase.removeDatabase().then((_) {
setState(() {});
});
},
),
ListTile(
leading: const Icon(Icons.info_outline),
trailing: const Icon(Icons.chevron_right),
@@ -90,15 +148,6 @@ class _SettingScreenState extends State<SettingScreen> {
AppRouter.instance.pushNamed('about');
},
),
ListTile(
leading: const Icon(Icons.delete_sweep),
trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(horizontal: 22),
title: Text('messageHistoryWipe'.tr),
onTap: () {
// TODO Wipe message history
},
),
],
),
);

View File

@@ -1,28 +1,58 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:get/get.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:solian/platform.dart';
abstract class ServiceFinder {
static const bool devFlag = false;
static const String dealerUrl =
devFlag ? 'http://localhost:8442' : 'https://api.sn.solsynth.dev';
static const String capitalUrl =
devFlag ? 'http://localhost:8444' : 'https://solsynth.dev';
static String buildUrl(String serviceName, String? append) {
append ??= '';
if (serviceName == 'dealer') {
return '$dealerUrl$append';
} else if (serviceName == 'capital') {
return '$capitalUrl$append';
}
return '$dealerUrl/cgi/$serviceName$append';
}
static GetConnect configureClient(String serviceName,
{timeout = const Duration(seconds: 5)}) {
static Future<String> getUserAgent() async {
final String platformInfo;
if (PlatformInfo.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
platformInfo =
'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
} else if (PlatformInfo.isIOS) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
} else if (PlatformInfo.isMacOS) {
final deviceInfo = await DeviceInfoPlugin().macOsInfo;
platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
} else if (PlatformInfo.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
platformInfo =
'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
} else if (PlatformInfo.isLinux) {
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
platformInfo = 'Linux; ${deviceInfo.prettyName}';
} else if (PlatformInfo.isWeb) {
final deviceInfo = await DeviceInfoPlugin().webBrowserInfo;
platformInfo = 'Web; ${deviceInfo.vendor}';
} else {
platformInfo = 'Unknown';
}
final packageInfo = await PackageInfo.fromPlatform();
return 'Solian/${packageInfo.version}+${packageInfo.buildNumber} ($platformInfo)';
}
static Future<GetConnect> configureClient(String serviceName,
{timeout = const Duration(seconds: 5)}) async {
final client = GetConnect(
timeout: timeout,
userAgent: 'Solian/1.1',
userAgent: await getUserAgent(),
sendUserAgent: true,
);
client.httpClient.baseUrl = buildUrl(serviceName, null);

View File

@@ -24,6 +24,8 @@ class TitleShell extends StatelessWidget {
@override
Widget build(BuildContext context) {
assert(state != null || title != null);
return Scaffold(
appBar: showAppBar
? AppBar(

View File

@@ -26,7 +26,7 @@ class _AccountProfilePopupState extends State<AccountProfilePopup> {
setState(() => _isBusy = true);
try {
final client = ServiceFinder.configureClient('auth');
final client = await ServiceFinder.configureClient('auth');
final resp = await client.get('/users/${widget.name}');
if (resp.statusCode == 200) {
setState(() {

View File

@@ -36,16 +36,13 @@ class _AccountSelectorState extends State<AccountSelector> {
_revertSelectedUsers() async {
if (widget.initialSelection?.isEmpty ?? true) return;
final client = ServiceFinder.configureClient('auth');
final client = await ServiceFinder.configureClient('auth');
final idQuery = widget.initialSelection!.join(',');
final resp = await client.get('/users?id=$idQuery');
setState(() {
_selectedUsers.addAll(
resp.body
.map((e) => Account.fromJson(e))
.toList()
.cast<Account>(),
resp.body.map((e) => Account.fromJson(e)).toList().cast<Account>(),
);
});
}
@@ -73,7 +70,7 @@ class _AccountSelectorState extends State<AccountSelector> {
if (_probeController.text.isEmpty) return;
final client = auth.configureClient('auth');
final client = await auth.configureClient('auth');
final resp = await client.get(
'/users/search?probe=${_probeController.text}',
);
@@ -156,7 +153,8 @@ class _AccountSelectorState extends State<AccountSelector> {
}
setState(() {
final idx = _selectedUsers.indexWhere((x) => x.id == element.id);
final idx = _selectedUsers
.indexWhere((x) => x.id == element.id);
if (idx != -1) {
_selectedUsers.removeAt(idx);
} else {

View File

@@ -1,49 +1,43 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/sized_container.dart';
class SigninRequiredOverlay extends StatelessWidget {
final Function onSignedIn;
final Function onDone;
const SigninRequiredOverlay({super.key, required this.onSignedIn});
const SigninRequiredOverlay({super.key, required this.onDone});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 280),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.login,
size: 48,
),
const Gap(8),
Text(
'signinRequired'.tr,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
'signinRequiredHint'.tr,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
child: CenteredContainer(
maxWidth: 280,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.login,
size: 48,
),
const Gap(8),
Text(
'signinRequired'.tr,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
Text(
'signinRequiredHint'.tr,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) => const SignInPopup(),
).then((value) {
if (value != null) onSignedIn();
AppRouter.instance.pushNamed('signin').then((value) {
if (value != null) onDone();
});
},
);

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math' as math;
import 'package:desktop_drop/desktop_drop.dart';
import 'package:dismissible_page/dismissible_page.dart';
@@ -209,25 +208,6 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
});
}
String _formatBytes(int bytes, {int decimals = 2}) {
if (bytes == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(bytes) / math.log(k)).floor().toInt();
return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
void _revertMetadataList() {
final AttachmentProvider attach = Get.find();
@@ -332,7 +312,9 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
}
Widget _buildQueueEntry(AttachmentUploadTask element, int index) {
final extName = extension(element.file.path).substring(1);
final extName = element.file.name.contains('.')
? extension(element.file.name).substring(1)
: '';
final canBeCrop = ['png', 'jpg', 'jpeg', 'gif'].contains(extName);
return Container(
@@ -367,7 +349,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
return const SizedBox.shrink();
}
return Text(
_formatBytes(snapshot.data!),
snapshot.data!.formatBytes(),
style: Theme.of(context).textTheme.bodySmall,
);
},
@@ -502,7 +484,7 @@ class _AttachmentEditorPopupState extends State<AttachmentEditorPopup> {
),
),
Text(
'${fileType[0].toUpperCase()}${fileType.substring(1)} · ${_formatBytes(element.size)}',
'${fileType[0].toUpperCase()}${fileType.substring(1)} · ${element.size.formatBytes()}',
style: const TextStyle(fontSize: 12),
),
],

View File

@@ -93,14 +93,14 @@ class _AttachmentEditorThumbnailDialogState
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('postThumbnail'.tr),
title: Text('attachmentThumbnail'.tr),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Card(
margin: EdgeInsets.zero,
child: ListTile(
title: Text('postThumbnailAttachmentNew'.tr),
title: Text('attachmentThumbnailAttachmentNew'.tr),
contentPadding: const EdgeInsets.only(left: 12, right: 9),
trailing: const Icon(Icons.chevron_right),
shape: const RoundedRectangleBorder(
@@ -122,7 +122,7 @@ class _AttachmentEditorThumbnailDialogState
isDense: true,
border: const OutlineInputBorder(),
prefixText: '#',
labelText: 'postThumbnailAttachment'.tr,
labelText: 'attachmentThumbnailAttachment'.tr,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),

View File

@@ -38,25 +38,6 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
Color get _unFocusColor =>
Theme.of(context).colorScheme.onSurface.withOpacity(0.75);
String _formatBytes(int bytes, {int decimals = 2}) {
if (bytes == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(bytes) / math.log(k)).floor().toInt();
return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
double _getRatio() {
final value = widget.item.metadata?['ratio'];
if (value == null) return 1;
@@ -274,7 +255,7 @@ class _AttachmentFullScreenState extends State<AttachmentFullScreen> {
style: metaTextStyle,
),
Text(
_formatBytes(widget.item.size),
widget.item.size.formatBytes(),
style: metaTextStyle,
),
Text(

Some files were not shown because too many files have changed in this diff Show More