Compare commits

..

43 Commits

Author SHA1 Message Date
LittleSheep
d46d584ff3 🐛 Bug fixes and optimization 2024-05-13 22:52:41 +08:00
LittleSheep
f43f9e91f6 Better websocket reconnection and maintainer 2024-05-13 22:12:37 +08:00
LittleSheep
b9461e5019 💄 Optimize connection experience 2024-05-12 22:29:38 +08:00
LittleSheep
8e0e2dacfe 🐛 Fix connection issue 2024-05-12 22:17:32 +08:00
LittleSheep
b4d1d62e9b 🚑 Fix services url issue 2024-05-12 21:27:55 +08:00
LittleSheep
6f7ae4467c 🐛 Bug fixes and optimization 2024-05-12 20:59:33 +08:00
LittleSheep
98547708af E2EE and Keypair 2024-05-12 20:15:12 +08:00
LittleSheep
08d0a99b10 🐛 Bug fixes 2024-05-11 23:22:35 +08:00
LittleSheep
5ce6543275 Message view source 2024-05-10 23:42:59 +08:00
LittleSheep
40aa16e971 ⬆️ Support some new server stuff 2024-05-10 23:17:01 +08:00
LittleSheep
c1d3bac0c8 🐛 Fix several known bugs 2024-05-08 22:01:06 +08:00
LittleSheep
a4f8c65aa5 💄 Realm shortcut 2024-05-07 23:38:12 +08:00
LittleSheep
3bcdc67285 ♻️ Better chat connection method 2024-05-07 23:07:51 +08:00
LittleSheep
dffa0077de 💄 Better user send message experience 2024-05-07 21:49:24 +08:00
LittleSheep
b3e266d564 🐛 Bug fixes on realm missing post editor 2024-05-07 13:06:26 +08:00
LittleSheep
0c87bbbce1 🐛 Bug fixes on realm UI 2024-05-06 23:36:54 +08:00
LittleSheep
ae4d9cf81a Complete the realm system 2024-05-06 23:30:30 +08:00
LittleSheep
22c2a80650 Complete the realm system 2024-05-06 20:57:52 +08:00
LittleSheep
0b9439262c Realm members & edit 2024-05-05 23:51:46 +08:00
LittleSheep
384d861d56 Realm basis 2024-05-05 23:01:08 +08:00
LittleSheep
cf0d473a40 ⬆️ Support newer version of server messaging 2024-05-05 01:53:31 +08:00
LittleSheep
efc46dbbc5 🐛 Bug fixes 2024-05-03 23:38:51 +08:00
LittleSheep
f96ca899b5 Personal page 2024-05-03 18:20:32 +08:00
LittleSheep
083975bcd9 Batch mark notify as read 2024-05-03 16:35:28 +08:00
LittleSheep
740c704fb8 Can edit avatar and banner 2024-05-03 16:16:42 +08:00
LittleSheep
e742338d92 🐛 Bug fixes of scaffold 2024-05-03 14:04:34 +08:00
LittleSheep
d179d907ad 💄 Better large screen optimization! 2024-05-03 13:52:41 +08:00
LittleSheep
c0680a3134 🎨 Optimized code structure, rename a lot of widgets
💄 Better large screen support in chat
2024-05-03 13:39:52 +08:00
LittleSheep
e080f49935 Cached network images 2024-05-03 12:15:54 +08:00
LittleSheep
9df4aba56c 💄 Animated chat history 2024-05-03 01:55:12 +08:00
LittleSheep
ec69b34877 Better attachments upload, and supports files 2024-05-03 00:09:33 +08:00
LittleSheep
9f6942a8cb 🐛 Dispose current call when hang up 2024-05-02 21:36:23 +08:00
LittleSheep
1a5faabf86 🐛 Bug fixes of infinite reloading 2024-05-02 21:19:37 +08:00
LittleSheep
e5a4554bdd 🐛 Bug fixes of bug fixes 2024-05-02 20:52:20 +08:00
LittleSheep
7e4fa47d00 🐛 Bug fixes 2024-05-02 20:45:24 +08:00
LittleSheep
7633619edd Basis personalize 2024-05-02 18:56:40 +08:00
LittleSheep
52c09151a6 Account page two pane 2024-05-02 12:51:16 +08:00
LittleSheep
3089e1f8d2 Rewrite http client 2024-05-02 12:16:01 +08:00
LittleSheep
d968169e42 🐛 Bug fixes 2024-05-02 01:38:45 +08:00
LittleSheep
b39c8c770e Basic large screen support 2024-05-02 00:49:38 +08:00
LittleSheep
fceb3edbc6 Dark mode 2024-05-01 22:35:58 +08:00
LittleSheep
7c4427e84a 💄 Optimize UX 2024-05-01 19:39:48 +08:00
LittleSheep
fd200105c0 Sign up screen 2024-05-01 18:10:57 +08:00
104 changed files with 5333 additions and 1936 deletions

View File

@@ -20,7 +20,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "7.3.0" apply false
id "org.jetbrains.kotlin.android" version "1.7.10" apply false
id "org.jetbrains.kotlin.android" version "1.9.23" apply false
}
include ":app"

View File

@@ -4,6 +4,40 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.8):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.8)
- DKImagePickerController/PhotoGallery (4.3.8):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.8)
- DKPhotoGallery (0.0.17):
- DKPhotoGallery/Core (= 0.0.17)
- DKPhotoGallery/Model (= 0.0.17)
- DKPhotoGallery/Preview (= 0.0.17)
- DKPhotoGallery/Resource (= 0.0.17)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.17):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.17):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.17):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
@@ -32,6 +66,13 @@ PODS:
- Flutter
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.19.1):
- SDWebImage/Core (= 5.19.1)
- SDWebImage/Core (5.19.1)
- sqflite (0.0.3):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
- url_launcher_ios (0.0.1):
- Flutter
- volume_controller (0.0.1):
@@ -45,6 +86,7 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/darwin`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
@@ -58,6 +100,7 @@ DEPENDENCIES:
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
@@ -65,6 +108,10 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- DKImagePickerController
- DKPhotoGallery
- SDWebImage
- SwiftyGif
- WebRTC-SDK
EXTERNAL SOURCES:
@@ -72,6 +119,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/darwin"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
flutter_local_notifications:
@@ -98,6 +147,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
@@ -110,6 +161,9 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
DKImagePickerController: a7836546cfdfe014171694f643a7d575bc8ace7f
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
@@ -123,6 +177,9 @@ SPEC CHECKSUMS:
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: 40b0b4053e36c660a764958bff99eed16610acbb
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 6116280ddcfe98ab8820085d8d76ae7449447586
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1

View File

@@ -2,6 +2,7 @@
"appName": "Solar Network",
"explore": "Explore",
"chat": "Chat",
"realm": "Realm",
"account": "Account",
"riskDetection": "Risk Detection",
"signIn": "Sign In",
@@ -9,7 +10,9 @@
"signInRequired": "Sign in required",
"signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signUp": "Sign Up",
"signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Networks!",
"signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Network!",
"signUpDone": "Sign Up Successfully",
"signUpDoneCaption": "Welcome to join Solar Network! Now go to sign in!",
"signOut": "Sign Out",
"poweredBy": "Powered by Project Hydrogen",
"copyright": "Copyright © 2024 Solsynth LLC",
@@ -19,22 +22,32 @@
"email": "Email Address",
"nickname": "Nickname",
"username": "Username",
"firstName": "First Name",
"lastName": "Last Name",
"description": "Description",
"birthday": "Birthday",
"password": "Password",
"next": "Next",
"join": "Join",
"edit": "Edit",
"apply": "Apply",
"delete": "Delete",
"exit": "Exit",
"action": "Action",
"reset": "Reset",
"cancel": "Cancel",
"report": "Report",
"reply": "Reply",
"export": "Export",
"import": "Import",
"settings": "Settings",
"errorHappened": "An Error Occurred",
"notification": "Notification",
"notifyDone": "You're done!",
"notifyDoneCaption": "There are no notifications unread for you.",
"notifyListHint": "Pull to refresh, swipe to dismiss",
"notifyMarkAllRead": "Mark all as read",
"notifyMarkAllReadDone": "Marked all notifications as read",
"friend": "Friend",
"friendPending": "Pending",
"friendActive": "Active",
@@ -43,6 +56,13 @@
"friendAdd": "Add friend",
"friendAddHint": "Use your their username to send a friend request to your best friend!",
"friendAddDone": "Friend request sent, go reach your friend!",
"personalize": "Personalize",
"personalizeApplied": "Your account information has been updated, some fields may take a while to fully applied.",
"keypair": "Keypair",
"keypairGenerated": "A new set of keypair has been generated. Automatically set it to active.",
"keypairSecretCode": "Secret Code",
"keypairImportHint": "You can paste the exported secret code here to import all keys in it.",
"keypairExportHint": "You can copy the exported secret code to other device to import all keys into it. Do not share it with anybody else!",
"reaction": "Reaction",
"reactVerb": "React",
"post": "Post",
@@ -50,10 +70,10 @@
"comment": "Comment",
"attachment": "Attachment",
"attachmentAdd": "Add new attachment",
"pickPhoto": "Gallery photo",
"pickMedia": "Gallery media",
"pickFile": "Device file",
"takePhoto": "Capture photo",
"pickVideo": "Gallery video",
"takeVideo": "Record video",
"takeVideo": "Capture video",
"newMoment": "Record a moment",
"newComment": "Leave a comment",
"connectingServer": "Connecting to server...",
@@ -63,12 +83,30 @@
"postEditNotify": "You are about editing a post that already published.",
"reactionAdded": "Your reaction has been added.",
"reactionRemoved": "Your reaction has been removed.",
"shortcutsEmpty": "Shortcuts are empty, looks like you didn't go anywhere recently...",
"realmNew": "New Realm",
"realmNewCreate": "Create a realm",
"realmNewJoin": "Join a exists realm",
"realmUsage": "Realm",
"realmUsageCaption": "Realm is a place to organize your posts, channels and more. It will be much easier if you build community with realm!",
"realmEstablish": "Establish a realm",
"realmEditNotify": "You are about editing a existing realm.",
"realmAliasLabel": "Realm Alias",
"realmNameLabel": "Realm Name",
"realmDescriptionLabel": "Realm Description",
"realmPublicLabel": "It's public",
"realmCommunityLabel": "It's community realm",
"realmMember": "Member",
"realmManage": "Realm Manage",
"chatNew": "New Chat",
"chatNewCreate": "Create a channel",
"chatNewJoin": "Join a exists channel",
"chatManage": "Manage Chat",
"chatDetail": "Chat Details",
"chatMember": "Member",
"chatNotifySetting": "Notify Settings",
"chatChannelUnavailable": "Channel Unavailable",
"chatChannelUnavailableCaptionWithRealm": "You didn't join the channel, but looks like you able to join to, would you want to have a try?",
"chatChannelUnavailableCaption": "You didn't join the channel, so you cannot access the information of this channel.",
"chatChannelUsage": "Channel",
"chatChannelUsageCaption": "Channel is place to talk with people, one or a lot.",
"chatChannelOrganize": "Organize a channel",
@@ -76,6 +114,7 @@
"chatChannelAliasLabel": "Channel Alias",
"chatChannelNameLabel": "Channel Name",
"chatChannelDescriptionLabel": "Channel Description",
"chatChannelEncryptedLabel": "Encrypted Channel",
"chatChannelLeaveConfirm": "Are you sure you want to leave this channel? Your message will be stored, but if you rejoin this channel later, you will lose your control of your previous messages.",
"chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!",
"chatCall": "Call",
@@ -93,7 +132,13 @@
"chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.",
"chatCallChangeSpeaker": "Change Speaker",
"chatMessagePlaceholder": "Write a message...",
"chatMessageEncryptedPlaceholder": "Write a encrypted message...",
"chatMessageSending": "Now delivering your messages...",
"chatMessageEditNotify": "You are about editing a message.",
"chatMessageReplyNotify": "You are about replying a message.",
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!"
"chatMessageDeleteConfirm": "Are you sure you want to delete this message? This operation cannot be revert and no local history is saved!",
"chatMessageViewSource": "View source",
"chatMessageUnableDecryptWaiting": "Waiting for encryption key...",
"chatMessageUnableDecryptUnsupported": "Unable to decrypt the message, encryption algorithm unsupported",
"chatMessageUnableDecryptMissing": "Unable to decrypt the message, missing encryption key"
}

View File

@@ -1,7 +1,8 @@
{
"appName": "Solar",
"appName": "Solar Network",
"explore": "探索",
"chat": "聊天",
"realm": "领域",
"account": "账号",
"riskDetection": "风险监测",
"signIn": "登录",
@@ -9,7 +10,9 @@
"signInRequired": "请先登录",
"signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。",
"signUp": "注册",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Network 的存取权!",
"signUpDone": "注册成功",
"signUpDoneCaption": "欢迎您加入 Solar Network现在去登陆吧",
"signOut": "登出",
"poweredBy": "由 Project Hydrogen 强力驱动",
"copyright": "2024 Solsynth LLC © 版权所有",
@@ -19,22 +22,32 @@
"email": "邮箱地址",
"nickname": "显示名",
"username": "用户名",
"firstName": "姓氏",
"lastName": "名字",
"description": "简介",
"birthday": "生日",
"password": "密码",
"next": "下一步",
"join": "加入",
"edit": "编辑",
"delete": "删除",
"action": "操作",
"apply": "应用",
"reset": "重置",
"cancel": "取消",
"exit": "离开",
"report": "举报",
"reply": "回复",
"export": "导出",
"import": "导入",
"settings": "设置",
"errorHappened": "发生了错误",
"notification": "通知",
"notifyDone": "所有通知已读!",
"notifyDoneCaption": "这里没有什么东西可以给你看的了~",
"notifyListHint": "下拉以刷新,左滑来已读",
"notifyMarkAllRead": "将所有标记为已读",
"notifyMarkAllReadDone": "已将所有通知标记为已读",
"friend": "好友",
"friendPending": "请求中",
"friendActive": "活跃的好友",
@@ -43,6 +56,13 @@
"friendAdd": "添加好友",
"friendAddHint": "使用用户名来给你的好朋友发一个好友请求吧!",
"friendAddDone": "好友请求已发送,快告诉你的朋友吧!",
"personalize": "个性化",
"personalizeApplied": "您的账号信息已被更新,部分信息可能需要一段时间来同步。",
"keypair": "密钥对",
"keypairGenerated": "已生成一套新的密钥对,并且设为活跃的密钥。",
"keypairSecretCode": "神秘代码",
"keypairImportHint": "你可以将别的设备导出的神秘代码粘贴到这里来导入其中的所有密钥。",
"keypairExportHint": "你可以将这个导出的神秘代码到你的别的设备来导入这个设备所包含的密钥,但绝对不要发送给其他人!",
"reaction": "反应",
"reactVerb": "作出反应",
"post": "帖子",
@@ -50,9 +70,9 @@
"comment": "评论",
"attachment": "附件",
"attachmentAdd": "附加新附件",
"pickPhoto": "相册照片",
"pickMedia": "相册媒体",
"pickFile": "设备文件",
"takePhoto": "拍摄照片",
"pickVideo": "相册视频",
"takeVideo": "拍摄视频",
"newMoment": "记录时刻",
"newComment": "留下评论",
@@ -63,10 +83,28 @@
"postEditNotify": "你正在修改一个已经发布了的帖子。",
"reactionAdded": "你的反应已被添加。",
"reactionRemoved": "你的反应已被移除。",
"shortcutsEmpty": "快捷方式空空如也,看起来最近你没去哪里呀~",
"realmNew": "新领域",
"realmNewCreate": "创建新领域",
"realmNewJoin": "加入现有领域",
"realmUsage": "领域",
"realmUsageCaption": "领域是一个地方给你来组织帖子、文章、聊天频道的,好好利用领域打造一个绝妙的专属于你的社区吧!",
"realmEstablish": "部署领域",
"realmEditNotify": "你正在修改一个现有领域……",
"realmAliasLabel": "领域别名",
"realmNameLabel": "领域名称",
"realmDescriptionLabel": "领域简介",
"realmPublicLabel": "公共领域",
"realmCommunityLabel": "社区领域(任何人均可加入)",
"realmMember": "成员",
"realmManage": "领域管理",
"chatNew": "新聊天",
"chatManage": "管理聊天",
"chatDetail": "聊天详情",
"chatMember": "成员",
"chatNotifySetting": "通知设定",
"chatChannelUnavailable": "频道不可用",
"chatChannelUnavailableCaptionWithRealm": "你没加入该频道,但是看起来你能加入本频道,你想加入吗?",
"chatChannelUnavailableCaption": "你没加入该频道,所以你无法读取本频道的信息。",
"chatNewCreate": "新建频道",
"chatNewJoin": "加入已有频道",
"chatChannelUsage": "频道",
@@ -76,6 +114,7 @@
"chatChannelAliasLabel": "频道别名",
"chatChannelNameLabel": "频道名称",
"chatChannelDescriptionLabel": "频道简介",
"chatChannelEncryptedLabel": "加密频道",
"chatChannelLeaveConfirm": "你确定你要离开这个频道吗?你在这个频道里的消息将被存储下来,但是当你重新加入本频道后你将会失去对你之前消息的权限。",
"chatChannelDeleteConfirm": "你确定你要删除这个频道吗?这个频道里的所有消息都将消失,并且不可被反转!",
"chatCall": "通话",
@@ -93,7 +132,13 @@
"chatCallDisconnect": "断开连接",
"chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。",
"chatMessagePlaceholder": "发条消息……",
"chatMessageEncryptedPlaceholder": "发条加密信息……",
"chatMessageSending": "正在送出你的信息……",
"chatMessageEditNotify": "你正在编辑信息中……",
"chatMessageReplyNotify": "你正在回复消息中……",
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!"
}
"chatMessageDeleteConfirm": "你确定要删除这条消息吗?这条消息将永远的从所有人的视图中消失,并且不会有本地消息记录保存!",
"chatMessageViewSource": "查看原始信息",
"chatMessageUnableDecryptWaiting": "正在等待解密密钥……",
"chatMessageUnableDecryptUnsupported": "无法解密信息,不支持加密的算法",
"chatMessageUnableDecryptMissing": "无法解密信息,缺失解密密钥"
}

View File

@@ -3,14 +3,17 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/friend.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/utils/timeago.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/video_player.dart';
import 'package:solian/widgets/chat/call/call_overlay.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/provider_init.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
@@ -24,18 +27,16 @@ void main() {
class SolianApp extends StatelessWidget {
const SolianApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'Solian',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
theme: SolianTheme.build(Brightness.light),
darkTheme: SolianTheme.build(Brightness.dark),
themeMode: ThemeMode.system,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: router,
routerConfig: SolianRouter.router,
builder: (context, child) {
return MultiProvider(
providers: [
@@ -44,11 +45,19 @@ class SolianApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => ChatProvider()),
ChangeNotifierProvider(create: (_) => NotifyProvider()),
ChangeNotifierProvider(create: (_) => FriendProvider()),
ChangeNotifierProvider(create: (_) => RealmProvider()),
ChangeNotifierProvider(create: (_) => KeypairProvider()),
],
child: Overlay(
initialEntries: [
OverlayEntry(builder: (context) {
return NotificationNotifier(child: child ?? Container());
return ScaffoldMessenger(
child: Scaffold(
body: ProviderInitializer(
child: child ?? Container(),
),
),
);
}),
OverlayEntry(builder: (context) => const CallOverlay()),
],

View File

@@ -28,32 +28,32 @@ class Account {
});
factory Account.fromJson(Map<String, dynamic> json) => Account(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
name: json["name"],
nick: json["nick"],
avatar: json["avatar"],
banner: json["banner"],
description: json["description"],
emailAddress: json["email_address"],
powerLevel: json["power_level"],
externalId: json["external_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
name: json['name'],
nick: json['nick'],
avatar: json['avatar'],
banner: json['banner'],
description: json['description'],
emailAddress: json['email_address'],
powerLevel: json['power_level'],
externalId: json['external_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"name": name,
"nick": nick,
"avatar": avatar,
"banner": banner,
"description": description,
"email_address": emailAddress,
"power_level": powerLevel,
"external_id": externalId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'name': name,
'nick': nick,
'avatar': avatar,
'banner': banner,
'description': description,
'email_address': emailAddress,
'power_level': powerLevel,
'external_id': externalId,
};
}

View File

@@ -28,32 +28,32 @@ class Author {
});
factory Author.fromJson(Map<String, dynamic> json) => Author(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
name: json["name"],
nick: json["nick"],
avatar: json["avatar"],
banner: json["banner"],
description: json["description"],
emailAddress: json["email_address"],
powerLevel: json["power_level"],
externalId: json["external_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
name: json['name'],
nick: json['nick'],
avatar: json['avatar'],
banner: json['banner'],
description: json['description'],
emailAddress: json['email_address'],
powerLevel: json['power_level'],
externalId: json['external_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"name": name,
"nick": nick,
"avatar": avatar,
"banner": banner,
"description": description,
"email_address": emailAddress,
"power_level": powerLevel,
"external_id": externalId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'name': name,
'nick': nick,
'avatar': avatar,
'banner': banner,
'description': description,
'email_address': emailAddress,
'power_level': powerLevel,
'external_id': externalId,
};
}

View File

@@ -25,28 +25,28 @@ class Call {
});
factory Call.fromJson(Map<String, dynamic> json) => Call(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
endedAt:
json["ended_at"] != null ? DateTime.parse(json["ended_at"]) : null,
externalId: json["external_id"],
founderId: json["founder_id"],
channelId: json["channel_id"],
channel: Channel.fromJson(json["channel"]),
json['ended_at'] != null ? DateTime.parse(json['ended_at']) : null,
externalId: json['external_id'],
founderId: json['founder_id'],
channelId: json['channel_id'],
channel: Channel.fromJson(json['channel']),
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"ended_at": endedAt?.toIso8601String(),
"external_id": externalId,
"founder_id": founderId,
"channel_id": channelId,
"channel": channel.toJson(),
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'ended_at': endedAt?.toIso8601String(),
'external_id': externalId,
'founder_id': founderId,
'channel_id': channelId,
'channel': channel.toJson(),
};
}

View File

@@ -8,12 +8,13 @@ class Channel {
String alias;
String name;
String description;
dynamic members;
dynamic calls;
int type;
Account account;
int accountId;
int realmId;
int? realmId;
bool isEncrypted;
bool isAvailable = false;
Channel({
required this.id,
@@ -23,44 +24,41 @@ class Channel {
required this.alias,
required this.name,
required this.description,
this.members,
this.calls,
required this.type,
required this.account,
required this.accountId,
required this.realmId,
required this.isEncrypted,
this.realmId,
});
factory Channel.fromJson(Map<String, dynamic> json) => Channel(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
alias: json["alias"],
name: json["name"],
description: json["description"],
members: json["members"],
calls: json["calls"],
type: json["type"],
account: Account.fromJson(json["account"]),
accountId: json["account_id"],
realmId: json["realm_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
alias: json['alias'],
name: json['name'],
description: json['description'],
type: json['type'],
account: Account.fromJson(json['account']),
accountId: json['account_id'],
realmId: json['realm_id'],
isEncrypted: json['is_encrypted'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"alias": alias,
"name": name,
"description": description,
"members": members,
"calls": calls,
"type": type,
"account": account,
"account_id": accountId,
"realm_id": realmId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'name': name,
'description': description,
'type': type,
'account': account,
'account_id': accountId,
'realm_id': realmId,
'is_encrypted': isEncrypted,
};
}
@@ -86,24 +84,24 @@ class ChannelMember {
});
factory ChannelMember.fromJson(Map<String, dynamic> json) => ChannelMember(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
channelId: json["channel_id"],
accountId: json["account_id"],
account: Account.fromJson(json["account"]),
notify: json["notify"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
channelId: json['channel_id'],
accountId: json['account_id'],
account: Account.fromJson(json['account']),
notify: json['notify'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"channel_id": channelId,
"account_id": accountId,
"account": account.toJson(),
"notify": notify,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'channel_id': channelId,
'account_id': accountId,
'account': account.toJson(),
'notify': notify,
};
}

View File

@@ -26,29 +26,29 @@ class Friendship {
});
factory Friendship.fromJson(Map<String, dynamic> json) => Friendship(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
accountId: json["account_id"],
relatedId: json["related_id"],
blockedBy: json["blocked_by"],
account: Account.fromJson(json["account"]),
related: Account.fromJson(json["related"]),
status: json["status"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
accountId: json['account_id'],
relatedId: json['related_id'],
blockedBy: json['blocked_by'],
account: Account.fromJson(json['account']),
related: Account.fromJson(json['related']),
status: json['status'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"account_id": accountId,
"related_id": relatedId,
"blocked_by": blockedBy,
"account": account.toJson(),
"related": related.toJson(),
"status": status,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'account_id': accountId,
'related_id': relatedId,
'blocked_by': blockedBy,
'account': account.toJson(),
'related': related.toJson(),
'status': status,
};
Account getOtherside(int selfId) {

32
lib/models/keypair.dart Normal file
View File

@@ -0,0 +1,32 @@
class Keypair {
final String id;
final String algorithm;
final String publicKey;
final String? privateKey;
final bool isOwned;
Keypair({
required this.id,
required this.algorithm,
required this.publicKey,
required this.privateKey,
this.isOwned = false,
});
factory Keypair.fromJson(Map<String, dynamic> json) => Keypair(
id: json['id'],
algorithm: json['algorithm'],
publicKey: json['public_key'],
privateKey: json['private_key'],
isOwned: json['is_owned'],
);
Map<String, dynamic> toJson() => {
'id': id,
'algorithm': algorithm,
'public_key': publicKey,
'private_key': privateKey,
'is_owned': isOwned,
};
}

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart';
@@ -7,9 +9,9 @@ class Message {
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String content;
dynamic metadata;
int type;
String rawContent;
Map<String, dynamic>? metadata;
String type;
List<Attachment>? attachments;
Channel? channel;
Sender sender;
@@ -18,12 +20,18 @@ class Message {
int channelId;
int senderId;
bool isSending = false;
Map<String, dynamic> get decodedContent {
return jsonDecode(utf8.fuse(base64).decode(rawContent));
}
Message({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.content,
required this.rawContent,
required this.metadata,
required this.type,
this.attachments,
@@ -36,42 +44,37 @@ class Message {
});
factory Message.fromJson(Map<String, dynamic> json) => Message(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
content: json["content"],
metadata: json["metadata"],
type: json["type"],
attachments: List<Attachment>.from(
json["attachments"]?.map((x) => Attachment.fromJson(x)) ??
List.empty()),
channel: Channel.fromJson(json["channel"]),
sender: Sender.fromJson(json["sender"]),
replyId: json["reply_id"],
replyTo: json["reply_to"] != null
? Message.fromJson(json["reply_to"])
: null,
channelId: json["channel_id"],
senderId: json["sender_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
rawContent: json['content'],
metadata: json['metadata'],
type: json['type'],
attachments: List<Attachment>.from(json['attachments']?.map((x) => Attachment.fromJson(x)) ?? List.empty()),
channel: Channel.fromJson(json['channel']),
sender: Sender.fromJson(json['sender']),
replyId: json['reply_id'],
replyTo: json['reply_to'] != null ? Message.fromJson(json['reply_to']) : null,
channelId: json['channel_id'],
senderId: json['sender_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"content": content,
"metadata": metadata,
"type": type,
"attachments": List<dynamic>.from(
attachments?.map((x) => x.toJson()) ?? List.empty()),
"channel": channel?.toJson(),
"sender": sender.toJson(),
"reply_id": replyId,
"reply_to": replyTo?.toJson(),
"channel_id": channelId,
"sender_id": senderId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'content': rawContent,
'metadata': metadata,
'type': type,
'attachments': List<dynamic>.from(attachments?.map((x) => x.toJson()) ?? List.empty()),
'channel': channel?.toJson(),
'sender': sender.toJson(),
'reply_id': replyId,
'reply_to': replyTo?.toJson(),
'channel_id': channelId,
'sender_id': senderId,
};
}
@@ -97,24 +100,24 @@ class Sender {
});
factory Sender.fromJson(Map<String, dynamic> json) => Sender(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
account: Account.fromJson(json["account"]),
channelId: json["channel_id"],
accountId: json["account_id"],
notify: json["notify"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
account: Account.fromJson(json['account']),
channelId: json['channel_id'],
accountId: json['account_id'],
notify: json['notify'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"account": account.toJson(),
"channel_id": channelId,
"account_id": accountId,
"notify": notify,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'account': account.toJson(),
'channel_id': channelId,
'account_id': accountId,
'notify': notify,
};
}

View File

@@ -9,7 +9,7 @@ class Notification {
bool isImportant;
bool isRealtime;
DateTime? readAt;
int senderId;
int? senderId;
int recipientId;
Notification({
@@ -23,42 +23,38 @@ class Notification {
required this.isImportant,
required this.isRealtime,
this.readAt,
required this.senderId,
this.senderId,
required this.recipientId,
});
factory Notification.fromJson(Map<String, dynamic> json) => Notification(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
subject: json["subject"],
content: json["content"],
links: json["links"] != null
? List<Link>.from(json["links"].map((x) => Link.fromJson(x)))
: List.empty(),
isImportant: json["is_important"],
isRealtime: json["is_realtime"],
readAt: json["read_at"],
senderId: json["sender_id"],
recipientId: json["recipient_id"],
id: json['id'] ?? 0,
createdAt: json['created_at'] == null ? DateTime.now() : DateTime.parse(json['created_at']),
updatedAt: json['updated_at'] == null ? DateTime.now() : DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
subject: json['subject'],
content: json['content'],
links: json['links'] != null ? List<Link>.from(json['links'].map((x) => Link.fromJson(x))) : List.empty(),
isImportant: json['is_important'],
isRealtime: json['is_realtime'],
readAt: json['read_at'],
senderId: json['sender_id'],
recipientId: json['recipient_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"subject": subject,
"content": content,
"links": links != null
? List<dynamic>.from(links!.map((x) => x.toJson()))
: List.empty(),
"is_important": isImportant,
"is_realtime": isRealtime,
"read_at": readAt,
"sender_id": senderId,
"recipient_id": recipientId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'subject': subject,
'content': content,
'links': links != null ? List<dynamic>.from(links!.map((x) => x.toJson())) : List.empty(),
'is_important': isImportant,
'is_realtime': isRealtime,
'read_at': readAt,
'sender_id': senderId,
'recipient_id': recipientId,
};
}
@@ -72,12 +68,12 @@ class Link {
});
factory Link.fromJson(Map<String, dynamic> json) => Link(
label: json["label"],
url: json["url"],
label: json['label'],
url: json['url'],
);
Map<String, dynamic> toJson() => {
"label": label,
"url": url,
'label': label,
'url': url,
};
}

View File

@@ -10,14 +10,14 @@ class NetworkPackage {
});
factory NetworkPackage.fromJson(Map<String, dynamic> json) => NetworkPackage(
method: json["w"],
message: json["m"],
payload: json["p"],
method: json['w'],
message: json['m'],
payload: json['p'],
);
Map<String, dynamic> toJson() => {
"w": method,
"m": message,
"p": payload,
'w': method,
'm': message,
'p': payload,
};
}

View File

@@ -8,10 +8,10 @@ class PaginationResult {
});
factory PaginationResult.fromJson(Map<String, dynamic> json) =>
PaginationResult(count: json["count"], data: json["data"]);
PaginationResult(count: json['count'], data: json['data']);
Map<String, dynamic> toJson() => {
"count": count,
"data": data,
'count': count,
'data': data,
};
}

View File

@@ -0,0 +1,47 @@
class PersonalPage {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String content;
String script;
String style;
Map<String, String>? links;
int accountId;
PersonalPage({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.content,
required this.script,
required this.style,
this.links,
required this.accountId,
});
factory PersonalPage.fromJson(Map<String, dynamic> json) => PersonalPage(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
content: json['content'],
script: json['script'],
style: json['style'],
links: json['links'],
accountId: json['account_id'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt?.toIso8601String(),
'content': content,
'script': script,
'style': style,
'links': links,
'account_id': accountId,
};
}

View File

@@ -18,6 +18,8 @@ class Post {
List<Attachment>? attachments;
Map<String, dynamic>? reactionList;
String get dataset => '${modelType}s';
Post({
required this.id,
required this.createdAt,
@@ -38,46 +40,46 @@ class Post {
});
factory Post.fromJson(Map<String, dynamic> json) => Post(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
alias: json["alias"],
title: json["title"],
description: json["description"],
content: json["content"],
modelType: json["model_type"],
commentCount: json["comment_count"],
reactionCount: json["reaction_count"],
authorId: json["author_id"],
realmId: json["realm_id"],
author: Author.fromJson(json["author"]),
attachments: json["attachments"] != null
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
alias: json['alias'],
title: json['title'],
description: json['description'],
content: json['content'],
modelType: json['model_type'],
commentCount: json['comment_count'],
reactionCount: json['reaction_count'],
authorId: json['author_id'],
realmId: json['realm_id'],
author: Author.fromJson(json['author']),
attachments: json['attachments'] != null
? List<Attachment>.from(
json["attachments"].map((x) => Attachment.fromJson(x)))
json['attachments'].map((x) => Attachment.fromJson(x)))
: List.empty(),
reactionList: json["reaction_list"],
reactionList: json['reaction_list'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"alias": alias,
"title": title,
"description": description,
"content": content,
"model_type": modelType,
"comment_count": commentCount,
"reaction_count": reactionCount,
"author_id": authorId,
"realm_id": realmId,
"author": author.toJson(),
"attachments": attachments == null
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'title': title,
'description': description,
'content': content,
'model_type': modelType,
'comment_count': commentCount,
'reaction_count': reactionCount,
'author_id': authorId,
'realm_id': realmId,
'author': author.toJson(),
'attachments': attachments == null
? List.empty()
: List<dynamic>.from(attachments!.map((x) => x.toJson())),
"reaction_list": reactionList,
'reaction_list': reactionList,
};
}
@@ -117,38 +119,38 @@ class Attachment {
});
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
id: json["id"],
createdAt: DateTime.parse(json["created_at"]),
updatedAt: DateTime.parse(json["updated_at"]),
deletedAt: json["deleted_at"],
fileId: json["file_id"],
filesize: json["filesize"],
filename: json["filename"],
mimetype: json["mimetype"],
type: json["type"],
externalUrl: json["external_url"],
author: Author.fromJson(json["author"]),
articleId: json["article_id"],
momentId: json["moment_id"],
commentId: json["comment_id"],
authorId: json["author_id"],
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'],
fileId: json['file_id'],
filesize: json['filesize'],
filename: json['filename'],
mimetype: json['mimetype'],
type: json['type'],
externalUrl: json['external_url'],
author: Author.fromJson(json['author']),
articleId: json['article_id'],
momentId: json['moment_id'],
commentId: json['comment_id'],
authorId: json['author_id'],
);
Map<String, dynamic> toJson() => {
"id": id,
"created_at": createdAt.toIso8601String(),
"updated_at": updatedAt.toIso8601String(),
"deleted_at": deletedAt,
"file_id": fileId,
"filesize": filesize,
"filename": filename,
"mimetype": mimetype,
"type": type,
"external_url": externalUrl,
"author": author.toJson(),
"article_id": articleId,
"moment_id": momentId,
"comment_id": commentId,
"author_id": authorId,
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'file_id': fileId,
'filesize': filesize,
'filename': filename,
'mimetype': mimetype,
'type': type,
'external_url': externalUrl,
'author': author.toJson(),
'article_id': articleId,
'moment_id': momentId,
'comment_id': commentId,
'author_id': authorId,
};
}

97
lib/models/realm.dart Normal file
View File

@@ -0,0 +1,97 @@
import 'package:solian/models/account.dart';
class Realm {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String name;
String description;
bool isPublic;
bool isCommunity;
int accountId;
Realm({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.isPublic,
required this.isCommunity,
required this.accountId,
});
factory Realm.fromJson(Map<String, dynamic> json) => Realm(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
alias: json['alias'],
name: json['name'],
description: json['description'],
isPublic: json['is_public'],
isCommunity: json['is_community'],
accountId: json['account_id'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'name': name,
'description': description,
'is_public': isPublic,
'is_community': isCommunity,
'account_id': accountId,
};
}
class RealmMember {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
int realmId;
int accountId;
Account account;
int powerLevel;
RealmMember({
required this.id,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
required this.realmId,
required this.accountId,
required this.account,
required this.powerLevel,
});
factory RealmMember.fromJson(Map<String, dynamic> json) => RealmMember(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null ? DateTime.parse(json['deleted_at']) : null,
realmId: json['realm_id'],
accountId: json['account_id'],
account: Account.fromJson(json['account']),
powerLevel: json['power_level'],
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'realm_id': realmId,
'account_id': accountId,
'account': account.toJson(),
'power_level': powerLevel,
};
}

View File

@@ -3,37 +3,37 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/http.dart';
import 'package:solian/utils/services_url.dart';
class AuthProvider extends ChangeNotifier {
AuthProvider();
AuthProvider() {
loadClient();
}
final deviceEndpoint =
getRequestUri('passport', '/api/notifications/subscribe');
final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe');
final tokenEndpoint = getRequestUri('passport', '/api/auth/token');
final userinfoEndpoint = getRequestUri('passport', '/api/users/me');
final redirectUrl = Uri.parse('solian://auth');
static const clientId = "solian";
static const clientSecret = "_F4%q2Eea3";
static const clientId = 'solian';
static const clientSecret = '_F4%q2Eea3';
static const storage = FlutterSecureStorage();
static const storageKey = "identity";
static const profileKey = "profiles";
static const storageKey = 'identity';
static const profileKey = 'profiles';
/// Before use this variable to make request
/// **MAKE SURE YOU HAVE CALL THE isAuthorized() METHOD**
oauth2.Client? client;
DateTime? lastRefreshedAt;
HttpClient? client;
Future<bool> loadClient() async {
if (await storage.containsKey(key: storageKey)) {
try {
final credentials =
oauth2.Credentials.fromJson((await storage.read(key: storageKey))!);
client = oauth2.Client(credentials,
identifier: clientId, secret: clientSecret);
final credentials = oauth2.Credentials.fromJson((await storage.read(key: storageKey))!);
client = HttpClient(
defaultToken: credentials.accessToken,
defaultRefreshToken: credentials.refreshToken,
onTokenRefreshed: setToken,
);
await fetchProfiles();
return true;
} catch (e) {
@@ -41,24 +41,33 @@ class AuthProvider extends ChangeNotifier {
return false;
}
} else {
client = HttpClient(onTokenRefreshed: setToken);
return false;
}
}
Future<oauth2.Client> createClient(
BuildContext context, String username, String password) async {
Future<HttpClient> createClient(BuildContext context, String username, String password) async {
if (await loadClient()) {
return client!;
}
return await oauth2.resourceOwnerPasswordGrant(
final credentials = (await oauth2.resourceOwnerPasswordGrant(
tokenEndpoint,
username,
password,
identifier: clientId,
secret: clientSecret,
scopes: ["openid"],
scopes: ['openid'],
basicAuth: false,
))
.credentials;
setToken(credentials.accessToken, credentials.refreshToken!);
return HttpClient(
defaultToken: credentials.accessToken,
defaultRefreshToken: credentials.refreshToken,
onTokenRefreshed: setToken,
);
}
@@ -70,21 +79,16 @@ class AuthProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> refreshToken() async {
Future<void> setToken(String atk, String rtk) async {
if (client != null) {
final credentials = await client!.credentials.refresh(
identifier: clientId, secret: clientSecret, basicAuth: false);
client = oauth2.Client(credentials,
identifier: clientId, secret: clientSecret);
final credentials = oauth2.Credentials(atk, refreshToken: rtk, idToken: atk, scopes: ['openid']);
storage.write(key: storageKey, value: credentials.toJson());
}
notifyListeners();
}
Future<void> signin(
BuildContext context, String username, String password) async {
Future<void> signin(BuildContext context, String username, String password) async {
client = await createClient(context, username, password);
storage.write(key: storageKey, value: client!.credentials.toJson());
await fetchProfiles();
}
@@ -96,25 +100,11 @@ class AuthProvider extends ChangeNotifier {
Future<bool> isAuthorized() async {
const storage = FlutterSecureStorage();
if (await storage.containsKey(key: storageKey)) {
if (client == null) {
await loadClient();
}
if (lastRefreshedAt == null ||
DateTime.now()
.subtract(const Duration(minutes: 3))
.isAfter(lastRefreshedAt!)) {
await refreshToken();
lastRefreshedAt = DateTime.now();
}
return true;
} else {
return false;
}
return await storage.containsKey(key: storageKey);
}
Future<dynamic> getProfiles() async {
const storage = FlutterSecureStorage();
return jsonDecode(await storage.read(key: profileKey) ?? "{}");
return jsonDecode(await storage.read(key: profileKey) ?? '{}');
}
}

View File

@@ -2,58 +2,209 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/chat/call/exts.dart';
import 'package:solian/widgets/exts.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatProvider extends ChangeNotifier {
bool isOpened = false;
bool isShown = false;
bool isCallShown = false;
ChatCallInstance? call;
Call? ongoingCall;
Channel? focusChannel;
String? focusChannelRealm;
ChatCallInstance? currentCall;
Future<WebSocketChannel?> connect(AuthProvider auth) async {
PagingController<int, Message>? historyPagingController;
IOWebSocketChannel? _channel;
Future<IOWebSocketChannel?> connect(
AuthProvider auth, {
Function(bool status)? onStateUpdated,
noRetry = false,
}) async {
if (auth.client == null) await auth.loadClient();
if (!await auth.isAuthorized()) return null;
await auth.refreshToken();
if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
return _channel;
}
var ori = getRequestUri('messaging', '/api/ws');
var uri = Uri(
scheme: ori.scheme.replaceFirst('http', 'ws'),
host: ori.host,
port: ori.port,
path: ori.path,
queryParameters: {
'tk': Uri.encodeComponent(auth.client!.credentials.accessToken)
},
queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
);
final channel = WebSocketChannel.connect(uri);
await channel.ready;
try {
_channel = IOWebSocketChannel.connect(uri);
if (onStateUpdated != null) onStateUpdated(true);
await _channel!.ready;
if (onStateUpdated != null) onStateUpdated(false);
} catch (e) {
if (!noRetry) {
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
connect(auth, noRetry: true);
} else {
rethrow;
}
}
return channel;
_channel!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
if (focusChannel == null || historyPagingController == null) return;
switch (result.method) {
case 'messages.new':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
historyPagingController?.itemList?.insert(0, payload);
}
break;
case 'messages.update':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
historyPagingController?.itemList =
historyPagingController?.itemList?.map((x) => x.id == payload.id ? payload : x).toList();
}
break;
case 'messages.burnt':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
historyPagingController?.itemList =
historyPagingController?.itemList?.where((x) => x.id != payload.id).toList();
}
break;
case 'calls.new':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
setOngoingCall(payload);
}
break;
case 'calls.end':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == focusChannel?.id) {
setOngoingCall(null);
}
break;
}
notifyListeners();
},
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
);
return _channel!;
}
bool handleCall(Call call, Channel channel,
{Function? onUpdate, Function? onDispose}) {
if (this.call != null) return false;
void disconnect() {
_channel?.sink.close(status.goingAway);
}
this.call = ChatCallInstance(
Future<void> fetchMessages(int pageKey, BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
if (focusChannel == null || focusChannelRealm == null) return;
final offset = pageKey;
const take = 10;
var uri = getRequestUri(
'messaging',
'/api/channels/$focusChannelRealm/${focusChannel!.alias}/messages?take=$take&offset=$offset',
);
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items = result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
historyPagingController!.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
historyPagingController!.appendPage(items, nextPageKey);
}
} else if (res.statusCode == 403) {
historyPagingController!.appendLastPage([]);
} else {
historyPagingController!.error = utf8.decode(res.bodyBytes);
}
}
Future<Channel> fetchChannel(BuildContext context, AuthProvider auth, String alias, String realm) async {
if (focusChannel != null) {
unFocus();
}
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias/availability');
var res = await auth.client!.get(uri);
if (res.statusCode == 200 || res.statusCode == 403) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
focusChannel = Channel.fromJson(result);
focusChannel?.isAvailable = res.statusCode == 200;
focusChannelRealm = realm;
if (historyPagingController == null) {
historyPagingController = PagingController(firstPageKey: 0);
historyPagingController?.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
}
notifyListeners();
return focusChannel!;
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
Future<Call?> fetchOngoingCall(String alias, String realm) async {
final Client client = Client();
var uri = getRequestUri('messaging', '/api/channels/$realm/$alias/calls/ongoing');
var res = await client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
ongoingCall = Call.fromJson(result);
notifyListeners();
return ongoingCall;
} else if (res.statusCode != 404) {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
} else {
return null;
}
}
bool handleCallJoin(Call call, Channel channel, {Function? onUpdate, Function? onDispose}) {
if (currentCall != null) return false;
currentCall = ChatCallInstance(
onUpdate: () {
notifyListeners();
if (onUpdate != null) onUpdate();
},
onDispose: () {
this.call = null;
currentCall = null;
notifyListeners();
if (onDispose != null) onDispose();
},
@@ -64,8 +215,21 @@ class ChatProvider extends ChangeNotifier {
return true;
}
void setShown(bool state) {
isShown = state;
void setOngoingCall(Call? item) {
ongoingCall = item;
notifyListeners();
}
void setCallShown(bool state) {
isCallShown = state;
notifyListeners();
}
void unFocus() {
currentCall = null;
focusChannel = null;
historyPagingController?.dispose();
historyPagingController = null;
notifyListeners();
}
}
@@ -109,8 +273,7 @@ class ChatCallInstance {
});
void init() {
subscription =
Hardware.instance.onDeviceChange.stream.listen(revertDevices);
subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices);
room = Room();
listener = room.createListener();
Hardware.instance.enumerateDevices().then(revertDevices);
@@ -118,8 +281,9 @@ class ChatCallInstance {
}
Future<void> checkPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux))
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
}
await Permission.camera.request();
await Permission.microphone.request();
@@ -133,11 +297,10 @@ class ChatCallInstance {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
onDispose();
throw Exception("unauthorized");
throw Exception('unauthorized');
}
var uri = getRequestUri(
'messaging', '/api/channels/${channel.alias}/calls/ongoing/token');
var uri = getRequestUri('messaging', '/api/channels/global/${channel.alias}/calls/ongoing/token');
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
@@ -190,12 +353,10 @@ class ChatCallInstance {
useiOSBroadcastExtension: true,
params: VideoParameters(
dimensions: VideoDimensionsPresets.h1080_169,
encoding:
VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30),
encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30),
),
),
defaultCameraCaptureOptions:
CameraCaptureOptions(maxFrameRate: 30, params: videoParameters),
defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: videoParameters),
),
fastConnectOptions: FastConnectOptions(
microphone: TrackOption(track: audioTrack),
@@ -228,8 +389,7 @@ class ChatCallInstance {
room.addListener(onRoomDidUpdate);
setupRoomListeners(context);
sortParticipants();
WidgetsBindingCompatible.instance
?.addPostFrameCallback((_) => autoPublish(context));
WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish(context));
if (lkPlatformIsMobile()) {
Hardware.instance.setSpeakerphoneOn(true);
@@ -304,8 +464,7 @@ class ChatCallInstance {
}
// First joined people first
return a.participant.joinedAt.millisecondsSinceEpoch -
b.participant.joinedAt.millisecondsSinceEpoch;
return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch;
});
ParticipantTrack localTrack = ParticipantTrack(
@@ -314,8 +473,7 @@ class ChatCallInstance {
isScreenShare: false,
);
if (room.localParticipant != null) {
final localParticipantTracks =
room.localParticipant?.videoTrackPublications;
final localParticipantTracks = room.localParticipant?.videoTrackPublications;
if (localParticipantTracks != null) {
for (var t in localParticipantTracks) {
localTrack.videoTrack = t.track;
@@ -328,8 +486,7 @@ class ChatCallInstance {
if (focusTrack == null) {
focusTrack = participantTracks.first;
} else {
final idx = participantTracks
.indexWhere((x) => focusTrack!.participant.sid == x.participant.sid);
final idx = participantTracks.indexWhere((x) => focusTrack!.participant.sid == x.participant.sid);
focusTrack = participantTracks[idx];
}

View File

@@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:solian/models/friendship.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
class FriendProvider extends ChangeNotifier {
List<Friendship> friends = List.empty();

151
lib/providers/keypair.dart Normal file
View File

@@ -0,0 +1,151 @@
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:encrypt/encrypt.dart' as encrypt;
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/models/packet.dart';
import 'package:uuid/uuid.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
class KeypairProvider extends ChangeNotifier {
static const storage = FlutterSecureStorage();
static const encryptIV = 'WT7s~><Ae?YrJd)D';
WebSocketChannel? channel;
String? activeKeyId;
Map<String, Keypair> keys = {};
List<String> requestingKeys = List.empty(growable: true);
KeypairProvider() {
loadKeys();
}
void loadKeys() async {
final result = await storage.read(key: 'keypair');
if (result != null) {
jsonDecode(result).values.forEach((x) {
keys[x['id']] = Keypair.fromJson(x);
});
activeKeyId = await storage.read(key: 'keypairActive');
}
notifyListeners();
}
void saveKeys() async {
await storage.write(key: 'keypair', value: jsonEncode(keys));
if (activeKeyId != null) {
await storage.write(key: 'keypairActive', value: activeKeyId);
}
}
void receiveKeypair(Keypair kp) {
print('received ${kp.id}');
keys[kp.id] = kp;
requestingKeys.remove(kp.id);
notifyListeners();
saveKeys();
}
Keypair? provideKeypair(String id) {
return keys[id];
}
void importKeys(String code) {
final result = jsonDecode(utf8.fuse(base64).decode(code)).map((x) => Keypair.fromJson(x)).toList();
for (final item in result) {
if (item is Keypair) {
keys[item.id] = item;
}
}
saveKeys();
notifyListeners();
}
void setActiveKey(String id) {
if (keys[id] == null) return;
activeKeyId = id;
saveKeys();
notifyListeners();
}
void clearKeys() {
keys = {};
storage.delete(key: 'keypairActive');
saveKeys();
}
void requestKey(String id, String algorithm, int uid) {
if (channel == null) return;
if (requestingKeys.contains(id)) return;
print('requested $id');
channel!.sink.add(jsonEncode(
NetworkPackage(method: 'kex.request', payload: {
'request_id': const Uuid().v4(),
'keypair_id': id,
'algorithm': algorithm,
'owner_id': uid,
'deadline': 3,
}).toJson(),
));
requestingKeys.add(id);
Future.delayed(const Duration(seconds: 3), () {
requestingKeys.remove(id);
notifyListeners();
});
notifyListeners();
}
String? encodeViaAESKey(String keypairId, String content) {
if (keys[keypairId] == null) {
return null;
} else if (keys[keypairId]?.algorithm != 'aes') {
throw Exception('invalid algorithm');
}
final kp = keys[keypairId]!;
final iv = encrypt.IV.fromUtf8(encryptIV);
final key = encrypt.Key.fromBase64(kp.publicKey);
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
return encryptor.encryptBytes(utf8.encode(content), iv: iv).base64;
}
String? decodeViaAESKey(String keypairId, String encrypted) {
if (keys[keypairId] == null) {
return null;
} else if (keys[keypairId]?.algorithm != 'aes') {
throw Exception('invalid algorithm');
}
final kp = keys[keypairId]!;
final iv = encrypt.IV.fromUtf8(encryptIV);
final key = encrypt.Key.fromBase64(kp.publicKey);
final encryptor = encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.sic, padding: null));
return utf8.decode(encryptor.decryptBytes(encrypt.Encrypted.fromBase64(encrypted), iv: iv));
}
Keypair generateAESKey() {
final random = Random.secure();
final values = List<int>.generate(32, (i) => random.nextInt(256));
final key = Uint8List.fromList(values);
final kp = Keypair(
id: const Uuid().v4(),
algorithm: 'aes',
publicKey: base64.encode(key),
privateKey: null,
isOwned: true,
);
keys[kp.id] = kp;
return kp;
}
}

View File

@@ -4,21 +4,22 @@ import 'package:flutter/material.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/models/notification.dart' as model;
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/status.dart' as status;
import 'dart:math' as math;
class NotifyProvider extends ChangeNotifier {
bool isOpened = false;
int unreadAmount = 0;
List<model.Notification> notifications = List.empty(growable: true);
final FlutterLocalNotificationsPlugin localNotify =
FlutterLocalNotificationsPlugin();
final FlutterLocalNotificationsPlugin localNotify = FlutterLocalNotificationsPlugin();
NotifyProvider() {
initNotify();
@@ -29,13 +30,11 @@ class NotifyProvider extends ChangeNotifier {
const androidSettings = AndroidInitializationSettings('app_icon');
const darwinSettings = DarwinInitializationSettings(
notificationCategories: [
DarwinNotificationCategory("general"),
DarwinNotificationCategory('general'),
],
);
const linuxSettings =
LinuxInitializationSettings(defaultActionName: 'Open notification');
const InitializationSettings initializationSettings =
InitializationSettings(
const linuxSettings = LinuxInitializationSettings(defaultActionName: 'Open notification');
const InitializationSettings initializationSettings = InitializationSettings(
android: androidSettings,
iOS: darwinSettings,
macOS: darwinSettings,
@@ -46,8 +45,9 @@ class NotifyProvider extends ChangeNotifier {
}
Future<void> requestPermissions() async {
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux))
if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) {
return;
}
await Permission.notification.request();
}
@@ -57,42 +57,96 @@ class NotifyProvider extends ChangeNotifier {
var uri = getRequestUri('passport', '/api/notifications?skip=0&take=25');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result =
PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
notifications =
result.data?.map((x) => model.Notification.fromJson(x)).toList() ??
List.empty(growable: true);
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
notifications = result.data?.map((x) => model.Notification.fromJson(x)).toList() ?? List.empty(growable: true);
}
notifyListeners();
}
Future<WebSocketChannel?> connect(AuthProvider auth) async {
IOWebSocketChannel? _channel;
Future<IOWebSocketChannel?> connect(
AuthProvider auth, {
Keypair? Function(String id)? onKexRequest,
Function(Keypair kp)? onKexProvide,
Function(bool status)? onStateUpdated,
bool noRetry = false,
}) async {
if (auth.client == null) await auth.loadClient();
if (!await auth.isAuthorized()) return null;
await auth.refreshToken();
var ori = getRequestUri('passport', '/api/notifications/listen');
if (_channel != null && (_channel!.innerWebSocket?.readyState ?? 0) < 2) {
return _channel;
}
var ori = getRequestUri('passport', '/api/ws');
var uri = Uri(
scheme: ori.scheme.replaceFirst('http', 'ws'),
host: ori.host,
port: ori.port,
path: ori.path,
queryParameters: {
'tk': Uri.encodeComponent(auth.client!.credentials.accessToken)
},
queryParameters: {'tk': Uri.encodeComponent(auth.client!.currentToken!)},
);
final channel = WebSocketChannel.connect(uri);
await channel.ready;
try {
_channel = IOWebSocketChannel.connect(uri);
if (onStateUpdated != null) onStateUpdated(true);
await _channel!.ready;
if (onStateUpdated != null) onStateUpdated(false);
} catch (e) {
if (!noRetry) {
await auth.client!.refreshToken(auth.client!.currentRefreshToken!);
connect(auth, noRetry: true);
} else {
rethrow;
}
}
return channel;
_channel!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
switch (result.method) {
case 'notifications.new':
if (result.payload == null) break;
final notification = model.Notification.fromJson(result.payload!);
unreadAmount++;
notifications.add(notification);
notifyListeners();
notifyMessage(notification.subject, notification.content);
break;
case 'kex.request':
if (onKexRequest == null || result.payload == null) break;
final resp = onKexRequest(result.payload!['keypair_id']);
if (resp == null) break;
_channel!.sink.add(jsonEncode(
NetworkPackage(method: 'kex.provide', payload: {
'request_id': result.payload!['request_id'],
'keypair_id': resp.id,
'public_key': resp.publicKey,
'algorithm': resp.algorithm,
}).toJson(),
));
break;
case 'kex.provide':
if (onKexProvide == null || result.payload == null) break;
onKexProvide(Keypair(
id: result.payload!['keypair_id'],
algorithm: result.payload?['algorithm'] ?? 'aes',
publicKey: result.payload!['public_key'],
privateKey: result.payload?['private_key'],
));
break;
}
},
onError: (_, __) => Future.delayed(const Duration(seconds: 3), () => connect(auth)),
onDone: () => Future.delayed(const Duration(seconds: 1), () => connect(auth)),
);
return _channel!;
}
void onRemoteMessage(model.Notification item) {
unreadAmount++;
notifications.add(item);
notifyListeners();
void disconnect() {
_channel?.sink.close(status.goingAway);
}
void notifyMessage(String title, String body) {
@@ -129,7 +183,7 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners();
}
void clearRealtime() {
void clearRealtimeNotifications() {
notifications = notifications.where((x) => !x.isRealtime).toList();
notifyListeners();
}

47
lib/providers/realm.dart Normal file
View File

@@ -0,0 +1,47 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
class RealmProvider with ChangeNotifier {
List<Realm> realms = List.empty();
Realm? focusRealm;
Future<void> fetch(AuthProvider auth) async {
if (!await auth.isAuthorized()) return;
var uri = getRequestUri('passport', '/api/realms/me/available');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
realms = result.map((x) => Realm.fromJson(x)).toList();
notifyListeners();
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
Future<Realm> fetchSingle(AuthProvider auth, String alias) async {
var uri = getRequestUri('passport', '/api/realms/$alias');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
focusRealm = Realm.fromJson(result);
notifyListeners();
return focusRealm!;
} else {
var message = utf8.decode(res.bodyBytes);
throw Exception(message);
}
}
void clearFocus() {
focusRealm = null;
notifyListeners();
}
}

View File

@@ -1,111 +1,281 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/keypair.dart';
import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/auth/signup.dart';
import 'package:solian/screens/chat/call.dart';
import 'package:solian/screens/chat/chat.dart';
import 'package:solian/screens/chat/index.dart';
import 'package:solian/screens/chat/manage.dart';
import 'package:solian/screens/chat/channel/editor.dart';
import 'package:solian/screens/chat/channel/member.dart';
import 'package:solian/screens/chat/chat_list.dart';
import 'package:solian/screens/chat/chat_detail.dart';
import 'package:solian/screens/chat/channel/channel_editor.dart';
import 'package:solian/screens/chat/channel/channel_member.dart';
import 'package:solian/screens/explore.dart';
import 'package:solian/screens/notification.dart';
import 'package:solian/screens/posts/comment_editor.dart';
import 'package:solian/screens/posts/moment_editor.dart';
import 'package:solian/screens/posts/screen.dart';
import 'package:solian/screens/auth/signin.dart';
import 'package:solian/screens/realms/realm.dart';
import 'package:solian/screens/realms/realm_manage.dart';
import 'package:solian/screens/realms/realm_editor.dart';
import 'package:solian/screens/realms/realm_list.dart';
import 'package:solian/screens/realms/realm_member.dart';
import 'package:solian/screens/users/userinfo.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/empty.dart';
import 'package:solian/widgets/layouts/two_column.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
name: 'explore',
builder: (context, state) => const ExploreScreen(),
),
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) => const ChatIndexScreen(),
),
GoRoute(
path: '/chat/create',
name: 'chat.channel.editor',
builder: (context, state) =>
ChannelEditorScreen(editing: state.extra as Channel?),
),
GoRoute(
path: '/chat/c/:channel',
name: 'chat.channel',
builder: (context, state) =>
ChatScreen(alias: state.pathParameters['channel'] as String),
),
GoRoute(
path: '/chat/c/:channel/call',
name: 'chat.channel.call',
builder: (context, state) => ChatCall(call: state.extra as Call),
),
GoRoute(
path: '/chat/c/:channel/manage',
name: 'chat.channel.manage',
builder: (context, state) =>
ChatManageScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/chat/c/:channel/member',
name: 'chat.channel.member',
builder: (context, state) =>
ChatMemberScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) => const AccountScreen(),
),
GoRoute(
path: '/posts/publish/moments',
name: 'posts.moments.editor',
builder: (context, state) =>
MomentEditorScreen(editing: state.extra as Post?),
),
GoRoute(
path: '/posts/publish/comments',
name: 'posts.comments.editor',
builder: (context, state) {
final args = state.extra as CommentPostArguments;
return CommentEditorScreen(
editing: args.editing, related: args.related);
},
),
GoRoute(
path: '/posts/:dataset/:alias',
name: 'posts.screen',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
abstract class SolianRouter {
static final router = GoRouter(
routes: [
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
),
GoRoute(
path: '/auth/sign-in',
name: 'auth.sign-in',
builder: (context, state) => SignInScreen(),
),
GoRoute(
path: '/auth/sign-up',
name: 'auth.sign-up',
builder: (context, state) => SignUpScreen(),
),
GoRoute(
path: '/account/friend',
name: 'account.friend',
builder: (context, state) => const FriendScreen(),
),
],
);
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const ExplorePostScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/',
name: 'explore',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const ExplorePostScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/posts/publish/moments',
name: 'posts.moments.editor',
builder: (context, state) => MomentEditorScreen(editing: state.extra as Post?),
),
GoRoute(
path: '/posts/publish/comments',
name: 'posts.comments.editor',
builder: (context, state) {
final args = state.extra as CommentPostArguments;
return CommentEditorScreen(editing: args.editing, related: args.related);
},
),
GoRoute(
path: '/posts/:dataset/:alias',
name: 'posts.details',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
),
),
],
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const RealmListScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/realms',
name: 'realms',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const RealmListScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/realms/editor',
name: 'realms.editor',
builder: (context, state) => RealmEditorScreen(
editing: state.extra as Realm?,
realm: state.uri.queryParameters['realm'],
),
),
GoRoute(
path: '/realms/:realm/manage',
name: 'realms.manage',
builder: (context, state) => RealmManageScreen(realm: state.extra as Realm),
),
GoRoute(
path: '/realms/:realm/member',
name: 'realms.member',
builder: (context, state) => RealmMemberScreen(realm: state.extra as Realm),
),
GoRoute(
path: '/realms/:realm/posts/publish/moments',
name: 'realms.posts.moments.editor',
builder: (context, state) => MomentEditorScreen(
editing: state.extra as Post?,
realm: state.pathParameters['realm']
),
),
GoRoute(
path: '/realms/:realm/posts/:dataset/:alias',
name: 'realms.posts.details',
builder: (context, state) => PostScreen(
alias: state.pathParameters['alias'] as String,
dataset: state.pathParameters['dataset'] as String,
),
),
GoRoute(
path: '/realms/:realm',
name: 'realms.details',
builder: (context, state) => !SolianTheme.isLargeScreen(context)
? RealmScreen(alias: state.pathParameters['realm'] as String)
: const PageEmptyWidget(),
),
GoRoute(
path: '/realms/:realm/chat/:channel',
name: 'realms.chat.channel',
builder: (context, state) => ChatScreen(
alias: state.pathParameters['channel'] as String,
realm: state.pathParameters['realm'] as String,
),
),
GoRoute(
path: '/realms/:realm/chat/:channel/manage',
name: 'realms.chat.channel.manage',
builder: (context, state) => ChatDetailScreen(
channel: state.extra as Channel,
realm: state.pathParameters['realm'] as String,
),
),
GoRoute(
path: '/realms/:realm/chat/:channel/member',
name: 'realms.chat.channel.member',
builder: (context, state) => ChatMemberScreen(
channel: state.extra as Channel,
realm: state.pathParameters['realm'] as String,
),
),
],
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const ChatListScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/chat',
name: 'chat',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const ChatListScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/chat/editor',
name: 'chat.channel.editor',
builder: (context, state) => ChannelEditorScreen(
editing: state.extra as Channel?,
realm: state.uri.queryParameters['realm'] ?? 'global',
),
),
GoRoute(
path: '/chat/:channel',
name: 'chat.channel',
builder: (context, state) => ChatScreen(alias: state.pathParameters['channel'] as String),
),
GoRoute(
path: '/chat/:channel/manage',
name: 'chat.channel.manage',
builder: (context, state) => ChatDetailScreen(channel: state.extra as Channel),
),
GoRoute(
path: '/chat/:channel/member',
name: 'chat.channel.member',
builder: (context, state) => ChatMemberScreen(channel: state.extra as Channel),
),
],
),
GoRoute(
path: '/chat/:channel/call',
name: 'chat.channel.call',
builder: (context, state) => ChatCall(call: state.extra as Call),
),
ShellRoute(
pageBuilder: (context, state, child) => defaultPageBuilder(
context,
state,
SolianTheme.isLargeScreen(context)
? TwoColumnLayout(
sideChild: const AccountScreen(),
mainChild: child,
)
: child,
),
routes: [
GoRoute(
path: '/account',
name: 'account',
builder: (context, state) =>
!SolianTheme.isLargeScreen(context) ? const AccountScreen() : const PageEmptyWidget(),
),
GoRoute(
path: '/auth/sign-in',
name: 'auth.sign-in',
builder: (context, state) => SignInScreen(),
),
GoRoute(
path: '/auth/sign-up',
name: 'auth.sign-up',
builder: (context, state) => SignUpScreen(),
),
GoRoute(
path: '/account/friend',
name: 'account.friend',
builder: (context, state) => const FriendScreen(),
),
GoRoute(
path: '/account/personalize',
name: 'account.personalize',
builder: (context, state) => const PersonalizeScreen(),
),
GoRoute(
path: '/account/keypair',
name: 'account.keypair',
builder: (context, state) => const KeypairScreen(),
),
],
),
GoRoute(
path: '/users/:user',
name: 'users.info',
builder: (context, state) => UserInfoScreen(name: state.pathParameters['user'] as String),
),
],
);
static GoRoute get currentRoute => SolianRouter.router.routerDelegate.currentConfiguration.last.route;
static Page defaultPageBuilder(
BuildContext context,
GoRouterState state,
Widget child,
) =>
MaterialPage(
key: state.pageKey,
restorationId: state.pageKey.value,
child: child,
);
}

View File

@@ -1,28 +1,49 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/common_wrapper.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/scaffold.dart';
class AccountScreen extends StatefulWidget {
class AccountScreen extends StatelessWidget {
const AccountScreen({super.key});
@override
State<AccountScreen> createState() => _AccountScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.account,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: AccountScreenWidget(
onSelect: (item) {
SolianRouter.router.pushNamed(item);
},
),
);
}
}
class _AccountScreenState extends State<AccountScreen> {
bool isAuthorized = false;
class AccountScreenWidget extends StatefulWidget {
final Function(String item) onSelect;
const AccountScreenWidget({super.key, required this.onSelect});
@override
State<AccountScreenWidget> createState() => _AccountScreenWidgetState();
}
class _AccountScreenWidgetState extends State<AccountScreenWidget> {
bool _isAuthorized = false;
@override
void initState() {
Future.delayed(Duration.zero, () async {
var authorized = await context.read<AuthProvider>().isAuthorized();
setState(() => isAuthorized = authorized);
setState(() => _isAuthorized = authorized);
});
super.initState();
@@ -31,65 +52,76 @@ class _AccountScreenState extends State<AccountScreen> {
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthProvider>();
final keypair = context.read<KeypairProvider>();
return LayoutWrapper(
title: AppLocalizations.of(context)!.account,
child: isAuthorized
? Column(
children: [
const Padding(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 24),
child: NameCard(),
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.diversity_1),
title: Text(AppLocalizations.of(context)!.friend),
onTap: () {
router.goNamed('account.friend');
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut),
onTap: () {
auth.signoff();
setState(() {
isAuthorized = false;
});
},
),
],
)
: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ActionCard(
icon: const Icon(Icons.login, color: Colors.white),
title: AppLocalizations.of(context)!.signIn,
caption: AppLocalizations.of(context)!.signInCaption,
onTap: () {
router.pushNamed('auth.sign-in').then((did) {
auth.isAuthorized().then((value) {
setState(() => isAuthorized = value);
});
});
},
),
ActionCard(
icon: const Icon(Icons.add, color: Colors.white),
title: AppLocalizations.of(context)!.signUp,
caption: AppLocalizations.of(context)!.signUpCaption,
onTap: () {
router.pushNamed('auth.sign-up');
},
),
],
),
final actionItems = [
(const Icon(Icons.color_lens), AppLocalizations.of(context)!.personalize, 'account.personalize'),
(const Icon(Icons.diversity_1), AppLocalizations.of(context)!.friend, 'account.friend'),
(const Icon(Icons.key), AppLocalizations.of(context)!.keypair, 'account.keypair'),
];
if (_isAuthorized) {
return Column(
children: [
const Padding(
padding: EdgeInsets.only(top: 16, bottom: 8, left: 24, right: 24),
child: NameCard(),
),
...(actionItems.map(
(x) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: x.$1,
title: Text(x.$2),
onTap: () {
widget.onSelect(x.$3);
},
),
);
)),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut),
onTap: () {
auth.signoff();
keypair.clearKeys();
context.read<NotifyProvider>().disconnect();
context.read<ChatProvider>().disconnect();
setState(() {
_isAuthorized = false;
});
},
),
],
);
} else {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ActionCard(
icon: const Icon(Icons.login, color: Colors.white),
title: AppLocalizations.of(context)!.signIn,
caption: AppLocalizations.of(context)!.signInCaption,
onTap: () {
SolianRouter.router.pushNamed('auth.sign-in').then((did) {
auth.isAuthorized().then((value) {
setState(() => _isAuthorized = value);
});
});
},
),
ActionCard(
icon: const Icon(Icons.add, color: Colors.white),
title: AppLocalizations.of(context)!.signUp,
caption: AppLocalizations.of(context)!.signUpCaption,
onTap: () {
SolianRouter.router.pushNamed('auth.sign-up');
},
),
],
),
);
}
}
}
@@ -99,7 +131,7 @@ class NameCard extends StatelessWidget {
Future<Widget> renderAvatar(BuildContext context) async {
final auth = context.read<AuthProvider>();
final profiles = await auth.getProfiles();
return AccountAvatar(source: profiles["picture"], direct: true);
return AccountAvatar(source: profiles['picture'], direct: true);
}
Future<Column> renderLabel(BuildContext context) async {
@@ -109,13 +141,19 @@ class NameCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
profiles["nick"],
profiles['nick'],
maxLines: 1,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
Text(profiles["email"])
Text(
profiles['email'],
maxLines: 1,
style: const TextStyle(overflow: TextOverflow.ellipsis),
)
],
);
}
@@ -131,8 +169,7 @@ class NameCard extends StatelessWidget {
children: [
FutureBuilder(
future: renderAvatar(context),
builder:
(BuildContext context, AsyncSnapshot<Widget> snapshot) {
builder: (BuildContext context, AsyncSnapshot<Widget> snapshot) {
if (snapshot.hasData) {
return snapshot.data!;
} else {
@@ -143,10 +180,9 @@ class NameCard extends StatelessWidget {
const SizedBox(width: 20),
FutureBuilder(
future: renderLabel(context),
builder:
(BuildContext context, AsyncSnapshot<Column> snapshot) {
builder: (BuildContext context, AsyncSnapshot<Column> snapshot) {
if (snapshot.hasData) {
return snapshot.data!;
return Expanded(child: snapshot.data!);
} else {
return const Column();
}
@@ -166,12 +202,13 @@ class ActionCard extends StatelessWidget {
final String caption;
final Function onTap;
const ActionCard(
{super.key,
required this.onTap,
required this.title,
required this.caption,
required this.icon});
const ActionCard({
super.key,
required this.onTap,
required this.title,
required this.caption,
required this.icon,
});
@override
Widget build(BuildContext context) {
@@ -198,9 +235,13 @@ class ActionCard extends StatelessWidget {
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
overflow: TextOverflow.clip,
),
),
Text(caption),
Text(
caption,
style: const TextStyle(overflow: TextOverflow.clip),
),
],
),
),

View File

@@ -5,23 +5,37 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/friendship.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class FriendScreen extends StatefulWidget {
class FriendScreen extends StatelessWidget {
const FriendScreen({super.key});
@override
State<FriendScreen> createState() => _FriendScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.friend,
hideDrawer: true,
body: const FriendScreenWidget(),
);
}
}
class _FriendScreenState extends State<FriendScreen> {
class FriendScreenWidget extends StatefulWidget {
const FriendScreenWidget({super.key});
@override
State<FriendScreenWidget> createState() => _FriendScreenWidgetState();
}
class _FriendScreenWidgetState extends State<FriendScreenWidget> {
bool _isSubmitting = false;
int _selfId = 0;
List<Friendship> _friendships = List.empty();
Future<void> fetchFriendships() async {
@@ -120,16 +134,14 @@ class _FriendScreenState extends State<FriendScreen> {
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor:
Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
@@ -157,8 +169,9 @@ class _FriendScreenState extends State<FriendScreen> {
DismissDirection getDismissDirection(Friendship relation) {
if (relation.status == 2) return DismissDirection.endToStart;
if (relation.status == 1) return DismissDirection.startToEnd;
if (relation.status == 0 && relation.relatedId != _selfId)
if (relation.status == 0 && relation.relatedId != _selfId) {
return DismissDirection.startToEnd;
}
return DismissDirection.horizontal;
}
@@ -210,31 +223,22 @@ class _FriendScreenState extends State<FriendScreen> {
);
}
return IndentWrapper(
title: AppLocalizations.of(context)!.friend,
appBarActions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => promptAddFriend(),
),
],
child: RefreshIndicator(
return Scaffold(
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.add),
onPressed: () => promptAddFriend(),
),
body: RefreshIndicator(
onRefresh: () => fetchFriendships(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting
? const LinearProgressIndicator().animate().scaleX()
: Container(),
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
SliverToBoxAdapter(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.8),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
child: Text(AppLocalizations.of(context)!.friendPending),
),
),
@@ -244,12 +248,8 @@ class _FriendScreenState extends State<FriendScreen> {
),
SliverToBoxAdapter(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.8),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
child: Text(AppLocalizations.of(context)!.friendActive),
),
),
@@ -259,12 +259,8 @@ class _FriendScreenState extends State<FriendScreen> {
),
SliverToBoxAdapter(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.8),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
child: Text(AppLocalizations.of(context)!.friendBlocked),
),
),
@@ -277,10 +273,7 @@ class _FriendScreenState extends State<FriendScreen> {
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.8),
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8),
width: 0.3,
)),
),

View File

@@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/keypair.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class KeypairScreen extends StatelessWidget {
const KeypairScreen({super.key});
Widget getIcon(KeypairProvider provider, Keypair item) {
if (item.id == provider.activeKeyId) {
return const Icon(Icons.check_box);
} else if (item.isOwned) {
return const Icon(Icons.check_box_outlined);
} else {
return const Icon(Icons.key);
}
}
void importKeys(BuildContext context) async {
final controller = TextEditingController();
final input = await showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.import),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(AppLocalizations.of(context)!.keypairImportHint),
const SizedBox(height: 18),
TextField(
controller: controller,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.keypairSecretCode,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
],
),
actions: <Widget>[
TextButton(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
Navigator.pop(context, controller.text);
},
),
],
);
},
);
WidgetsBinding.instance.addPostFrameCallback((_) => controller.dispose());
if (input == null || input.isEmpty) return;
context.read<KeypairProvider>().importKeys(input);
}
void exportKeys(BuildContext context) {
showModalBottomSheet(context: context, builder: (context) => const KeypairExportWidget());
}
@override
Widget build(BuildContext context) {
final keypair = context.watch<KeypairProvider>();
final keys = keypair.keys.values.toList();
return IndentScaffold(
title: AppLocalizations.of(context)!.keypair,
hideDrawer: true,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.generating_tokens),
onPressed: () {
final result = keypair.generateAESKey();
keypair.setActiveKey(result.id);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.keypairGenerated),
));
},
),
appBarActions: [
IconButton(
icon: const Icon(Icons.upload),
tooltip: AppLocalizations.of(context)!.import,
onPressed: () => importKeys(context),
),
IconButton(
icon: const Icon(Icons.download),
tooltip: AppLocalizations.of(context)!.export,
onPressed: () => exportKeys(context),
),
],
body: ListView.builder(
itemCount: keys.length,
itemBuilder: (context, index) {
final element = keys[index];
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
return Dismissible(
key: Key(randomId.toString()),
background: Container(
color: Colors.teal,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.check, color: Colors.white),
),
direction: keypair.activeKeyId != element.id && element.isOwned
? DismissDirection.horizontal
: DismissDirection.none,
child: ListTile(
leading: getIcon(keypair, element),
title: Text('${element.algorithm.toUpperCase()} Key'),
subtitle: Text(element.id.toUpperCase()),
),
onDismissed: (_) {
keypair.setActiveKey(element.id);
},
);
},
),
);
}
}
class KeypairExportWidget extends StatelessWidget {
const KeypairExportWidget({super.key});
String getEncodedContent(BuildContext context) {
final keypair = context.read<KeypairProvider>();
return utf8.fuse(base64).encode(jsonEncode(
keypair.keys.values.map((x) => x.toJson()).toList(),
));
}
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: SizedBox(
width: double.infinity,
height: 640,
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.export,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 20, bottom: 20),
child: Text(
AppLocalizations.of(context)!.keypairExportHint,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${getEncodedContent(context)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
codeblockPadding: const EdgeInsets.symmetric(vertical: 20, horizontal: 8),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,324 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PersonalizeScreen extends StatelessWidget {
const PersonalizeScreen({super.key});
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.personalize,
hideDrawer: true,
body: const PersonalizeScreenWidget(),
);
}
}
class PersonalizeScreenWidget extends StatefulWidget {
const PersonalizeScreenWidget({super.key});
@override
State<PersonalizeScreenWidget> createState() => _PersonalizeScreenWidgetState();
}
class _PersonalizeScreenWidgetState extends State<PersonalizeScreenWidget> {
final _imagePicker = ImagePicker();
final _usernameController = TextEditingController();
final _nicknameController = TextEditingController();
final _firstNameController = TextEditingController();
final _lastNameController = TextEditingController();
final _descriptionController = TextEditingController();
final _birthdayController = TextEditingController();
String? _avatar;
String? _banner;
DateTime? _birthday;
bool _isSubmitting = false;
void editBirthday() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _birthday,
firstDate: DateTime(DateTime.now().year - 200),
lastDate: DateTime(DateTime.now().year + 200),
);
if (picked != null && picked != _birthday) {
setState(() {
_birthday = picked;
_birthdayController.text = DateFormat('yyyy-MM-dd hh:mm').format(_birthday!);
});
}
}
void resetInputs() async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
setState(() {
_usernameController.text = prof['name'];
_nicknameController.text = prof['nick'];
_descriptionController.text = prof['description'];
_firstNameController.text = prof['profile']['first_name'];
_lastNameController.text = prof['profile']['last_name'];
if (prof['avatar'] != null && prof['avatar'].isNotEmpty) {
_avatar = getRequestUri('passport', '/api/avatar/${prof['avatar']}').toString();
}
if (prof['banner'] != null && prof['banner'].isNotEmpty) {
_banner = getRequestUri('passport', '/api/avatar/${prof['banner']}').toString();
}
if (prof['profile']['birthday'] != null) {
_birthday = DateTime.parse(prof['profile']['birthday']);
_birthdayController.text = DateFormat('yyyy-MM-dd hh:mm').format(_birthday!);
}
});
}
void applyChanges() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final res = await auth.client!.put(
getRequestUri('passport', '/api/users/me'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'nick': _nicknameController.value.text,
'description': _descriptionController.value.text,
'first_name': _firstNameController.value.text,
'last_name': _lastNameController.value.text,
'birthday': _birthday?.toIso8601String(),
}),
);
if (res.statusCode == 200) {
await auth.fetchProfiles();
resetInputs();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.personalizeApplied),
));
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
Future<void> applyImage(String position) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
setState(() => _isSubmitting = true);
final file = File(image.path);
try {
final req = MultipartRequest('PUT', getRequestUri('passport', '/api/users/me/$position'));
req.files.add(await MultipartFile.fromPath(position, file.path));
var res = await auth.client!.send(req);
if (res.statusCode == 200) {
await auth.fetchProfiles();
resetInputs();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.personalizeApplied),
));
} else {
throw Exception(utf8.decode(await res.stream.toBytes()));
}
} catch (err) {
context.showErrorDialog(err);
}
setState(() => _isSubmitting = false);
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
resetInputs();
});
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: ListView(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
const SizedBox(height: 24),
Stack(
children: [
AccountAvatar(source: _avatar ?? '', radius: 40, direct: true),
Positioned(
bottom: 0,
left: 40,
child: FloatingActionButton.small(
heroTag: const Key('avatar-editor'),
onPressed: () => applyImage('avatar'),
child: const Icon(
Icons.camera,
),
),
),
],
),
const SizedBox(height: 16),
Stack(
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _banner != null
? Image.network(
_banner!,
fit: BoxFit.cover,
loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: null,
),
);
},
)
: Container(),
),
),
),
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton(
heroTag: const Key('banner-editor'),
onPressed: () => applyImage('banner'),
child: const Icon(
Icons.camera_alt,
),
),
),
],
),
const SizedBox(height: 24),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
readOnly: true,
controller: _usernameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
prefixText: '@',
),
),
),
const SizedBox(width: 16),
Flexible(
flex: 1,
child: TextField(
controller: _nicknameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.nickname,
),
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Flexible(
flex: 1,
child: TextField(
controller: _firstNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.firstName,
),
),
),
const SizedBox(width: 16),
Flexible(
flex: 1,
child: TextField(
controller: _lastNameController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.lastName,
),
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _descriptionController,
keyboardType: TextInputType.multiline,
maxLines: null,
minLines: 3,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.description,
),
),
const SizedBox(height: 16),
TextField(
controller: _birthdayController,
readOnly: true,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.birthday,
),
onTap: editBirthday,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isSubmitting ? null : () => resetInputs(),
child: Text(AppLocalizations.of(context)!.reset),
),
ElevatedButton(
onPressed: _isSubmitting ? null : () => applyChanges(),
child: Text(AppLocalizations.of(context)!.apply),
),
],
),
],
),
);
}
}

View File

@@ -1,41 +0,0 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AuthorizationScreen extends StatelessWidget {
final Uri authorizationUrl;
const AuthorizationScreen(this.authorizationUrl, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context)!.signIn),
),
body: Stack(children: [
WebViewWidget(
controller: WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.white)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (NavigationRequest request) {
if (request.url.startsWith('solian')) {
Navigator.of(context).pop(request.url);
WebViewCookieManager().clearCookies();
return NavigationDecision.prevent;
} else if (request.url.contains("sign-up")) {
launchUrl(Uri.parse(request.url));
return NavigationDecision.prevent;
}
return NavigationDecision.navigate;
},
))
..loadRequest(authorizationUrl)
..clearCache(),
),
]),
);
}
}

View File

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SignInScreen extends StatelessWidget {
@@ -21,14 +23,15 @@ class SignInScreen extends StatelessWidget {
final password = _passwordController.value.text;
if (username.isEmpty || password.isEmpty) return;
auth.signin(context, username, password).then((_) {
router.pop(true);
context.read<ChatProvider>().connect(auth);
context.read<NotifyProvider>().connect(auth);
SolianRouter.router.pop(true);
}).catchError((e) {
List<String> messages = e.toString().split('\n');
if (messages.last.contains("risk")) {
final ticketId = RegExp(r"ticketId=(\d+)").firstMatch(messages.last);
if (messages.last.contains('risk')) {
final ticketId = RegExp(r'ticketId=(\d+)').firstMatch(messages.last);
if (ticketId == null) {
context.showErrorDialog(
"requested to multi-factor authenticate, but the ticket id was not found");
context.showErrorDialog('requested to multi-factor authenticate, but the ticket id was not found');
}
showDialog(
context: context,
@@ -41,9 +44,7 @@ class SignInScreen extends StatelessWidget {
child: Text(AppLocalizations.of(context)!.next),
onPressed: () {
launchUrlString(
getRequestUri(
'passport', '/mfa?ticket=${ticketId!.group(1)}')
.toString(),
getRequestUri('passport', '/mfa?ticket=${ticketId!.group(1)}').toString(),
);
if (Navigator.canPop(context)) {
Navigator.pop(context);
@@ -64,10 +65,10 @@ class SignInScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return IndentWrapper(
return IndentScaffold(
title: AppLocalizations.of(context)!.signIn,
hideDrawer: true,
child: Center(
body: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),
@@ -88,8 +89,7 @@ class SignInScreen extends StatelessWidget {
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.username,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 12),
TextField(
@@ -103,8 +103,7 @@ class SignInScreen extends StatelessWidget {
border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.password,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onSubmitted: (_) => performSignIn(context),
),
const SizedBox(height: 16),

View File

@@ -1,8 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:http/http.dart' as http;
class SignUpScreen extends StatelessWidget {
final _emailController = TextEditingController();
@@ -10,11 +14,11 @@ class SignUpScreen extends StatelessWidget {
final _nicknameController = TextEditingController();
final _passwordController = TextEditingController();
final http.Client _client = http.Client();
SignUpScreen({super.key});
void performSignIn(BuildContext context) {
final auth = context.read<AuthProvider>();
void performSignIn(BuildContext context) async {
final email = _emailController.value.text;
final username = _usernameController.value.text;
final nickname = _passwordController.value.text;
@@ -23,14 +27,51 @@ class SignUpScreen extends StatelessWidget {
username.isEmpty ||
nickname.isEmpty ||
password.isEmpty) return;
final uri = getRequestUri('passport', '/api/users');
final res = await _client.post(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'name': username,
'nick': nickname,
'email': email,
'password': password,
}),
);
if (res.statusCode == 200) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: Text(AppLocalizations.of(context)!.signUpDone),
content: Text(AppLocalizations.of(context)!.signUpDoneCaption),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.confirmOkay),
onPressed: () => Navigator.pop(context),
),
],
);
},
).then((_) {
SolianRouter.router.replaceNamed('auth.sign-in');
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
@override
Widget build(BuildContext context) {
return IndentWrapper(
return IndentScaffold(
title: AppLocalizations.of(context)!.signUp,
hideDrawer: true,
child: Center(
body: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.6,
constraints: const BoxConstraints(maxWidth: 360),

View File

@@ -3,10 +3,11 @@ import 'package:livekit_client/livekit_client.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/widgets/chat/call/controls.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/chat/call/call_controls.dart';
import 'package:solian/widgets/chat/call/participant.dart';
import 'package:solian/widgets/chat/call/participant_menu.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'dart:math' as math;
@@ -24,14 +25,14 @@ class _ChatCallState extends State<ChatCall> {
late ChatProvider _chat;
ChatCallInstance get _call => _chat.call!;
ChatCallInstance get _call => _chat.currentCall!;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_chat.setShown(true);
_chat.setCallShown(true);
});
}
@@ -40,13 +41,13 @@ class _ChatCallState extends State<ChatCall> {
_chat = context.watch<ChatProvider>();
if (!_isHandled) {
_isHandled = true;
if (_chat.handleCall(widget.call, widget.call.channel)) {
_chat.call?.init();
if (_chat.handleCallJoin(widget.call, widget.call.channel)) {
_chat.currentCall?.init();
}
}
Widget content;
if (_chat.call == null) {
if (_chat.currentCall == null) {
content = const Center(
child: CircularProgressIndicator(),
);
@@ -127,16 +128,17 @@ class _ChatCallState extends State<ChatCall> {
);
}
return IndentWrapper(
return IndentScaffold(
title: AppLocalizations.of(context)!.chatCall,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
child: content,
body: content,
);
}
@override
void deactivate() {
WidgetsBinding.instance.addPostFrameCallback((_) => _chat.setShown(false));
WidgetsBinding.instance.addPostFrameCallback((_) => _chat.setCallShown(false));
super.deactivate();
}
}

View File

@@ -0,0 +1,198 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:uuid/uuid.dart';
class ChannelEditorScreen extends StatefulWidget {
final Channel? editing;
final String realm;
const ChannelEditorScreen({super.key, this.editing, this.realm = 'global'});
@override
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
}
class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isEncrypted = false;
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final scope = widget.realm.isNotEmpty ? widget.realm : 'global';
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels/$scope')
: getRequestUri('messaging', '/api/channels/$scope/${widget.editing!.id}');
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_encrypted': _isEncrypted,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(_aliasController.value.text.toLowerCase());
}
}
setState(() => _isSubmitting = false);
}
void randomizeAlias() {
_aliasController.text = const Uuid().v4().replaceAll('-', '');
}
void cancelEditing() {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@override
void initState() {
if (widget.editing != null) {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
_isEncrypted = widget.editing!.isEncrypted;
}
super.initState();
}
@override
Widget build(BuildContext context) {
final editingBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatChannelEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentScaffold(
hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.chatChannelOrganize,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => applyChannel(context) : null,
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
),
],
body: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
widget.editing != null ? editingBanner : Container(),
ListTile(
title: Text(AppLocalizations.of(context)!.chatChannelUsage),
subtitle: Text(AppLocalizations.of(context)!.chatChannelUsageCaption),
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.tag, color: Colors.white),
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelAliasLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelNameLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.chatChannelDescriptionLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.chatChannelEncryptedLabel),
value: _isEncrypted,
onChanged: (widget.editing?.isEncrypted ?? false) ? null : (newValue) {
setState(() => _isEncrypted = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
),
],
),
);
}
}

View File

@@ -6,17 +6,18 @@ import 'package:provider/provider.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/friend_picker.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMemberScreen extends StatefulWidget {
final Channel channel;
final String realm;
const ChatMemberScreen({super.key, required this.channel});
const ChatMemberScreen({super.key, required this.channel, this.realm = 'global'});
@override
State<ChatMemberScreen> createState() => _ChatMemberScreenState();
@@ -36,8 +37,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
_selfId = prof['id'];
var uri = getRequestUri(
'messaging', '/api/channels/${widget.channel.alias}/members');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
@@ -51,7 +51,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
}
}
Future<void> kickMember(ChannelMember item) async {
Future<void> removeMember(ChannelMember item) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
@@ -60,16 +60,15 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
return;
}
var uri = getRequestUri(
'messaging', '/api/channels/${widget.channel.alias}/kick');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members');
var res = await auth.client!.post(
var res = await auth.client!.delete(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'account_name': item.account.name,
'target': item.account.name,
}),
);
if (res.statusCode == 200) {
@@ -82,7 +81,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
setState(() => _isSubmitting = false);
}
Future<void> inviteMember(String username) async {
Future<void> addMember(String username) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
@@ -91,8 +90,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
return;
}
var uri = getRequestUri(
'messaging', '/api/channels/${widget.channel.alias}/invite');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members');
var res = await auth.client!.post(
uri,
@@ -100,7 +98,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
'Content-Type': 'application/json',
},
body: jsonEncode({
'account_name': username,
'target': username,
}),
);
if (res.statusCode == 200) {
@@ -113,7 +111,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
setState(() => _isSubmitting = false);
}
void promptInviteMember() async {
void promptAddMember() async {
final input = await showModalBottomSheet(
context: context,
builder: (context) {
@@ -122,10 +120,10 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
);
if (input == null) return;
await inviteMember((input as Account).name);
await addMember((input as Account).name);
}
bool getKickable(ChannelMember item) {
bool getRemovable(ChannelMember item) {
if (_selfId != widget.channel.account.externalId) return false;
if (item.accountId == widget.channel.accountId) return false;
if (item.account.externalId == _selfId) return false;
@@ -141,24 +139,21 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
@override
Widget build(BuildContext context) {
return IndentWrapper(
return IndentScaffold(
title: AppLocalizations.of(context)!.chatMember,
noSafeArea: true,
hideDrawer: true,
appBarActions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => promptInviteMember(),
onPressed: () => promptAddMember(),
),
],
child: RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => fetchMemberships(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting
? const LinearProgressIndicator().animate().scaleX()
: Container(),
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
SliverList.builder(
itemCount: _members.length,
@@ -169,9 +164,7 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
return Dismissible(
key: Key(randomId.toString()),
direction: getKickable(element)
? DismissDirection.startToEnd
: DismissDirection.none,
direction: getRemovable(element) ? DismissDirection.startToEnd : DismissDirection.none,
background: Container(
color: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 20),
@@ -179,13 +172,12 @@ class _ChatMemberScreenState extends State<ChatMemberScreen> {
child: const Icon(Icons.remove, color: Colors.white),
),
child: ListTile(
leading: AccountAvatar(
source: element.account.avatar, direct: true),
leading: AccountAvatar(source: element.account.avatar, direct: true),
title: Text(element.account.nick),
subtitle: Text(element.account.name),
),
onDismissed: (_) {
kickMember(element);
removeMember(element);
},
);
},

View File

@@ -1,200 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:uuid/uuid.dart';
class ChannelEditorScreen extends StatefulWidget {
final Channel? editing;
const ChannelEditorScreen({super.key, this.editing});
@override
State<ChannelEditorScreen> createState() => _ChannelEditorScreenState();
}
class _ChannelEditorScreenState extends State<ChannelEditorScreen> {
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels')
: getRequestUri('messaging', '/api/channels/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (router.canPop()) {
router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void randomizeAlias() {
_aliasController.text = const Uuid().v4().replaceAll('-', '');
}
void cancelEditing() {
if (router.canPop()) {
router.pop(false);
}
}
@override
void initState() {
if (widget.editing != null) {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
}
super.initState();
}
@override
Widget build(BuildContext context) {
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatChannelEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentWrapper(
hideDrawer: true,
title: AppLocalizations.of(context)!.chatChannelOrganize,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => applyChannel(context) : null,
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
),
],
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
children: [
_isSubmitting
? const LinearProgressIndicator().animate().scaleX()
: Container(),
ListTile(
title: Text(AppLocalizations.of(context)!.chatChannelUsage),
subtitle:
Text(AppLocalizations.of(context)!.chatChannelUsageCaption),
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.tag, color: Colors.white),
),
),
const Divider(thickness: 0.3),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!
.chatChannelAliasLabel,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity:
const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
),
),
const Divider(thickness: 0.3),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.chatChannelNameLabel,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!
.chatChannelDescriptionLabel,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
widget.editing != null ? editingBanner : Container(),
],
),
),
),
);
}
}

View File

@@ -4,99 +4,92 @@ import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/chat/channel_action.dart';
import 'package:solian/widgets/chat/maintainer.dart';
import 'package:solian/widgets/chat/message.dart';
import 'package:solian/widgets/chat/message_action.dart';
import 'package:solian/widgets/chat/message_editor.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http;
class ChatScreen extends StatefulWidget {
class ChatScreen extends StatelessWidget {
final String alias;
final String realm;
const ChatScreen({super.key, required this.alias});
const ChatScreen({super.key, required this.alias, this.realm = 'global'});
@override
State<ChatScreen> createState() => _ChatScreenState();
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.watch<ChatProvider>();
return IndentScaffold(
title: chat.focusChannel?.name ?? 'Loading...',
hideDrawer: true,
showSafeArea: true,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
appBarActions: chat.focusChannel != null
? [
ChannelCallAction(
call: chat.ongoingCall,
channel: chat.focusChannel!,
realm: realm,
onUpdate: () => chat.fetchChannel(context, auth, chat.focusChannel!.alias, realm),
),
ChannelManageAction(
channel: chat.focusChannel!,
realm: realm,
onUpdate: () => chat.fetchChannel(context, auth, chat.focusChannel!.alias, realm),
),
]
: [],
body: ChatWidget(
alias: alias,
realm: realm,
),
);
}
}
class _ChatScreenState extends State<ChatScreen> {
Call? _ongoingCall;
Channel? _channelMeta;
class ChatWidget extends StatefulWidget {
final String alias;
final String realm;
final PagingController<int, Message> _pagingController =
PagingController(firstPageKey: 0);
const ChatWidget({super.key, required this.alias, required this.realm});
final http.Client _client = http.Client();
@override
State<ChatWidget> createState() => _ChatWidgetState();
}
Future<Channel> fetchMetadata() async {
var uri = getRequestUri('messaging', '/api/channels/${widget.alias}');
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
setState(() => _channelMeta = Channel.fromJson(result));
return _channelMeta!;
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
}
}
class _ChatWidgetState extends State<ChatWidget> {
bool _isReady = false;
Future<Call?> fetchCall() async {
var uri = getRequestUri(
'messaging', '/api/channels/${widget.alias}/calls/ongoing');
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes));
setState(() => _ongoingCall = Call.fromJson(result));
return _ongoingCall;
} else if (res.statusCode != 404) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
} else {
return null;
}
}
late final ChatProvider _chat;
Future<void> fetchMessages(int pageKey, BuildContext context) async {
Future<void> joinChannel() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final offset = pageKey;
const take = 10;
var uri = getRequestUri(
'messaging',
'/api/channels/${widget.alias}/messages?take=$take&offset=$offset',
'/api/channels/${widget.realm}/${widget.alias}/members/me',
);
var res = await auth.client!.get(uri);
var res = await auth.client!.post(uri);
if (res.statusCode == 200) {
final result =
PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items =
result.data?.map((x) => Message.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items);
} else {
final nextPageKey = pageKey + items.length;
_pagingController.appendPage(items, nextPageKey);
}
setState(() {});
_chat.historyPagingController?.refresh();
} else {
_pagingController.error = utf8.decode(res.bodyBytes);
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message).then((_) {
SolianRouter.router.pop();
});
}
}
@@ -104,28 +97,7 @@ class _ChatScreenState extends State<ChatScreen> {
if (a?.replyTo != null) return false;
if (a == null || b == null) return false;
if (a.senderId != b.senderId) return false;
return a.createdAt.difference(b.createdAt).inMinutes <= 5;
}
void addMessage(Message item) {
setState(() {
_pagingController.itemList?.insert(0, item);
});
}
void updateMessage(Message item) {
setState(() {
_pagingController.itemList = _pagingController.itemList
?.map((x) => x.id == item.id ? item : x)
.toList();
});
}
void deleteMessage(Message item) {
setState(() {
_pagingController.itemList =
_pagingController.itemList?.where((x) => x.id != item.id).toList();
});
return a.createdAt.difference(b.createdAt).inMinutes <= 3;
}
Message? _editingItem;
@@ -136,6 +108,7 @@ class _ChatScreenState extends State<ChatScreen> {
context: context,
builder: (context) => ChatMessageAction(
channel: widget.alias,
realm: widget.realm,
item: item,
onEdit: () => setState(() {
_editingItem = item;
@@ -147,17 +120,57 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
void showUnavailableDialog() {
final content = widget.realm == 'global'
? AppLocalizations.of(context)!.chatChannelUnavailableCaption
: AppLocalizations.of(context)!.chatChannelUnavailableCaptionWithRealm;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.chatChannelUnavailable),
content: Text(content),
actions: <Widget>[
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () {
Navigator.of(context).pop();
SolianRouter.router.pop();
},
),
...(widget.realm != 'global'
? [
TextButton(
child: Text(AppLocalizations.of(context)!.join),
onPressed: () {
Navigator.of(context).pop();
joinChannel();
},
),
]
: [])
],
),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchMetadata();
fetchCall();
});
_pagingController
.addPageRequestListener((pageKey) => fetchMessages(pageKey, context));
super.initState();
Future.delayed(Duration.zero, () async {
final auth = context.read<AuthProvider>();
await _chat.connect(auth);
_chat.fetchOngoingCall(widget.alias, widget.realm);
_chat.fetchChannel(context, auth, widget.alias, widget.realm).then((result) {
if (result.isAvailable == false) {
showUnavailableDialog();
}
});
});
}
@override
@@ -165,12 +178,10 @@ class _ChatScreenState extends State<ChatScreen> {
Widget chatHistoryBuilder(context, item, index) {
bool isMerged = false, hasMerged = false;
if (index > 0) {
hasMerged =
getMessageMergeable(_pagingController.itemList?[index - 1], item);
hasMerged = getMessageMergeable(_chat.historyPagingController?.itemList?[index - 1], item);
}
if (index + 1 < (_pagingController.itemList?.length ?? 0)) {
isMerged =
getMessageMergeable(item, _pagingController.itemList?[index + 1]);
if (index + 1 < (_chat.historyPagingController?.itemList?.length ?? 0)) {
isMerged = getMessageMergeable(item, _chat.historyPagingController?.itemList?[index + 1]);
}
return InkWell(
child: Container(
@@ -181,29 +192,37 @@ class _ChatScreenState extends State<ChatScreen> {
right: 12,
),
child: ChatMessage(
key: Key('m${item.id}'),
item: item,
underMerged: isMerged,
),
),
onLongPress: () => viewActions(item),
);
).animate(key: Key('m${item.id}'), autoPlay: true).slideY(
curve: Curves.fastEaseInToSlowEaseOut,
duration: 350.ms,
begin: 0.25,
end: 0,
);
}
if (!_isReady) {
_isReady = true;
_chat = context.watch<ChatProvider>();
}
final callBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.call_received),
backgroundColor:
Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatCallOngoing),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.chatCallJoin),
onPressed: () {
router.pushNamed(
SolianRouter.router.pushNamed(
'chat.channel.call',
extra: _ongoingCall,
extra: _chat.ongoingCall,
pathParameters: {'channel': widget.alias},
);
},
@@ -211,66 +230,41 @@ class _ChatScreenState extends State<ChatScreen> {
],
);
return IndentWrapper(
hideDrawer: true,
title: _channelMeta?.name ?? "Loading...",
appBarActions: _channelMeta != null
? [
ChannelCallAction(
call: _ongoingCall,
channel: _channelMeta!,
onUpdate: () => fetchMetadata()),
ChannelManageAction(
channel: _channelMeta!, onUpdate: () => fetchMetadata()),
]
: [],
child: FutureBuilder(
future: fetchMetadata(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator());
}
if (_chat.focusChannel == null || _chat.historyPagingController == null) {
return const Center(child: CircularProgressIndicator());
}
return ChatMaintainer(
channel: snapshot.data!,
child: Stack(
children: [
Column(
children: [
Expanded(
child: PagedListView<int, Message>(
reverse: true,
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Message>(
noItemsFoundIndicatorBuilder: (_) => Container(),
itemBuilder: chatHistoryBuilder,
),
),
),
ChatMessageEditor(
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
],
return Stack(
children: [
Column(
children: [
Expanded(
child: PagedListView<int, Message>(
reverse: true,
pagingController: _chat.historyPagingController!,
builderDelegate: PagedChildBuilderDelegate<Message>(
animateTransitions: true,
transitionDuration: 350.ms,
itemBuilder: chatHistoryBuilder,
noItemsFoundIndicatorBuilder: (_) => Container(),
),
_ongoingCall != null
? callBanner.animate().slideY()
: Container(),
],
),
),
onInsertMessage: (message) => addMessage(message),
onUpdateMessage: (message) => updateMessage(message),
onDeleteMessage: (message) => deleteMessage(message),
onCallStarted: (call) => setState(() => _ongoingCall = call),
onCallEnded: () => setState(() => _ongoingCall = null),
);
},
),
ChatMessageEditor(
realm: widget.realm,
channel: widget.alias,
editing: _editingItem,
replying: _replyingItem,
isEncrypted: _chat.focusChannel?.isEncrypted ?? false,
onReset: () => setState(() {
_editingItem = null;
_replyingItem = null;
}),
),
],
),
_chat.ongoingCall != null ? callBanner.animate().slideY() : Container(),
],
);
}
}

View File

@@ -2,33 +2,37 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/chat/channel_deletion.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatManageScreen extends StatefulWidget {
class ChatDetailScreen extends StatefulWidget {
final Channel channel;
final String realm;
const ChatManageScreen({super.key, required this.channel});
const ChatDetailScreen({super.key, required this.channel, this.realm = 'global'});
@override
State<ChatManageScreen> createState() => _ChatManageScreenState();
State<ChatDetailScreen> createState() => _ChatDetailScreenState();
}
class _ChatManageScreenState extends State<ChatManageScreen> {
class _ChatDetailScreenState extends State<ChatDetailScreen> {
bool _isOwned = false;
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) => ChannelDeletion(
channel: widget.channel,
isOwned: _isOwned,
),
builder: (context) =>
ChannelDeletion(
channel: widget.channel,
realm: widget.realm,
isOwned: _isOwned,
),
);
if (did == true && router.canPop()) {
router.pop('disposed');
if (did == true && SolianRouter.router.canPop()) {
SolianRouter.router.pop('disposed');
}
}
@@ -48,27 +52,33 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
final authorizedItems = [
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () async {
router
.pushNamed('chat.channel.editor', extra: widget.channel)
.then((did) {
if (did == true) {
if (router.canPop()) router.pop('refresh');
SolianRouter.router
.pushNamed(
'chat.channel.editor',
extra: widget.channel,
queryParameters: widget.realm != 'global' ? {'realm': widget.realm} : {},
)
.then((resp) {
if (resp != null) {
chat.fetchChannel(context, auth, resp as String, widget.realm);
}
});
},
),
];
return IndentWrapper(
title: AppLocalizations.of(context)!.chatManage,
return IndentScaffold(
title: AppLocalizations.of(context)!.chatDetail,
hideDrawer: true,
noSafeArea: true,
child: Column(
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
@@ -81,14 +91,16 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.channel.name,
style: Theme.of(context).textTheme.bodyLarge),
Text(widget.channel.description,
style: Theme.of(context).textTheme.bodySmall),
]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.channel.name, style: Theme
.of(context)
.textTheme
.bodyLarge),
Text(widget.channel.description, style: Theme
.of(context)
.textTheme
.bodySmall),
]),
)
],
),
@@ -106,22 +118,21 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
leading: const Icon(Icons.supervisor_account),
title: Text(AppLocalizations.of(context)!.chatMember),
onTap: () {
router.pushNamed(
'chat.channel.member',
SolianRouter.router.pushNamed(
widget.realm == 'global' ? 'chat.channel.member' : 'realms.chat.channel.member',
extra: widget.channel,
pathParameters: {'channel': widget.channel.alias},
pathParameters: {
'channel': widget.channel.alias,
...(widget.realm == 'global' ? {} : {'realm': widget.realm}),
},
);
},
),
...(_isOwned ? authorizedItems : List.empty()),
const Divider(thickness: 0.3),
ListTile(
leading: _isOwned
? const Icon(Icons.delete)
: const Icon(Icons.exit_to_app),
title: Text(_isOwned
? AppLocalizations.of(context)!.delete
: AppLocalizations.of(context)!.exit),
leading: _isOwned ? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
title: Text(_isOwned ? AppLocalizations.of(context)!.delete : AppLocalizations.of(context)!.exit),
onTap: () => promptLeaveChannel(),
),
],

View File

@@ -0,0 +1,147 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/chat/chat_new.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/signin_required.dart';
class ChatListScreen extends StatelessWidget {
const ChatListScreen({super.key});
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.chat,
appBarActions: const [NotificationButton()],
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: const ChatListWidget(),
);
}
}
class ChatListWidget extends StatefulWidget {
final String? realm;
const ChatListWidget({super.key, this.realm});
@override
State<ChatListWidget> createState() => _ChatListWidgetState();
}
class _ChatListWidgetState extends State<ChatListWidget> {
List<Channel> _channels = List.empty();
Future<void> fetchChannels() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
Uri uri;
if (widget.realm == null) {
uri = getRequestUri('messaging', '/api/channels/global/me/available');
} else {
uri = getRequestUri('messaging', '/api/channels/${widget.realm}');
}
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_channels = result.map((x) => Channel.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
void viewNewChatAction() {
showModalBottomSheet(
context: context,
builder: (context) => ChatNewAction(
onUpdate: () => fetchChannels(),
realm: widget.realm,
),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchChannels();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final chat = context.watch<ChatProvider>();
return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.chatNew),
onPressed: () => viewNewChatAction(),
);
} else {
return Container();
}
},
),
body: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SignInRequiredScreen();
}
return RefreshIndicator(
onRefresh: () => fetchChannels(),
child: ListView.builder(
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.tag, color: Colors.white),
),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () async {
if (['chat.channel', 'realms.chat.channel'].contains(SolianRouter.currentRoute.name)) {
chat.fetchChannel(context, auth, element.alias, widget.realm!);
} else {
SolianRouter.router.pushNamed(
widget.realm == null ? 'chat.channel' : 'realms.chat.channel',
pathParameters: {
'channel': element.alias,
...(widget.realm == null ? {} : {'realm': widget.realm!}),
},
);
}
},
);
},
),
);
},
),
);
}
}

View File

@@ -1,120 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/chat/chat_new.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/signin_required.dart';
class ChatIndexScreen extends StatefulWidget {
const ChatIndexScreen({super.key});
@override
State<ChatIndexScreen> createState() => _ChatIndexScreenState();
}
class _ChatIndexScreenState extends State<ChatIndexScreen> {
List<Channel> _channels = List.empty();
Future<void> fetchChannels() async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
var uri = getRequestUri('messaging', '/api/channels/me/available');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_channels = result.map((x) => Channel.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
void viewNewChatAction() {
showModalBottomSheet(
context: context,
builder: (context) => ChatNewAction(onUpdate: () => fetchChannels()),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
fetchChannels();
});
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return IndentWrapper(
title: AppLocalizations.of(context)!.chat,
appBarActions: const [NotificationButton()],
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.chatNew),
onPressed: () => viewNewChatAction(),
);
} else {
return Container();
}
},
),
child: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SignInRequiredScreen();
}
return RefreshIndicator(
onRefresh: () => fetchChannels(),
child: ListView.builder(
itemCount: _channels.length,
itemBuilder: (context, index) {
final element = _channels[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.tag, color: Colors.white),
),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () async {
final result = await router.pushNamed(
'chat.channel',
pathParameters: {
'channel': element.alias,
},
);
switch (result) {
case 'refresh':
fetchChannels();
}
},
);
},
),
);
}),
);
}
}

View File

@@ -1,29 +1,53 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/realms/realm_shortcuts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/widgets/posts/post.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
class ExplorePostScreen extends StatelessWidget {
const ExplorePostScreen({super.key});
@override
State<ExploreScreen> createState() => _ExploreScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
fixedAppBarColor: SolianTheme.isLargeScreen(context),
appBarActions: const [NotificationButton()],
title: AppLocalizations.of(context)!.explore,
body: const ExplorePostWidget(showRealmShortcuts: true),
);
}
}
class _ExploreScreenState extends State<ExploreScreen> {
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
class ExplorePostWidget extends StatefulWidget {
final String? realm;
final bool showRealmShortcuts;
const ExplorePostWidget({
super.key,
this.realm,
this.showRealmShortcuts = false,
});
@override
State<ExplorePostWidget> createState() => _ExplorePostWidgetState();
}
class _ExplorePostWidgetState extends State<ExplorePostWidget> {
final PagingController<int, Post> _pagingController = PagingController(firstPageKey: 0);
final http.Client _client = http.Client();
@@ -31,15 +55,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
final offset = pageKey;
const take = 5;
var uri =
getRequestUri('interactive', '/api/feed?take=$take&offset=$offset');
Uri uri;
if (widget.realm == null) {
uri = getRequestUri('interactive', '/api/feed?take=$take&offset=$offset');
} else {
uri = getRequestUri('interactive', '/api/feed?realm=${widget.realm}&take=$take&offset=$offset');
}
var res = await _client.get(uri);
if (res.statusCode == 200) {
final result =
PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items =
result.data?.map((x) => Post.fromJson(x)).toList() ?? List.empty();
final result = PaginationResult.fromJson(jsonDecode(utf8.decode(res.bodyBytes)));
final items = result.data?.map((x) => Post.fromJson(x)).toList() ?? List.empty();
final isLastPage = (result.count - pageKey) < take;
if (isLastPage || result.data == null) {
_pagingController.appendLastPage(items);
@@ -56,6 +82,16 @@ class _ExploreScreenState extends State<ExploreScreen> {
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
if (widget.showRealmShortcuts) {
final auth = context.read<AuthProvider>();
if (auth.client == null) {
await auth.loadClient();
}
context.read<RealmProvider>().fetch(auth);
}
});
_pagingController.addPageRequestListener((pageKey) => fetchFeed(pageKey));
}
@@ -63,8 +99,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return IndentWrapper(
noSafeArea: true,
return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
@@ -72,7 +107,10 @@ class _ExploreScreenState extends State<ExploreScreen> {
return FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () async {
final did = await router.pushNamed("posts.moments.editor");
final did = await SolianRouter.router.pushNamed(
widget.realm == null ? 'posts.moments.editor' : 'realms.posts.moments.editor',
pathParameters: widget.realm == null ? {} : {'realm': widget.realm!},
);
if (did == true) _pagingController.refresh();
},
);
@@ -81,29 +119,52 @@ class _ExploreScreenState extends State<ExploreScreen> {
}
},
),
appBarActions: const [NotificationButton()],
title: AppLocalizations.of(context)!.explore,
child: RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => Future.sync(
() => _pagingController.refresh(),
),
child: PagedListView<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => PostItem(
item: item,
onUpdate: () => _pagingController.refresh(),
onTap: () {
router.pushNamed(
'posts.screen',
pathParameters: {
'alias': item.alias,
'dataset': '${item.modelType}s',
child: CustomScrollView(
slivers: [
widget.showRealmShortcuts
? SliverToBoxAdapter(
child: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data != true) {
return Container();
}
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: const Material(
elevation: 8,
child: SizedBox(height: 120, child: RealmShortcuts()),
).animate().fade().slideY(begin: -1, end: 0, curve: Curves.fastEaseInToSlowEaseOut),
);
},
),
)
: SliverToBoxAdapter(child: Container()),
PagedSliverList<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) => PostItem(
item: item,
onUpdate: () => _pagingController.refresh(),
onTap: () {
SolianRouter.router.pushNamed(
widget.realm == null ? 'posts.details' : 'realms.posts.details',
pathParameters: {
'alias': item.alias,
'dataset': item.dataset,
...(widget.realm == null ? {} : {'realm': widget.realm!}),
},
);
},
);
},
),
),
),
),
],
),
),
);

View File

@@ -1,9 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:solian/models/notification.dart' as model;
@@ -16,23 +19,79 @@ class NotificationScreen extends StatefulWidget {
}
class _NotificationScreenState extends State<NotificationScreen> {
bool _isSubmitting = false;
Future<void> markAllRead() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final nty = context.read<NotifyProvider>();
List<int> markList = List.empty(growable: true);
for (final element in nty.notifications) {
if (element.isRealtime) continue;
markList.add(element.id);
}
nty.clearRealtimeNotifications();
if(markList.isNotEmpty) {
var uri = getRequestUri('passport', '/api/notifications/batch/read');
await auth.client!.put(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'messages': markList}),
);
}
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.notifyMarkAllReadDone),
));
await nty.fetch(auth);
setState(() => _isSubmitting = false);
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
final nty = context.read<NotifyProvider>();
nty.allRead();
});
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final nty = context.watch<NotifyProvider>();
WidgetsBinding.instance.addPostFrameCallback((_) {
nty.allRead();
});
return IndentWrapper(
noSafeArea: true,
return IndentScaffold(
hideDrawer: true,
title: AppLocalizations.of(context)!.notification,
child: RefreshIndicator(
body: RefreshIndicator(
onRefresh: () => nty.fetch(auth),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
if (nty.notifications.isNotEmpty)
SliverToBoxAdapter(
child: ListTile(
tileColor: Theme.of(context).colorScheme.secondaryContainer,
leading: const Icon(Icons.checklist),
title: Text(AppLocalizations.of(context)!.notifyMarkAllRead),
contentPadding: const EdgeInsets.symmetric(horizontal: 28),
onTap: _isSubmitting ? null : markAllRead,
),
),
nty.notifications.isEmpty
? SliverToBoxAdapter(
child: Container(
@@ -41,8 +100,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
child: ListTile(
leading: const Icon(Icons.check),
title: Text(AppLocalizations.of(context)!.notifyDone),
subtitle: Text(
AppLocalizations.of(context)!.notifyDoneCaption),
subtitle: Text(AppLocalizations.of(context)!.notifyDoneCaption),
),
),
)
@@ -79,13 +137,12 @@ class NotificationItem extends StatelessWidget {
final model.Notification item;
final void Function()? onDismiss;
const NotificationItem(
{super.key, required this.index, required this.item, this.onDismiss});
const NotificationItem({super.key, required this.index, required this.item, this.onDismiss});
bool hasLinks() => item.links != null && item.links!.isNotEmpty;
bool get hasLinks => item.links != null && item.links!.isNotEmpty;
void showLinks(BuildContext context) {
if (!hasLinks()) return;
if (!hasLinks) return;
showModalBottomSheet<void>(
context: context,
@@ -94,10 +151,9 @@ class NotificationItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(
left: 16, right: 16, top: 34, bottom: 12),
padding: const EdgeInsets.only(left: 16, right: 16, top: 34, bottom: 12),
child: Text(
"Links",
'Links',
style: Theme.of(context).textTheme.headlineSmall,
),
),
@@ -124,8 +180,7 @@ class NotificationItem extends StatelessWidget {
);
}
Future<void> markAsRead(
model.Notification element, BuildContext context) async {
Future<void> markAsRead(model.Notification element, BuildContext context) async {
if (element.isRealtime) return;
final auth = context.read<AuthProvider>();
@@ -139,23 +194,11 @@ class NotificationItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Dismissible(
key: Key((DateTime.now().millisecondsSinceEpoch << 10).toString()),
key: Key('n${item.id}'),
onDismissed: (direction) {
markAsRead(item, context).then((value) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: RichText(
text: TextSpan(
children: [
TextSpan(
text: item.subject,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: " is marked as read")
],
),
),
),
SnackBar(content: Text('${item.subject} is marked as read')),
);
});
if (onDismiss != null) {
@@ -170,7 +213,7 @@ class NotificationItem extends StatelessWidget {
child: ListTile(
title: Text(item.subject),
subtitle: Text(item.content),
trailing: hasLinks()
trailing: hasLinks
? TextButton(
onPressed: () => showLinks(context),
style: TextButton.styleFrom(shape: const CircleBorder()),

View File

@@ -7,11 +7,11 @@ import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
class CommentPostArguments {
@@ -65,7 +65,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
? getRequestUri('interactive', '/api/p/$relatedDataset/$alias/comments')
: getRequestUri('interactive', '/api/p/comments/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _alias,
@@ -78,16 +78,16 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (router.canPop()) {
router.pop(true);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void cancelEditing() {
if (router.canPop()) {
router.pop(false);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@@ -107,9 +107,9 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
final auth = context.read<AuthProvider>();
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.postEditNotify),
actions: [
@@ -120,8 +120,9 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
],
);
return IndentWrapper(
return IndentScaffold(
hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.newComment,
appBarActions: <Widget>[
TextButton(
@@ -129,7 +130,7 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()),
),
],
child: Column(
body: Column(
children: [
_isSubmitting
? const LinearProgressIndicator().animate().scaleX()
@@ -140,12 +141,12 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo["nick"]),
title: Text(userinfo['nick']),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
leading: AccountAvatar(
source: userinfo["picture"],
source: userinfo['picture'],
direct: true,
),
);
@@ -175,9 +176,10 @@ class _CommentEditorScreenState extends State<CommentEditorScreen> {
),
widget.editing != null ? editingBanner : Container(),
Container(
decoration: const BoxDecoration(
constraints: const BoxConstraints(minHeight: 56),
decoration: BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor),
),
),
child: Row(

View File

@@ -7,17 +7,18 @@ import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
class MomentEditorScreen extends StatefulWidget {
final Post? editing;
final String? realm;
const MomentEditorScreen({super.key, this.editing});
const MomentEditorScreen({super.key, this.editing, this.realm});
@override
State<MomentEditorScreen> createState() => _MomentEditorScreenState();
@@ -55,12 +56,13 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
? getRequestUri('interactive', '/api/p/moments')
: getRequestUri('interactive', '/api/p/moments/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _alias,
'content': _textController.value.text,
'attachments': _attachments,
'realm': widget.realm,
});
var res = await Response.fromStream(await auth.client!.send(req));
@@ -68,16 +70,16 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (router.canPop()) {
router.pop(true);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void cancelEditing() {
if (router.canPop()) {
router.pop(false);
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@@ -97,9 +99,9 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
final auth = context.read<AuthProvider>();
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.postEditNotify),
actions: [
@@ -110,7 +112,8 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
],
);
return IndentWrapper(
return IndentScaffold(
showSafeArea: true,
hideDrawer: true,
title: AppLocalizations.of(context)!.newMoment,
appBarActions: <Widget>[
@@ -119,23 +122,21 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
child: Text(AppLocalizations.of(context)!.postVerb.toUpperCase()),
),
],
child: Column(
body: Column(
children: [
_isSubmitting
? const LinearProgressIndicator().animate().scaleX()
: Container(),
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo["nick"]),
title: Text(userinfo['nick']),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
leading: AccountAvatar(
source: userinfo["picture"],
source: userinfo['picture'],
direct: true,
),
);
@@ -155,19 +156,18 @@ class _MomentEditorScreenState extends State<MomentEditorScreen> {
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.postContentPlaceholder,
hintText: AppLocalizations.of(context)!.postContentPlaceholder,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
widget.editing != null ? editingBanner : Container(),
Container(
decoration: const BoxDecoration(
constraints: const BoxConstraints(minHeight: 56),
decoration: BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor),
),
),
child: Row(

View File

@@ -4,32 +4,49 @@ import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:solian/widgets/posts/comment_list.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/widgets/posts/post.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PostScreen extends StatefulWidget {
class PostScreen extends StatelessWidget {
final String dataset;
final String alias;
const PostScreen({super.key, required this.alias, required this.dataset});
@override
State<PostScreen> createState() => _PostScreenState();
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.post,
hideDrawer: true,
body: PostScreenWidget(
dataset: dataset,
alias: alias,
),
);
}
}
class _PostScreenState extends State<PostScreen> {
class PostScreenWidget extends StatefulWidget {
final String dataset;
final String alias;
const PostScreenWidget({super.key, required this.dataset, required this.alias});
@override
State<PostScreenWidget> createState() => _PostScreenWidgetState();
}
class _PostScreenWidgetState extends State<PostScreenWidget> {
final _client = http.Client();
final PagingController<int, Post> _commentPagingController =
PagingController(firstPageKey: 0);
final PagingController<int, Post> _commentPagingController = PagingController(firstPageKey: 0);
Future<Post?> fetchPost(BuildContext context) async {
final uri = getRequestUri(
'interactive', '/api/p/${widget.dataset}/${widget.alias}');
final uri = getRequestUri('interactive', '/api/p/${widget.dataset}/${widget.alias}');
final res = await _client.get(uri);
if (res.statusCode != 200) {
final err = utf8.decode(res.bodyBytes);
@@ -42,43 +59,38 @@ class _PostScreenState extends State<PostScreen> {
@override
Widget build(BuildContext context) {
return IndentWrapper(
noSafeArea: true,
hideDrawer: true,
title: AppLocalizations.of(context)!.post,
child: FutureBuilder(
future: fetchPost(context),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PostItem(
item: snapshot.data!,
brief: false,
ripple: false,
),
return FutureBuilder(
future: fetchPost(context),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: PostItem(
item: snapshot.data!,
brief: false,
ripple: false,
),
SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
paging: _commentPagingController,
),
),
CommentList(
),
SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
),
),
CommentList(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
);
} else {
return const Center(
child: CircularProgressIndicator(),
);
}
},
);
}

View File

@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/chat/chat_list.dart';
import 'package:solian/screens/explore.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/scaffold.dart';
class RealmScreen extends StatelessWidget {
final String alias;
const RealmScreen({super.key, required this.alias});
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final realm = context.watch<RealmProvider>();
return IndentScaffold(
title: realm.focusRealm?.name ?? 'Loading...',
hideDrawer: true,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
appBarActions: realm.focusRealm != null
? [
RealmManageAction(
realm: realm.focusRealm!,
onUpdate: () => realm.fetchSingle(auth, alias),
),
]
: [],
appBarLeading: SolianTheme.isLargeScreen(context)
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
realm.clearFocus();
},
)
: null,
body: RealmWidget(
alias: alias,
),
);
}
}
class RealmWidget extends StatefulWidget {
final String alias;
const RealmWidget({super.key, required this.alias});
@override
State<RealmWidget> createState() => _RealmWidgetState();
}
class _RealmWidgetState extends State<RealmWidget> {
bool _isReady = false;
late RealmProvider _realm;
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
final auth = context.read<AuthProvider>();
if (_realm.focusRealm?.alias != widget.alias) {
_realm.fetchSingle(auth, widget.alias);
}
});
}
@override
Widget build(BuildContext context) {
if (!_isReady) {
_realm = context.watch<RealmProvider>();
_isReady = true;
}
return DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
isScrollable: !SolianTheme.isLargeScreen(context),
tabs: const [
Tab(icon: Icon(Icons.newspaper)),
Tab(icon: Icon(Icons.message)),
],
),
Expanded(
child: TabBarView(
children: [
ExplorePostWidget(realm: widget.alias),
ChatListWidget(realm: widget.alias),
],
),
)
],
),
);
}
}
class RealmManageAction extends StatelessWidget {
final Realm realm;
final Function onUpdate;
const RealmManageAction({
super.key,
required this.realm,
required this.onUpdate,
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () async {
final did = await SolianRouter.router.pushNamed(
'realms.manage',
extra: realm,
pathParameters: {'realm': realm.alias},
);
if (did == true) onUpdate();
},
icon: const Icon(Icons.settings),
);
}
}

View File

@@ -0,0 +1,211 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:uuid/uuid.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RealmEditorScreen extends StatefulWidget {
final Realm? editing;
final String? realm;
const RealmEditorScreen({super.key, this.editing, this.realm});
@override
State<RealmEditorScreen> createState() => _RealmEditorScreenState();
}
class _RealmEditorScreenState extends State<RealmEditorScreen> {
final _aliasController = TextEditingController();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
bool _isPublic = false;
bool _isCommunity = false;
bool _isSubmitting = false;
Future<void> applyChannel(BuildContext context) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
final uri = widget.editing == null
? getRequestUri('passport', '/api/realms')
: getRequestUri('passport', '/api/realms/${widget.editing!.id}');
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'alias': _aliasController.value.text.toLowerCase(),
'name': _nameController.value.text,
'description': _descriptionController.value.text,
'is_public': _isPublic,
'is_community': _isCommunity,
'realm': widget.realm,
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
void randomizeAlias() {
_aliasController.text = const Uuid().v4().replaceAll('-', '').substring(0, 16);
}
void cancelEditing() {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop(false);
}
}
@override
void initState() {
if (widget.editing != null) {
_aliasController.text = widget.editing!.alias;
_nameController.text = widget.editing!.name;
_descriptionController.text = widget.editing!.description;
_isPublic = widget.editing!.isPublic;
_isCommunity = widget.editing!.isCommunity;
}
super.initState();
}
@override
Widget build(BuildContext context) {
final editingBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.realmEditNotify),
actions: [
TextButton(
child: Text(AppLocalizations.of(context)!.cancel),
onPressed: () => cancelEditing(),
),
],
);
return IndentScaffold(
hideDrawer: true,
showSafeArea: true,
title: AppLocalizations.of(context)!.realmEstablish,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => applyChannel(context) : null,
child: Text(AppLocalizations.of(context)!.apply.toUpperCase()),
),
],
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: Column(
children: [
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
widget.editing != null ? editingBanner : Container(),
ListTile(
title: Text(AppLocalizations.of(context)!.realmUsage),
subtitle: Text(AppLocalizations.of(context)!.realmUsageCaption),
leading: const CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.supervised_user_circle, color: Colors.white),
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: Row(
children: [
Expanded(
child: TextField(
autofocus: true,
controller: _aliasController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.realmAliasLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
),
onPressed: () => randomizeAlias(),
child: const Icon(Icons.refresh),
)
],
),
),
const Divider(thickness: 0.3),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
autocorrect: true,
controller: _nameController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.realmNameLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: TextField(
minLines: 5,
maxLines: null,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: AppLocalizations.of(context)!.realmDescriptionLabel,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
),
const Divider(thickness: 0.3),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.realmPublicLabel),
value: _isPublic,
onChanged: (newValue) {
setState(() => _isPublic = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
),
CheckboxListTile(
title: Text(AppLocalizations.of(context)!.realmCommunityLabel),
value: _isCommunity,
onChanged: (newValue) {
setState(() => _isCommunity = newValue ?? false);
},
controlAffinity: ListTileControlAffinity.leading,
)
],
),
);
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/realms/realm.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/notification_notifier.dart';
import 'package:solian/widgets/realms/realm_new.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/signin_required.dart';
class RealmListScreen extends StatelessWidget {
const RealmListScreen({super.key});
@override
Widget build(BuildContext context) {
final realm = context.watch<RealmProvider>();
return realm.focusRealm == null || !SolianTheme.isLargeScreen(context)
? IndentScaffold(
title: AppLocalizations.of(context)!.realm,
appBarActions: const [NotificationButton()],
fixedAppBarColor: SolianTheme.isLargeScreen(context),
body: const RealmListWidget(),
)
: RealmScreen(alias: realm.focusRealm!.alias);
}
}
class RealmListWidget extends StatefulWidget {
const RealmListWidget({super.key});
@override
State<RealmListWidget> createState() => _RealmListWidgetState();
}
class _RealmListWidgetState extends State<RealmListWidget> {
void viewNewRealmAction() {
final auth = context.read<AuthProvider>();
final realms = context.read<RealmProvider>();
showModalBottomSheet(
context: context,
builder: (context) => RealmNewAction(onUpdate: () => realms.fetch(auth)),
);
}
@override
void initState() {
Future.delayed(Duration.zero, () {
final auth = context.read<AuthProvider>();
final realms = context.read<RealmProvider>();
realms.fetch(auth);
});
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final realms = context.watch<RealmProvider>();
return Scaffold(
floatingActionButton: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data!) {
return FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.realmNew),
onPressed: () => viewNewRealmAction(),
);
} else {
return Container();
}
},
),
body: FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) {
return const SignInRequiredScreen();
}
return RefreshIndicator(
onRefresh: () => realms.fetch(auth),
child: ListView.builder(
itemCount: realms.realms.length,
itemBuilder: (context, index) {
final element = realms.realms[index];
return ListTile(
leading: const CircleAvatar(
backgroundColor: Colors.indigo,
child: Icon(Icons.supervisor_account, color: Colors.white),
),
title: Text(element.name),
subtitle: Text(element.description),
onTap: () async {
realms.fetchSingle(auth, element.alias);
String? result;
if (SolianRouter.currentRoute.name == 'chat.channel') {
result = await SolianRouter.router.pushReplacementNamed(
'realms.details',
pathParameters: {
'realm': element.alias,
},
);
} else {
result = await SolianRouter.router.pushNamed(
'realms.details',
pathParameters: {
'realm': element.alias,
},
);
}
switch (result) {
case 'refresh':
realms.fetch(auth);
}
},
);
},
),
);
},
),
);
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/realms/realm_deletion.dart';
import 'package:solian/widgets/scaffold.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RealmManageScreen extends StatefulWidget {
final Realm realm;
const RealmManageScreen({super.key, required this.realm});
@override
State<RealmManageScreen> createState() => _RealmManageScreenState();
}
class _RealmManageScreenState extends State<RealmManageScreen> {
bool _isOwned = false;
void promptLeaveChannel() async {
final did = await showDialog(
context: context,
builder: (context) => RealmDeletion(
realm: widget.realm,
isOwned: _isOwned,
),
);
if (did == true && SolianRouter.router.canPop()) {
SolianRouter.router.pop('disposed');
}
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
setState(() {
_isOwned = prof['id'] == widget.realm.accountId;
});
});
}
@override
Widget build(BuildContext context) {
final authorizedItems = [
ListTile(
leading: const Icon(Icons.settings),
title: Text(AppLocalizations.of(context)!.settings),
onTap: () async {
SolianRouter.router.pushNamed('realms.editor', extra: widget.realm).then((did) {
if (did == true) {
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
}
});
},
),
];
return IndentScaffold(
title: AppLocalizations.of(context)!.realmManage,
hideDrawer: true,
body: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
const CircleAvatar(
radius: 24,
backgroundColor: Colors.teal,
child: Icon(Icons.tag, color: Colors.white),
),
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(widget.realm.name, style: Theme.of(context).textTheme.bodyLarge),
Text(widget.realm.description, style: Theme.of(context).textTheme.bodySmall),
]),
)
],
),
),
const Divider(thickness: 0.3),
Expanded(
child: ListView(
children: [
ListTile(
leading: const Icon(Icons.supervisor_account),
title: Text(AppLocalizations.of(context)!.chatMember),
onTap: () {
SolianRouter.router.pushNamed(
'realms.member',
extra: widget.realm,
pathParameters: {'realm': widget.realm.alias},
);
},
),
...(_isOwned ? authorizedItems : List.empty()),
const Divider(thickness: 0.3),
ListTile(
leading: _isOwned ? const Icon(Icons.delete) : const Icon(Icons.exit_to_app),
title: Text(_isOwned ? AppLocalizations.of(context)!.delete : AppLocalizations.of(context)!.exit),
onTap: () => promptLeaveChannel(),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,191 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/friend_picker.dart';
import 'package:solian/widgets/exts.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/scaffold.dart';
class RealmMemberScreen extends StatefulWidget {
final Realm realm;
const RealmMemberScreen({super.key, required this.realm});
@override
State<RealmMemberScreen> createState() => _RealmMemberScreenState();
}
class _RealmMemberScreenState extends State<RealmMemberScreen> {
bool _isSubmitting = false;
List<RealmMember> _members = List.empty();
int _selfId = 0;
Future<void> fetchMemberships() async {
final auth = context.read<AuthProvider>();
final prof = await auth.getProfiles();
if (!await auth.isAuthorized()) return;
_selfId = prof['id'];
var uri = getRequestUri('passport', '/api/realms/${widget.realm.alias}/members');
var res = await auth.client!.get(uri);
if (res.statusCode == 200) {
final result = jsonDecode(utf8.decode(res.bodyBytes)) as List<dynamic>;
setState(() {
_members = result.map((x) => RealmMember.fromJson(x)).toList();
});
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
}
Future<void> removeMember(RealmMember item) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var uri = getRequestUri('passport', '/api/realms/${widget.realm.alias}/members');
var res = await auth.client!.delete(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'target': item.account.name,
}),
);
if (res.statusCode == 200) {
await fetchMemberships();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
Future<void> addMember(String username) async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var uri = getRequestUri('passport', '/api/realms/${widget.realm.alias}/members');
var res = await auth.client!.post(
uri,
headers: {
'Content-Type': 'application/json',
},
body: jsonEncode({
'target': username,
}),
);
if (res.statusCode == 200) {
await fetchMemberships();
} else {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
}
setState(() => _isSubmitting = false);
}
void promptAddMember() async {
final input = await showModalBottomSheet(
context: context,
builder: (context) {
return const FriendPicker();
},
);
if (input == null) return;
await addMember((input as Account).name);
}
bool getRemovable(RealmMember item) {
if (_selfId != widget.realm.accountId) return false;
if (item.accountId == widget.realm.accountId) return false;
if (item.account.id == _selfId) return false;
return true;
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () => fetchMemberships());
}
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: AppLocalizations.of(context)!.realmMember,
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
appBarActions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () => promptAddMember(),
),
],
body: RefreshIndicator(
onRefresh: () => fetchMemberships(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
),
SliverList.builder(
itemCount: _members.length,
itemBuilder: (context, index) {
final element = _members[index];
final randomId = DateTime.now().microsecondsSinceEpoch >> 10;
return Dismissible(
key: Key(randomId.toString()),
direction: getRemovable(element) ? DismissDirection.startToEnd : DismissDirection.none,
background: Container(
color: Colors.red,
padding: const EdgeInsets.symmetric(horizontal: 20),
alignment: Alignment.centerLeft,
child: const Icon(Icons.remove, color: Colors.white),
),
child: ListTile(
leading: AccountAvatar(source: element.account.avatar),
title: Text(element.account.nick),
subtitle: Text(element.account.name),
),
onDismissed: (_) {
removeMember(element);
},
);
},
)
],
),
),
);
}
}

View File

@@ -0,0 +1,170 @@
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/personal_page.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/personal_page_content.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/scaffold.dart';
class UserInfoScreen extends StatefulWidget {
final String name;
const UserInfoScreen({super.key, required this.name});
@override
State<UserInfoScreen> createState() => _UserInfoScreenState();
}
class _UserInfoScreenState extends State<UserInfoScreen> {
final _client = Client();
Account? _userinfo;
PersonalPage? _page;
Future<Account> fetchUserinfo() async {
final res = await Future.wait([
_client.get(getRequestUri('passport', '/api/users/${widget.name}')),
_client.get(getRequestUri('passport', '/api/users/${widget.name}/page'))
], eagerError: true);
final mistakeRes = res.indexWhere((x) => x.statusCode != 200 && x.statusCode != 400);
if (mistakeRes > -1) {
var message = utf8.decode(res[mistakeRes].bodyBytes);
context.showErrorDialog(message);
throw Exception(message);
} else {
final info = Account.fromJson(jsonDecode(utf8.decode(res[0].bodyBytes)));
final page = res[1].statusCode == 200 ? PersonalPage.fromJson(jsonDecode(utf8.decode(res[1].bodyBytes))) : null;
setState(() {
_userinfo = info;
_page = page ??
PersonalPage(
id: 0,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
content: '',
script: '',
style: '',
accountId: info.id,
);
});
return info;
}
}
String getAuthorDescribe() => _userinfo!.description.isNotEmpty ? _userinfo!.description : 'No description yet.';
@override
Widget build(BuildContext context) {
return IndentScaffold(
title: _userinfo?.nick ?? 'Loading...',
fixedAppBarColor: SolianTheme.isLargeScreen(context),
hideDrawer: true,
body: FutureBuilder(
future: fetchUserinfo(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(child: CircularProgressIndicator());
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 24),
children: [
const SizedBox(height: 8),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: AspectRatio(
aspectRatio: 16 / 5,
child: Container(
color: Theme.of(context).colorScheme.surfaceVariant,
child: _userinfo?.banner != null
? CachedNetworkImage(
imageUrl: getRequestUri('passport', '/api/avatar/${_userinfo!.banner}').toString(),
fit: BoxFit.cover,
progressIndicatorBuilder: (_, __, DownloadProgress loadingProgress) {
return Center(
child: CircularProgressIndicator(
value: loadingProgress.totalSize != null
? loadingProgress.downloaded / loadingProgress.totalSize!
: null,
),
);
},
)
: Container(),
),
),
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16, top: 20),
child: Row(
children: [
AccountAvatar(source: _userinfo!.avatar, radius: 32),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
textBaseline: TextBaseline.alphabetic,
crossAxisAlignment: CrossAxisAlignment.baseline,
children: [
Text(
_userinfo!.nick,
maxLines: 1,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Text(
'@${_userinfo!.name}',
maxLines: 1,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w400,
overflow: TextOverflow.ellipsis,
),
),
],
),
Text(
_userinfo!.description,
maxLines: 3,
style: const TextStyle(
fontSize: 14,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(thickness: 0.3, indent: 4, endIndent: 4),
),
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: PersonalPageContent(item: _page!),
),
),
],
);
},
),
);
}
}

9
lib/utils/file.dart Normal file
View File

@@ -0,0 +1,9 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
Future<String> calculateFileSha256(File file) async {
final bytes = await file.readAsBytes();
final digest = sha256.convert(bytes);
return digest.toString();
}

86
lib/utils/http.dart Normal file
View File

@@ -0,0 +1,86 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:solian/utils/services_url.dart';
class HttpClient extends http.BaseClient {
final bool isUnauthorizedRetry;
final Future<String> Function()? onUnauthorizedRetry;
final Function(String atk, String rtk)? onTokenRefreshed;
final _client = http.Client();
HttpClient({
this.isUnauthorizedRetry = true,
this.onUnauthorizedRetry,
this.onTokenRefreshed,
String? defaultToken,
String? defaultRefreshToken,
}) {
currentToken = defaultToken;
currentRefreshToken = defaultRefreshToken;
}
String? currentToken;
String? currentRefreshToken;
Future<String> refreshToken(String token) async {
final res = await _client.post(
getRequestUri('passport', '/api/auth/token'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'refresh_token': token, 'grant_type': 'refresh_token'}),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
throw Exception('An error occurred when trying refresh token: $message');
}
final result = jsonDecode(utf8.decode(res.bodyBytes));
currentToken = result['access_token'];
currentRefreshToken = result['refresh_token'];
if (onTokenRefreshed != null) {
onTokenRefreshed!(currentToken!, currentRefreshToken!);
}
return currentToken!;
}
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
request.headers['Authorization'] = 'Bearer $currentToken';
final res = await _client.send(request);
if (res.statusCode == 401 && currentToken != null && isUnauthorizedRetry) {
if (onUnauthorizedRetry != null) {
currentToken = await onUnauthorizedRetry!();
} else if (currentRefreshToken != null) {
currentToken = await refreshToken(currentRefreshToken!);
} else {
final result = await http.Response.fromStream(res);
throw Exception(utf8.decode(result.bodyBytes));
}
http.BaseRequest newRequest;
if (request is http.Request) {
newRequest = http.Request(request.method, request.url)
..encoding = request.encoding
..bodyBytes = request.bodyBytes;
} else if (request is http.MultipartRequest) {
newRequest = http.MultipartRequest(request.method, request.url)
..fields.addAll(request.fields)
..files.addAll(request.files);
} else {
throw Exception('unsupported request type to auto retry');
}
newRequest
..persistentConnection = request.persistentConnection
..followRedirects = request.followRedirects
..maxRedirects = request.maxRedirects
..headers.addAll(request.headers)
..headers['Authorization'] = 'Bearer $currentToken';
return await _client.send(newRequest);
}
return res;
}
}

39
lib/utils/platform.dart Normal file
View File

@@ -0,0 +1,39 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
abstract class PlatformInfo {
static bool get isWeb => kIsWeb;
static bool get isLinux => !kIsWeb && Platform.isLinux;
static bool get isWindows => !kIsWeb && Platform.isWindows;
static bool get isMacOS => !kIsWeb && Platform.isMacOS;
static bool get isIOS => !kIsWeb && Platform.isIOS;
static bool get isAndroid => !kIsWeb && Platform.isAndroid;
static bool get isMobile => isAndroid || isIOS;
// Not first tier supported platform
static bool get isBetaDesktop => isWindows || isLinux;
static bool get isDesktop => isLinux || isWindows || isMacOS;
static bool get useTouchscreen => !isMobile;
static bool get canCacheImage => isAndroid || isIOS || isMacOS;
static bool get canRecord => (isMobile || isMacOS);
static Future<String> getVersion() async {
var version = kIsWeb ? 'Web' : 'Unknown';
try {
version = (await PackageInfo.fromPlatform()).version;
} catch (_) {}
return version;
}
}

14
lib/utils/theme.dart Normal file
View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
abstract class SolianTheme {
static bool isLargeScreen(BuildContext context) =>
MediaQuery.of(context).size.width > 640;
static ThemeData build(Brightness brightness) {
return ThemeData(
brightness: brightness,
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(brightness: brightness, seedColor: Colors.indigo),
);
}
}

View File

@@ -1,5 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
class AccountAvatar extends StatelessWidget {
final String source;
@@ -27,17 +29,19 @@ class AccountAvatar extends StatelessWidget {
);
}
if (direct == true) {
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(source) : NetworkImage(source);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: NetworkImage(source),
backgroundImage: image as ImageProvider,
);
} else {
final url = getRequestUri('passport', '/api/avatar/$source').toString();
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(url) : NetworkImage(url);
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
backgroundImage: NetworkImage(url),
backgroundImage: image as ImageProvider,
);
}
}

View File

@@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/friend.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/account/account_avatar.dart';
class FriendPicker extends StatefulWidget {
const FriendPicker({super.key});

View File

@@ -1,16 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/personal_page.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ChatMessageContent extends StatelessWidget {
final Message item;
class PersonalPageContent extends StatelessWidget {
final PersonalPage item;
const ChatMessageContent({super.key, required this.item});
const PersonalPageContent({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Markdown(
selectable: true,
data: item.content,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),

View File

@@ -73,10 +73,10 @@ class _ControlsWidgetState extends State<ControlsWidget> {
if (await context.showDisconnectDialog() != true) return;
final chat = context.read<ChatProvider>();
if (chat.call != null) {
chat.call!.deactivate();
chat.call!.dispose();
router.pop();
if (chat.currentCall != null) {
chat.currentCall!.deactivate();
chat.currentCall!.dispose();
SolianRouter.router.pop();
}
}

View File

@@ -14,7 +14,7 @@ class CallOverlay extends StatelessWidget {
final chat = context.watch<ChatProvider>();
if (chat.isShown || chat.call == null) {
if (chat.isCallShown || chat.currentCall == null) {
return Container();
}
@@ -52,10 +52,10 @@ class CallOverlay extends StatelessWidget {
),
),
onTap: () {
router.pushNamed(
SolianRouter.router.pushNamed(
'chat.channel.call',
extra: chat.call!.info,
pathParameters: {'channel': chat.call!.channel.alias},
extra: chat.currentCall!.info,
pathParameters: {'channel': chat.currentCall!.channel.alias},
);
},
);

View File

@@ -5,7 +5,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:livekit_client/livekit_client.dart';
import 'package:solian/models/account.dart';
import 'package:solian/models/call.dart';
import 'package:solian/widgets/chat/call/no_content.dart';
import 'package:solian/widgets/chat/call/participant_no_content.dart';
import 'package:solian/widgets/chat/call/participant_info.dart';
import 'package:solian/widgets/chat/call/participant_stats.dart';

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:solian/models/account.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'dart:math' as math;
class NoContentWidget extends StatefulWidget {

View File

@@ -5,17 +5,24 @@ import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ChannelCallAction extends StatefulWidget {
final Call? call;
final Channel channel;
final String realm;
final Function onUpdate;
const ChannelCallAction(
{super.key, this.call, required this.channel, required this.onUpdate});
const ChannelCallAction({
super.key,
this.call,
required this.channel,
required this.onUpdate,
this.realm = 'global',
});
@override
State<ChannelCallAction> createState() => _ChannelCallActionState();
@@ -33,8 +40,7 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
return;
}
var uri = getRequestUri(
'messaging', '/api/channels/${widget.channel.alias}/calls');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/calls');
var res = await auth.client!.post(uri);
if (res.statusCode != 200) {
@@ -48,19 +54,24 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
Future<void> endsCall() async {
setState(() => _isSubmitting = true);
final chat = context.read<ChatProvider>();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var uri = getRequestUri(
'messaging', '/api/channels/${widget.channel.alias}/calls/ongoing');
var uri = getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/calls/ongoing');
var res = await auth.client!.delete(uri);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
if (chat.currentCall != null && chat.currentCall?.info.channelId == widget.channel.id) {
chat.currentCall!.deactivate();
chat.currentCall!.dispose();
}
}
setState(() => _isSubmitting = false);
@@ -78,9 +89,7 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
endsCall();
}
},
icon: widget.call == null
? const Icon(Icons.call)
: const Icon(Icons.call_end),
icon: widget.call == null ? const Icon(Icons.call) : const Icon(Icons.call_end),
);
}
}
@@ -88,22 +97,30 @@ class _ChannelCallActionState extends State<ChannelCallAction> {
class ChannelManageAction extends StatelessWidget {
final Channel channel;
final Function onUpdate;
final String realm;
const ChannelManageAction(
{super.key, required this.channel, required this.onUpdate});
const ChannelManageAction({
super.key,
required this.channel,
required this.onUpdate,
this.realm = 'global',
});
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () async {
final result = await router.pushNamed(
'chat.channel.manage',
final result = await SolianRouter.router.pushNamed(
realm == 'global' ? 'chat.channel.manage' : 'realms.chat.channel.manage',
extra: channel,
pathParameters: {'channel': channel.alias},
pathParameters: {
'channel': channel.alias,
...(realm == 'global' ? {} : {'realm': realm}),
},
);
switch (result) {
case 'disposed':
if (router.canPop()) router.pop('refresh');
if (SolianRouter.router.canPop()) SolianRouter.router.pop('refresh');
case 'refresh':
onUpdate();
}

View File

@@ -5,15 +5,20 @@ import 'package:provider/provider.dart';
import 'package:solian/models/channel.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ChannelDeletion extends StatefulWidget {
final Channel channel;
final String realm;
final bool isOwned;
const ChannelDeletion(
{super.key, required this.channel, required this.isOwned});
const ChannelDeletion({
super.key,
required this.channel,
required this.realm,
required this.isOwned,
});
@override
State<ChannelDeletion> createState() => _ChannelDeletionState();
@@ -32,7 +37,7 @@ class _ChannelDeletionState extends State<ChannelDeletion> {
}
var res = await auth.client!.delete(
getRequestUri('messaging', '/api/channels/${widget.channel.id}'),
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.id}'),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
@@ -53,8 +58,8 @@ class _ChannelDeletionState extends State<ChannelDeletion> {
return;
}
var res = await auth.client!.post(
getRequestUri('messaging', '/api/channels/${widget.channel.alias}/leave'),
var res = await auth.client!.delete(
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel.alias}/members/me'),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);

View File

@@ -4,8 +4,9 @@ import 'package:solian/router.dart';
class ChatNewAction extends StatelessWidget {
final Function onUpdate;
final String? realm;
const ChatNewAction({super.key, required this.onUpdate});
const ChatNewAction({super.key, required this.onUpdate, this.realm});
@override
Widget build(BuildContext context) {
@@ -29,7 +30,10 @@ class ChatNewAction extends StatelessWidget {
leading: const Icon(Icons.add),
title: Text(AppLocalizations.of(context)!.chatNewCreate),
onTap: () {
router.pushNamed('chat.channel.editor').then((did) {
SolianRouter.router.pushNamed(
'chat.channel.editor',
queryParameters: {'realm': realm},
).then((did) {
if (did == true) {
onUpdate();
if (Navigator.canPop(context)) {

View File

@@ -1,103 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/call.dart';
import 'package:solian/models/channel.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/packet.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMaintainer extends StatefulWidget {
final Widget child;
final Channel channel;
final Function(Message val) onInsertMessage;
final Function(Message val) onUpdateMessage;
final Function(Message val) onDeleteMessage;
final Function(Call val) onCallStarted;
final Function() onCallEnded;
const ChatMaintainer({
super.key,
required this.child,
required this.channel,
required this.onInsertMessage,
required this.onUpdateMessage,
required this.onDeleteMessage,
required this.onCallStarted,
required this.onCallEnded,
});
@override
State<ChatMaintainer> createState() => _ChatMaintainerState();
}
class _ChatMaintainerState extends State<ChatMaintainer> {
void connect() {
ScaffoldMessenger.of(context).clearSnackBars();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
final auth = context.read<AuthProvider>();
final chat = context.read<ChatProvider>();
chat.connect(auth).then((snapshot) {
snapshot!.stream.listen(
(event) {
final result = NetworkPackage.fromJson(jsonDecode(event));
switch (result.method) {
case 'messages.new':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id)
widget.onInsertMessage(payload);
break;
case 'messages.update':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id)
widget.onUpdateMessage(payload);
break;
case 'messages.burnt':
final payload = Message.fromJson(result.payload!);
if (payload.channelId == widget.channel.id)
widget.onDeleteMessage(payload);
break;
case 'calls.new':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == widget.channel.id)
widget.onCallStarted(payload);
break;
case 'calls.end':
final payload = Call.fromJson(result.payload!);
if (payload.channelId == widget.channel.id) widget.onCallEnded();
break;
}
},
onError: (_, __) => connect(),
onDone: () => connect(),
);
notify.close();
});
}
@override
void initState() {
Future.delayed(Duration.zero, () {
connect();
});
super.initState();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:solian/models/message.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/widgets/chat/content.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/chat/message_content.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'dart:math' as math;

View File

@@ -1,4 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/providers/auth.dart';
@@ -7,6 +11,7 @@ import 'package:solian/widgets/chat/message_deletion.dart';
class ChatMessageAction extends StatelessWidget {
final String channel;
final String realm;
final Message item;
final Function? onEdit;
final Function? onReply;
@@ -15,6 +20,7 @@ class ChatMessageAction extends StatelessWidget {
super.key,
required this.channel,
required this.item,
this.realm = 'global',
this.onEdit,
this.onReply,
});
@@ -24,7 +30,7 @@ class ChatMessageAction extends StatelessWidget {
final auth = context.read<AuthProvider>();
return SizedBox(
height: 320,
height: 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -38,7 +44,7 @@ class ChatMessageAction extends StatelessWidget {
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'Message ID #${item.id.toString().padLeft(8, '0')}',
'#${item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
],
@@ -67,6 +73,7 @@ class ChatMessageAction extends StatelessWidget {
builder: (context) => ChatMessageDeletionDialog(
item: item,
channel: channel,
realm: realm,
),
).then((did) {
if (did == true && Navigator.canPop(context)) {
@@ -79,9 +86,7 @@ class ChatMessageAction extends StatelessWidget {
return ListView(
children: [
...(snapshot.data['id'] == item.sender.account.externalId
? authorizedItems
: List.empty()),
...(snapshot.data['id'] == item.sender.account.externalId ? authorizedItems : List.empty()),
ListTile(
leading: const Icon(Icons.reply),
title: Text(AppLocalizations.of(context)!.reply),
@@ -89,6 +94,17 @@ class ChatMessageAction extends StatelessWidget {
if (onReply != null) onReply!();
if (Navigator.canPop(context)) Navigator.pop(context);
},
),
ListTile(
leading: const Icon(Icons.code),
title: Text(AppLocalizations.of(context)!.chatMessageViewSource),
onTap: () {
if (Navigator.canPop(context)) Navigator.pop(context);
showModalBottomSheet(
context: context,
builder: (context) => ChatMessageSourceWidget(item: item),
);
},
)
],
);
@@ -105,3 +121,90 @@ class ChatMessageAction extends StatelessWidget {
);
}
}
class ChatMessageSourceWidget extends StatelessWidget {
final Message item;
const ChatMessageSourceWidget({super.key, required this.item});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(32)),
child: SizedBox(
width: double.infinity,
height: 640,
child: ListView(
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.chatMessageViewSource,
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
'Raw content',
style: Theme.of(context).textTheme.titleMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${item.rawContent}\n```',
padding: const EdgeInsets.all(0),
),
Padding(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
'Decoded content',
style: Theme.of(context).textTheme.titleMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${const JsonEncoder.withIndent(' ').convert(item.decodedContent)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
code: GoogleFonts.robotoMono(
backgroundColor: Theme.of(context).cardTheme.color ?? Theme.of(context).cardColor,
fontSize: Theme.of(context).textTheme.bodyMedium!.fontSize! * 0.85,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
'Entire message',
style: Theme.of(context).textTheme.titleMedium,
),
),
Markdown(
selectable: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
data: '```\n${const JsonEncoder.withIndent(' ').convert(item)}\n```',
padding: const EdgeInsets.all(0),
styleSheet: MarkdownStyleSheet(
code: GoogleFonts.robotoMono(
backgroundColor: Theme.of(context).cardTheme.color ?? Theme.of(context).cardColor,
fontSize: Theme.of(context).textTheme.bodyMedium!.fontSize! * 0.85,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/providers/keypair.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ChatMessageContent extends StatefulWidget {
final Message item;
const ChatMessageContent({super.key, required this.item});
@override
State<ChatMessageContent> createState() => _ChatMessageContentState();
}
class _ChatMessageContentState extends State<ChatMessageContent> {
@override
Widget build(BuildContext context) {
final feColor = Theme.of(context).colorScheme.onSurface.withOpacity(0.65);
final waitingKeyHint = Row(
children: [
Icon(Icons.key, color: feColor, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptWaiting,
style: TextStyle(color: feColor),
),
),
],
);
final missingKeyHint = Row(
children: [
Icon(Icons.key_off_outlined, color: feColor, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptMissing,
style: TextStyle(color: feColor),
),
),
],
);
if (widget.item.type == 'm.text') {
String? content;
switch (widget.item.decodedContent['algorithm']) {
case 'plain':
content = widget.item.decodedContent['value'];
case 'aes':
final keypair = context.watch<KeypairProvider>();
if (keypair.keys[widget.item.decodedContent['keypair_id']] == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
keypair.requestKey(
widget.item.decodedContent['keypair_id'],
widget.item.decodedContent['algorithm'],
widget.item.sender.account.externalId!,
);
});
} else {
content = keypair.decodeViaAESKey(
widget.item.decodedContent['keypair_id'],
widget.item.decodedContent['value'],
)!;
break;
}
if (keypair.requestingKeys.contains(widget.item.decodedContent['keypair_id'])) {
return waitingKeyHint.animate().swap(builder: (context, _) {
return missingKeyHint;
}, delay: 3000.ms);
}
}
if (content == null) {
return Row(
children: [
Icon(Icons.key_off, color: feColor, size: 16),
const SizedBox(width: 4),
Text(
AppLocalizations.of(context)!.chatMessageUnableDecryptUnsupported,
style: TextStyle(color: feColor),
),
],
);
}
return Markdown(
data: content,
shrinkWrap: true,
selectable: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(0),
onTapLink: (text, href, title) async {
if (href == null) return;
await launchUrlString(
href,
mode: LaunchMode.externalApplication,
);
},
);
}
return Container();
}
}

View File

@@ -5,22 +5,23 @@ import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ChatMessageDeletionDialog extends StatefulWidget {
final String channel;
final String realm;
final Message item;
const ChatMessageDeletionDialog({
super.key,
required this.item,
required this.channel,
this.realm = 'global'
});
@override
State<ChatMessageDeletionDialog> createState() =>
_ChatMessageDeletionDialogState();
State<ChatMessageDeletionDialog> createState() => _ChatMessageDeletionDialogState();
}
class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
@@ -30,8 +31,8 @@ class _ChatMessageDeletionDialogState extends State<ChatMessageDeletionDialog> {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final uri = getRequestUri('messaging',
'/api/channels/${widget.channel}/messages/${widget.item.id}');
final uri =
getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages/${widget.item.id}');
setState(() => _isSubmitting = true);
final res = await auth.client!.delete(uri);

View File

@@ -1,29 +1,37 @@
import 'dart:convert';
import 'package:easy_debounce/easy_debounce.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/message.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
import 'package:badges/badges.dart' as badge;
class ChatMessageEditor extends StatefulWidget {
final String channel;
final String realm;
final Message? editing;
final Message? replying;
final bool isEncrypted;
final Function? onReset;
const ChatMessageEditor(
{super.key,
required this.channel,
this.editing,
this.replying,
this.onReset});
const ChatMessageEditor({
super.key,
required this.channel,
required this.isEncrypted,
this.realm = 'global',
this.editing,
this.replying,
this.onReset,
});
@override
State<ChatMessageEditor> createState() => _ChatMessageEditorState();
@@ -31,8 +39,10 @@ class ChatMessageEditor extends StatefulWidget {
class _ChatMessageEditorState extends State<ChatMessageEditor> {
final _textController = TextEditingController();
final _focusNode = FocusNode();
final List<int> _pendingMessages = List.empty(growable: true);
bool _isSubmitting = false;
int? _prevEditingId;
List<Attachment> _attachments = List.empty(growable: true);
@@ -48,34 +58,59 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
);
}
Map<String, dynamic> buildContentBody(String content) {
final keypair = context.read<KeypairProvider>();
if (keypair.activeKeyId == null || keypair.keys[keypair.activeKeyId] == null) {
final kp = keypair.generateAESKey();
keypair.setActiveKey(kp.id);
return buildContentBody(content);
}
return {
'value': widget.isEncrypted ? keypair.encodeViaAESKey(keypair.activeKeyId!, content) : content,
'keypair_id': widget.isEncrypted ? keypair.activeKeyId : null,
'algorithm': widget.isEncrypted ? 'aes' : 'plain',
};
}
Future<void> sendMessage(BuildContext context) async {
if (_isSubmitting) return;
_focusNode.requestFocus();
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final uri = widget.editing == null
? getRequestUri('messaging', '/api/channels/${widget.channel}/messages')
: getRequestUri('messaging',
'/api/channels/${widget.channel}/messages/${widget.editing!.id}');
? getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages')
: getRequestUri('messaging', '/api/channels/${widget.realm}/${widget.channel}/messages/${widget.editing!.id}');
final req = Request(widget.editing == null ? "POST" : "PUT", uri);
final req = Request(widget.editing == null ? 'POST' : 'PUT', uri);
req.headers['Content-Type'] = 'application/json';
req.body = jsonEncode(<String, dynamic>{
'content': _textController.value.text,
'type': 'm.text',
'content': buildContentBody(_textController.value.text),
'attachments': _attachments,
'reply_to': widget.replying?.id,
});
setState(() => _isSubmitting = true);
reset();
final messageMarkId = DateTime.now().microsecondsSinceEpoch >> 10;
final messageDebounceId = 'm-pending$messageMarkId';
EasyDebounce.debounce(messageDebounceId, 350.ms, () {
setState(() => _pendingMessages.add(messageMarkId));
});
var res = await Response.fromStream(await auth.client!.send(req));
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else {
reset();
}
setState(() => _isSubmitting = false);
EasyDebounce.cancel(messageDebounceId);
if (_pendingMessages.isNotEmpty) {
setState(() => _pendingMessages.remove(messageMarkId));
}
}
void reset() {
@@ -89,9 +124,11 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
if (widget.editing != null && _prevEditingId != widget.editing!.id) {
setState(() {
_prevEditingId = widget.editing!.id;
_textController.text = widget.editing!.content;
_attachments =
widget.editing!.attachments ?? List.empty(growable: true);
_attachments = widget.editing!.attachments ?? List.empty(growable: true);
if (widget.editing!.type == 'm.text') {
_textController.text = widget.editing!.decodedContent['value'];
}
});
}
}
@@ -109,10 +146,19 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
@override
Widget build(BuildContext context) {
final sendingBanner = MaterialBanner(
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.schedule_send),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text('${AppLocalizations.of(context)!.chatMessageSending} (${_pendingMessages.length})'),
actions: const [SizedBox()],
);
final editingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.edit_note),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatMessageEditNotify),
actions: [
@@ -124,9 +170,9 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
);
final replyingBanner = MaterialBanner(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 20),
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 20),
leading: const Icon(Icons.reply),
backgroundColor: const Color(0xFFE0E0E0),
backgroundColor: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.9),
dividerColor: const Color.fromARGB(1, 0, 0, 0),
content: Text(AppLocalizations.of(context)!.chatMessageReplyNotify),
actions: [
@@ -139,14 +185,26 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
return Column(
children: [
_pendingMessages.isNotEmpty
? sendingBanner
.animate()
.scaleY(
begin: 0,
curve: Curves.fastEaseInToSlowEaseOut,
)
.slideY(
begin: 1,
curve: Curves.fastEaseInToSlowEaseOut,
)
: Container(),
widget.editing != null ? editingBanner : Container(),
widget.replying != null ? replyingBanner : Container(),
Container(
height: 56,
padding: const EdgeInsets.only(top: 4, bottom: 4, right: 8),
decoration: const BoxDecoration(
decoration: BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
top: BorderSide(width: 0.3, color: Theme.of(context).dividerColor),
),
),
child: Row(
@@ -154,39 +212,33 @@ class _ChatMessageEditorState extends State<ChatMessageEditor> {
children: [
badge.Badge(
showBadge: _attachments.isNotEmpty,
badgeContent: Text(_attachments.length.toString(),
style: const TextStyle(color: Colors.white)),
badgeContent: Text(_attachments.length.toString(), style: const TextStyle(color: Colors.white)),
position: badge.BadgePosition.custom(top: -2, end: 8),
child: TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(4)),
onPressed:
!_isSubmitting ? () => viewAttachments(context) : null,
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
onPressed: () => viewAttachments(context),
child: const Icon(Icons.attach_file),
),
),
Expanded(
child: TextField(
focusNode: _focusNode,
controller: _textController,
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.text,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.chatMessagePlaceholder,
hintText: widget.isEncrypted
? AppLocalizations.of(context)!.chatMessageEncryptedPlaceholder
: AppLocalizations.of(context)!.chatMessagePlaceholder,
),
onSubmitted: (_) => sendMessage(context),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
padding: const EdgeInsets.all(4)),
onPressed: !_isSubmitting ? () => sendMessage(context) : null,
style: TextButton.styleFrom(shape: const CircleBorder(), padding: const EdgeInsets.all(4)),
onPressed: () => sendMessage(context),
child: const Icon(Icons.send),
)
],

View File

@@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:solian/widgets/navigation_drawer.dart';
class LayoutWrapper extends StatelessWidget {
final Widget? child;
final Widget? floatingActionButton;
final List<Widget>? appBarActions;
final bool noSafeArea;
final String title;
const LayoutWrapper({
super.key,
this.child,
required this.title,
this.floatingActionButton,
this.appBarActions,
this.noSafeArea = false,
});
@override
Widget build(BuildContext context) {
final content = child ?? Container();
return Scaffold(
appBar: AppBar(title: Text(title), actions: appBarActions),
floatingActionButton: floatingActionButton,
drawer: const SolianNavigationDrawer(),
body: noSafeArea ? content : SafeArea(child: content),
);
}
}

25
lib/widgets/empty.dart Normal file
View File

@@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PageEmptyWidget extends StatelessWidget {
const PageEmptyWidget({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset('assets/logo.png', width: 64, height: 64),
const SizedBox(height: 2),
Text(
AppLocalizations.of(context)!.appName,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900),
),
],
),
),
);
}
}

View File

@@ -9,8 +9,8 @@ extension SolianCommonExtensions on BuildContext {
return message
.split(' ')
.map((element) =>
"${element[0].toUpperCase()}${element.substring(1).toLowerCase()}")
.join(" ");
'${element[0].toUpperCase()}${element.substring(1).toLowerCase()}')
.join(' ');
}
return showDialog<void>(

View File

@@ -1,39 +0,0 @@
import 'package:flutter/material.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/common_wrapper.dart';
import 'package:solian/widgets/navigation_drawer.dart';
class IndentWrapper extends LayoutWrapper {
final bool hideDrawer;
const IndentWrapper({
super.key,
super.child,
required super.title,
super.floatingActionButton,
super.appBarActions,
this.hideDrawer = false,
super.noSafeArea = false,
}) : super();
@override
Widget build(BuildContext context) {
final content = child ?? Container();
return Scaffold(
appBar: AppBar(
leading: hideDrawer
? IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => router.pop(),
)
: null,
title: Text(title),
actions: appBarActions,
),
floatingActionButton: floatingActionButton,
drawer: const SolianNavigationDrawer(),
body: noSafeArea ? content : SafeArea(child: content),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:solian/widgets/empty.dart';
class TwoColumnLayout extends StatelessWidget {
final Widget sideChild;
final Widget? mainChild;
const TwoColumnLayout({
super.key,
required this.sideChild,
required this.mainChild,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 400,
child: sideChild,
),
const VerticalDivider(width: 0.3, thickness: 0.3),
Expanded(child: mainChild ?? const PageEmptyWidget()),
],
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/navigation.dart';
import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/theme.dart';
class SolianNavigationDrawer extends StatefulWidget {
const SolianNavigationDrawer({super.key});
@@ -17,7 +18,7 @@ class _SolianNavigationDrawerState extends State<SolianNavigationDrawer> {
void _onSelect(String name, int idx) {
setState(() => _selectedIndex = idx);
context.read<NavigationProvider>().selectedIndex = idx;
router.goNamed(name);
SolianRouter.router.goNamed(name);
}
@override
@@ -39,26 +40,36 @@ class _SolianNavigationDrawerState extends State<SolianNavigationDrawer> {
icon: const Icon(Icons.explore),
label: Text(AppLocalizations.of(context)!.explore),
),
"explore",
'explore',
),
(
NavigationDrawerDestination(
icon: const Icon(Icons.supervised_user_circle),
label: Text(AppLocalizations.of(context)!.realm),
),
'realms',
),
(
NavigationDrawerDestination(
icon: const Icon(Icons.send),
label: Text(AppLocalizations.of(context)!.chat),
),
"chat",
'chat',
),
(
NavigationDrawerDestination(
icon: const Icon(Icons.account_circle),
label: Text(AppLocalizations.of(context)!.account),
),
"account",
'account',
),
];
return NavigationDrawer(
selectedIndex: _selectedIndex,
elevation: SolianTheme.isLargeScreen(context) ? 20 : 0,
shadowColor: SolianTheme.isLargeScreen(context) ? Theme.of(context).shadowColor : null,
surfaceTintColor: Theme.of(context).colorScheme.background,
onDestinationSelected: (int idx) {
final element = navigationItems[idx];
_onSelect(element.$2, idx);
@@ -69,7 +80,7 @@ class _SolianNavigationDrawerState extends State<SolianNavigationDrawer> {
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Image.asset("assets/logo.png", width: 26, height: 26),
Image.asset('assets/logo.png', width: 26, height: 26),
const SizedBox(width: 10),
Text(
AppLocalizations.of(context)!.appName,

View File

@@ -1,76 +1,12 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/notify.dart';
import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/models/notification.dart' as model;
import 'package:badges/badges.dart' as badge;
class NotificationNotifier extends StatefulWidget {
final Widget child;
const NotificationNotifier({super.key, required this.child});
@override
State<NotificationNotifier> createState() => _NotificationNotifierState();
}
class _NotificationNotifierState extends State<NotificationNotifier> {
void connect() async {
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
),
);
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
if (await auth.isAuthorized()) {
nty.fetch(auth);
nty.connect(auth).then((snapshot) {
snapshot!.stream.listen(
(event) {
final result = model.Notification.fromJson(jsonDecode(event));
nty.onRemoteMessage(result);
nty.notifyMessage(result.subject, result.content);
},
onError: (_, __) => connect(),
onDone: () => connect(),
);
});
}
notify.close();
}
@override
void initState() {
Future.delayed(Duration.zero, () {
connect();
});
super.initState();
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
class NotificationButton extends StatefulWidget {
class NotificationButton extends StatelessWidget {
const NotificationButton({super.key});
@override
State<NotificationButton> createState() => _NotificationButtonState();
}
class _NotificationButtonState extends State<NotificationButton> {
@override
Widget build(BuildContext context) {
final nty = context.watch<NotifyProvider>();
@@ -85,7 +21,7 @@ class _NotificationButtonState extends State<NotificationButton> {
child: IconButton(
icon: const Icon(Icons.notifications),
onPressed: () {
router.pushNamed("notification");
SolianRouter.router.pushNamed('notification');
},
),
);

View File

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:http/http.dart';
@@ -10,7 +10,8 @@ import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/file.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/exts.dart';
@@ -41,52 +42,85 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
showModalBottomSheet(
context: context,
builder: (context) => AttachmentEditorMethodPopup(
pickImage: () => pickImageToUpload(context, ImageSource.gallery),
takeImage: () => pickImageToUpload(context, ImageSource.camera),
pickVideo: () => pickVideoToUpload(context, ImageSource.gallery),
takeVideo: () => pickVideoToUpload(context, ImageSource.camera),
pickMedia: () => pickMediaToUpload(context),
pickFile: () => pickFileToUpload(context),
takeImage: () => takeMediaToUpload(context, false),
takeVideo: () => takeMediaToUpload(context, true),
),
);
}
Future<void> pickImageToUpload(
BuildContext context, ImageSource source) async {
Future<void> pickMediaToUpload(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickImage(source: source);
if (image == null) return;
final medias = await _imagePicker.pickMultipleMedia();
if (medias.isEmpty) return;
setState(() => _isSubmitting = true);
final file = File(image.path);
final hashcode = await calculateSha256(file);
if (Navigator.canPop(context)) {
Navigator.pop(context);
bool isPopped = false;
for (final media in medias) {
final file = File(media.path);
final hashcode = await calculateFileSha256(file);
try {
await uploadAttachment(file, hashcode);
} catch (err) {
context.showErrorDialog(err);
}
if (!isPopped && Navigator.canPop(context)) {
Navigator.pop(context);
isPopped = true;
}
}
try {
await uploadAttachment(file, hashcode);
} catch (err) {
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
setState(() => _isSubmitting = false);
}
Future<void> pickVideoToUpload(
BuildContext context, ImageSource source) async {
Future<void> pickFileToUpload(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickVideo(source: source);
if (image == null) return;
FilePickerResult? result = await FilePicker.platform.pickFiles(allowMultiple: true);
if (result == null) return;
List<File> files = result.paths.map((path) => File(path!)).toList();
setState(() => _isSubmitting = true);
final file = File(image.path);
final hashcode = await calculateSha256(file);
bool isPopped = false;
for (final file in files) {
final hashcode = await calculateFileSha256(file);
try {
await uploadAttachment(file, hashcode);
} catch (err) {
context.showErrorDialog(err);
}
if (!isPopped && Navigator.canPop(context)) {
Navigator.pop(context);
isPopped = true;
}
}
setState(() => _isSubmitting = false);
}
Future<void> takeMediaToUpload(BuildContext context, bool isVideo) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
XFile? media;
if (isVideo) {
media = await _imagePicker.pickVideo(source: ImageSource.camera);
} else {
media = await _imagePicker.pickImage(source: ImageSource.camera);
}
if (media == null) return;
setState(() => _isSubmitting = true);
final file = File(media.path);
final hashcode = await calculateFileSha256(file);
if (Navigator.canPop(context)) {
Navigator.pop(context);
@@ -96,23 +130,22 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
await uploadAttachment(file, hashcode);
} catch (err) {
context.showErrorDialog(err);
} finally {
setState(() => _isSubmitting = false);
}
setState(() => _isSubmitting = false);
}
Future<void> uploadAttachment(File file, String hashcode) async {
final auth = context.read<AuthProvider>();
final req = MultipartRequest(
'POST', getRequestUri(widget.provider, '/api/attachments'));
final req = MultipartRequest('POST', getRequestUri(widget.provider, '/api/attachments'));
req.files.add(await MultipartFile.fromPath('attachment', file.path));
req.fields['hashcode'] = hashcode;
var res = await auth.client!.send(req);
if (res.statusCode == 200) {
var result = Attachment.fromJson(
jsonDecode(utf8.decode(await res.stream.toBytes()))["info"],
jsonDecode(utf8.decode(await res.stream.toBytes()))['info'],
);
setState(() => _attachments.add(result));
widget.onUpdate(_attachments);
@@ -121,12 +154,10 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
}
}
Future<void> disposeAttachment(
BuildContext context, Attachment item, int index) async {
Future<void> disposeAttachment(BuildContext context, Attachment item, int index) async {
final auth = context.read<AuthProvider>();
final req = MultipartRequest('DELETE',
getRequestUri(widget.provider, '/api/attachments/${item.id}'));
final req = MultipartRequest('DELETE', getRequestUri(widget.provider, '/api/attachments/${item.id}'));
setState(() => _isSubmitting = true);
var res = await auth.client!.send(req);
@@ -140,12 +171,6 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
setState(() => _isSubmitting = false);
}
Future<String> calculateSha256(File file) async {
final bytes = await file.readAsBytes();
final digest = sha256.convert(bytes);
return digest.toString();
}
String getFileName(Attachment item) {
return item.filename.replaceAll(RegExp(r'\.[^/.]+$'), '');
}
@@ -167,17 +192,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
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 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]}';
}
@@ -195,8 +210,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
return Column(
children: [
Container(
padding:
const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12),
padding: const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -215,9 +229,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return TextButton(
onPressed: _isSubmitting
? null
: () => viewAttachMethods(context),
onPressed: _isSubmitting ? null : () => viewAttachMethods(context),
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.add_circle),
);
@@ -229,16 +241,14 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
],
),
),
_isSubmitting
? const LinearProgressIndicator().animate().scaleX()
: Container(),
_isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(),
Expanded(
child: ListView.separated(
child: ListView.builder(
itemCount: _attachments.length,
itemBuilder: (context, index) {
var element = _attachments[index];
return Container(
padding: const EdgeInsets.only(left: 16, right: 8),
padding: const EdgeInsets.only(left: 16, right: 8, bottom: 16),
child: Row(
children: [
Expanded(
@@ -252,7 +262,7 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
style: Theme.of(context).textTheme.titleMedium,
),
Text(
"${getFileType(element)} · ${formatBytes(element.filesize)}",
'${getFileType(element)} · ${formatBytes(element.filesize)}',
),
],
),
@@ -263,14 +273,12 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
foregroundColor: Colors.red,
),
child: const Icon(Icons.delete),
onPressed: () =>
disposeAttachment(context, element, index),
onPressed: () => disposeAttachment(context, element, index),
),
],
),
);
},
separatorBuilder: (context, index) => const Divider(thickness: 0.3),
),
),
],
@@ -279,16 +287,16 @@ class _AttachmentEditorState extends State<AttachmentEditor> {
}
class AttachmentEditorMethodPopup extends StatelessWidget {
final Function pickImage;
final Function pickMedia;
final Function pickFile;
final Function takeImage;
final Function pickVideo;
final Function takeVideo;
const AttachmentEditorMethodPopup({
super.key,
required this.pickImage,
required this.pickMedia,
required this.pickFile,
required this.takeImage,
required this.pickVideo,
required this.takeVideo,
});
@@ -319,15 +327,28 @@ class AttachmentEditorMethodPopup extends StatelessWidget {
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickImage(),
onTap: () => pickMedia(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_photo_alternate,
color: Colors.indigo),
const Icon(Icons.photo_library, color: Colors.indigo),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickPhoto),
Text(AppLocalizations.of(context)!.pickMedia),
],
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickFile(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.file_present, color: Colors.amber),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickFile),
],
),
),
@@ -339,27 +360,13 @@ class AttachmentEditorMethodPopup extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.camera_alt, color: Colors.indigo),
const Icon(Icons.camera_alt, color: Colors.teal),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.takePhoto),
],
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickVideo(),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.camera, color: Colors.teal),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickVideo),
],
),
),
),
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => takeVideo(),
@@ -367,7 +374,7 @@ class AttachmentEditorMethodPopup extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.video_call, color: Colors.teal),
const Icon(Icons.movie, color: Colors.blue),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.takeVideo),
],

View File

@@ -1,4 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:solian/utils/platform.dart';
class AttachmentScreen extends StatelessWidget {
final String url;
@@ -14,8 +16,10 @@ class AttachmentScreen extends StatelessWidget {
child: InteractiveViewer(
boundaryMargin: const EdgeInsets.all(128),
minScale: 0.1,
maxScale: 16.0,
child: Image.network(url, fit: BoxFit.contain),
maxScale: 16,
panEnabled: true,
scaleEnabled: true,
child: PlatformInfo.canCacheImage ? CachedNetworkImage(imageUrl: url, fit: BoxFit.contain) : Image.network(url),
),
);

View File

@@ -9,8 +9,8 @@ import 'package:http/http.dart' as http;
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/posts/comment_editor.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/posts/post.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class CommentList extends StatefulWidget {
@@ -120,8 +120,8 @@ class CommentListHeader extends StatelessWidget {
if (snapshot.hasData && snapshot.data == true) {
return TextButton(
onPressed: () async {
final did = await router.pushNamed(
"posts.comments.editor",
final did = await SolianRouter.router.pushNamed(
'posts.comments.editor',
extra: CommentPostArguments(related: related),
);
if (did == true) paging.refresh();

View File

@@ -1,9 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:solian/models/post.dart';
import 'package:markdown/markdown.dart' as markdown;
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ArticleContent extends StatelessWidget {
@@ -55,16 +56,15 @@ class ArticleContent extends StatelessWidget {
},
imageBuilder: (url, _, __) {
Uri uri;
if (url.toString().startsWith("/api/attachments")) {
if (url.toString().startsWith('/api/attachments')) {
uri = getRequestUri('interactive', url.toString());
} else {
uri = url;
}
return AttachmentItem(
type: 1,
url: uri.toString(),
);
return PlatformInfo.canCacheImage
? CachedNetworkImage(imageUrl: uri.toString())
: Image.network(uri.toString());
},
),
],

View File

@@ -1,8 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/platform.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_carousel_widget/flutter_carousel_widget.dart';
import 'package:solian/widgets/posts/attachment_screen.dart';
import 'package:uuid/uuid.dart';
@@ -12,6 +14,7 @@ class AttachmentItem extends StatefulWidget {
final String url;
final String? tag;
final String? badge;
final bool noTag;
const AttachmentItem({
super.key,
@@ -19,6 +22,7 @@ class AttachmentItem extends StatefulWidget {
required this.url,
this.tag,
this.badge,
this.noTag = false,
});
@override
@@ -30,7 +34,7 @@ class _AttachmentItemState extends State<AttachmentItem> {
late final _videoPlayer = Player(
configuration: PlayerConfiguration(
title: "Attachment #${getTag()}",
title: 'Attachment #${getTag()}',
logLevel: MPVLogLevel.error,
),
);
@@ -50,11 +54,12 @@ class _AttachmentItemState extends State<AttachmentItem> {
@override
Widget build(BuildContext context) {
const borderRadius = Radius.circular(8);
final tag = getTag();
final tag = widget.noTag ? const Uuid().v4() : getTag();
Widget content;
if (widget.type == 1) {
final image = PlatformInfo.canCacheImage ? CachedNetworkImageProvider(widget.url) : NetworkImage(widget.url);
content = GestureDetector(
child: ClipRRect(
borderRadius: const BorderRadius.all(borderRadius),
@@ -62,8 +67,8 @@ class _AttachmentItemState extends State<AttachmentItem> {
tag: tag,
child: Stack(
children: [
Image.network(
widget.url,
Image(
image: image as ImageProvider,
key: Key(getTag()),
width: double.infinity,
height: double.infinity,
@@ -128,12 +133,16 @@ class _AttachmentItemState extends State<AttachmentItem> {
class AttachmentList extends StatelessWidget {
final List<Attachment> items;
final String provider;
final bool noTag;
const AttachmentList(
{super.key, required this.items, required this.provider});
const AttachmentList({
super.key,
required this.items,
required this.provider,
this.noTag = false,
});
Uri getFileUri(String fileId) =>
getRequestUri(provider, '/api/attachments/o/$fileId');
Uri getFileUri(String fileId) => getRequestUri(provider, '/api/attachments/o/$fileId');
@override
Widget build(BuildContext context) {
@@ -156,6 +165,7 @@ class AttachmentList extends StatelessWidget {
tag: item.fileId,
url: getFileUri(item.fileId).toString(),
badge: items.length <= 1 ? null : badge,
noTag: noTag,
),
);
},

View File

@@ -1,20 +1,22 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/account/avatar.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/posts/comment_list.dart';
import 'package:solian/widgets/posts/content/article.dart';
import 'package:solian/widgets/posts/content/attachment.dart';
import 'package:solian/widgets/posts/content/moment.dart';
import 'package:solian/widgets/posts/item_action.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/reaction_list.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:timeago/timeago.dart' as timeago;
class PostItem extends StatefulWidget {
final Post item;
final bool? brief;
final bool? ripple;
final bool brief;
final bool ripple;
final Function? onUpdate;
final Function? onDelete;
final Function? onTap;
@@ -22,8 +24,8 @@ class PostItem extends StatefulWidget {
const PostItem({
super.key,
required this.item,
this.brief,
this.ripple,
this.brief = true,
this.ripple = true,
this.onUpdate,
this.onDelete,
this.onTap,
@@ -47,8 +49,7 @@ class _PostItemState extends State<PostItem> {
}
void viewComments() {
final PagingController<int, Post> commentPaging =
PagingController(firstPageKey: 0);
final PagingController<int, Post> commentPaging = PagingController(firstPageKey: 0);
showModalBottomSheet(
context: context,
@@ -79,21 +80,23 @@ class _PostItemState extends State<PostItem> {
Widget renderContent() {
switch (widget.item.modelType) {
case 'article':
return ArticleContent(item: widget.item, brief: widget.brief ?? true);
return ArticleContent(item: widget.item, brief: widget.brief);
default:
return MomentContent(item: widget.item, brief: widget.brief ?? true);
return MomentContent(item: widget.item, brief: widget.brief);
}
}
Widget renderAttachments() {
if (widget.item.modelType == 'article') return Container();
if (widget.item.attachments != null &&
widget.item.attachments!.isNotEmpty) {
if (widget.item.attachments != null && widget.item.attachments!.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: AttachmentList(
items: widget.item.attachments!, provider: 'interactive'),
items: widget.item.attachments!,
provider: 'interactive',
noTag: SolianTheme.isLargeScreen(context) && widget.brief,
),
);
} else {
return Container();
@@ -133,9 +136,8 @@ class _PostItemState extends State<PostItem> {
);
}
String getAuthorDescribe() => widget.item.author.description.isNotEmpty
? widget.item.author.description
: 'No description yet.';
String getAuthorDescribe() =>
widget.item.author.description.isNotEmpty ? widget.item.author.description : 'No description yet.';
@override
void initState() {
@@ -163,7 +165,7 @@ class _PostItemState extends State<PostItem> {
Widget content;
if (widget.brief ?? true) {
if (widget.brief) {
content = Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Column(
@@ -171,9 +173,17 @@ class _PostItemState extends State<PostItem> {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountAvatar(
source: widget.item.author.avatar,
direct: true,
GestureDetector(
child: AccountAvatar(
source: widget.item.author.avatar,
direct: true,
),
onTap: () {
SolianRouter.router.pushNamed(
'users.info',
pathParameters: {'user': widget.item.author.name},
);
},
),
Expanded(
child: Column(
@@ -181,8 +191,7 @@ class _PostItemState extends State<PostItem> {
children: [
...headingParts,
Padding(
padding:
const EdgeInsets.only(left: 12, right: 12, top: 4),
padding: const EdgeInsets.only(left: 12, right: 12, top: 4),
child: renderContent(),
),
renderAttachments(),
@@ -199,13 +208,21 @@ class _PostItemState extends State<PostItem> {
content = Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 12, right: 12, top: 16),
padding: const EdgeInsets.only(left: 20, right: 20, top: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountAvatar(
source: widget.item.author.avatar,
direct: true,
GestureDetector(
child: AccountAvatar(
source: widget.item.author.avatar,
direct: true,
),
onTap: () {
SolianRouter.router.pushNamed(
'users.info',
pathParameters: {'user': widget.item.author.name},
);
},
),
Expanded(
child: Column(
@@ -230,17 +247,17 @@ class _PostItemState extends State<PostItem> {
child: Divider(thickness: 0.3),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: renderContent(),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 16),
child: renderAttachments(),
),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
child: renderReactions(),
),
),
@@ -248,9 +265,7 @@ class _PostItemState extends State<PostItem> {
);
}
final ripple = widget.ripple ?? true;
if (ripple) {
if (widget.ripple) {
return InkWell(
child: content,
onTap: () {

View File

@@ -5,7 +5,7 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/screens/posts/comment_editor.dart';
import 'package:solian/widgets/posts/item_deletion.dart';
import 'package:solian/widgets/posts/post_deletion.dart';
class PostItemAction extends StatelessWidget {
final Post item;
@@ -23,17 +23,17 @@ class PostItemAction extends StatelessWidget {
bool ok = false;
switch (item.modelType) {
case 'article':
ok = await router.pushNamed(
ok = await SolianRouter.router.pushNamed(
'posts.articles.editor',
extra: item,
) as bool;
case 'moment':
ok = await router.pushNamed(
ok = await SolianRouter.router.pushNamed(
'posts.moments.editor',
extra: item,
) as bool;
case 'comment':
ok = await router.pushNamed(
ok = await SolianRouter.router.pushNamed(
'posts.comments.editor',
extra: CommentPostArguments(editing: item),
) as bool;
@@ -54,10 +54,19 @@ class PostItemAction extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 12),
child: Text(
AppLocalizations.of(context)!.action,
style: Theme.of(context).textTheme.headlineSmall,
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.action,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'#${item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
Expanded(

View File

@@ -5,7 +5,7 @@ import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class ItemDeletionDialog extends StatefulWidget {
@@ -29,8 +29,7 @@ class _ItemDeletionDialogState extends State<ItemDeletionDialog> {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final uri =
getRequestUri('interactive', '/api/p/moments/${widget.item.id}');
final uri = getRequestUri('interactive', '/api/p/${widget.item.modelType}s/${widget.item.id}');
setState(() => _isSubmitting = true);
final res = await auth.client!.delete(uri);

View File

@@ -5,7 +5,7 @@ import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/reaction.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/utils/services_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/exts.dart';
@@ -134,8 +134,8 @@ class _ReactionActionPopupState extends State<ReactionActionPopup> {
child: ListTile(
title: Text(info.value.icon),
subtitle: Text(
":${info.key}:",
style: const TextStyle(fontFamily: "monospace"),
':${info.key}:',
style: const TextStyle(fontFamily: 'monospace'),
),
),
);

View File

@@ -0,0 +1,90 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/chat.dart';
import 'package:solian/providers/keypair.dart';
import 'package:solian/providers/notify.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/exts.dart';
class ProviderInitializer extends StatefulWidget {
final Widget child;
const ProviderInitializer({super.key, required this.child});
@override
State<ProviderInitializer> createState() => _ProviderInitializerState();
}
class _ProviderInitializerState extends State<ProviderInitializer> {
void showConnectionStatus(bool status) {
if (status) {
showConnectionSnackbar();
} else {
ScaffoldMessenger.of(context).clearSnackBars();
}
}
void showConnectionSnackbar() {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(minutes: 1),
));
}
void connect() async {
final auth = context.read<AuthProvider>();
final nty = context.read<NotifyProvider>();
final chat = context.read<ChatProvider>();
final keypair = context.read<KeypairProvider>();
final notify = ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.connectingServer),
duration: const Duration(seconds: 3),
),
);
try {
if (await auth.isAuthorized()) {
if (auth.client == null) {
await auth.loadClient();
}
nty.connect(
auth,
onKexRequest: keypair.provideKeypair,
onKexProvide: keypair.receiveKeypair,
onStateUpdated: showConnectionStatus
).then((value) {
keypair.channel = value;
});
chat.connect(auth, onStateUpdated: showConnectionStatus);
nty.fetch(auth);
Timer.periodic(const Duration(seconds: 1), (timer) {
nty.connect(auth, onStateUpdated: showConnectionStatus);
chat.connect(auth, onStateUpdated: showConnectionStatus);
});
}
} catch (e) {
context.showErrorDialog(e);
}
notify.close();
}
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () => connect());
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}

View File

@@ -0,0 +1,101 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/services_url.dart';
import 'package:solian/widgets/exts.dart';
class RealmDeletion extends StatefulWidget {
final Realm realm;
final bool isOwned;
const RealmDeletion({
super.key,
required this.realm,
required this.isOwned,
});
@override
State<RealmDeletion> createState() => _RealmDeletionState();
}
class _RealmDeletionState extends State<RealmDeletion> {
bool _isSubmitting = false;
Future<void> deleteChannel() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var res = await auth.client!.delete(
getRequestUri('passport', '/api/realms/${widget.realm.alias}'),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else if (Navigator.canPop(context)) {
Navigator.pop(context, true);
}
setState(() => _isSubmitting = false);
}
Future<void> leaveChannel() async {
setState(() => _isSubmitting = true);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var res = await auth.client!.delete(
getRequestUri('passport', '/api/realms/${widget.realm.alias}/members/me'),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
context.showErrorDialog(message);
} else if (Navigator.canPop(context)) {
Navigator.pop(context, true);
}
setState(() => _isSubmitting = false);
}
@override
Widget build(BuildContext context) {
final content = widget.isOwned
? AppLocalizations.of(context)!.chatChannelDeleteConfirm
: AppLocalizations.of(context)!.chatChannelLeaveConfirm;
return AlertDialog(
title: Text(AppLocalizations.of(context)!.confirmation),
content: Text(content),
actions: <Widget>[
TextButton(
onPressed: _isSubmitting ? null : () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.confirmCancel),
),
TextButton(
onPressed: _isSubmitting
? null
: () {
if (widget.isOwned) {
deleteChannel();
} else {
leaveChannel();
}
},
child: Text(AppLocalizations.of(context)!.confirmOkay),
),
],
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/router.dart';
class RealmNewAction extends StatelessWidget {
final Function onUpdate;
const RealmNewAction({super.key, required this.onUpdate});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 320,
width: double.infinity,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.only(left: 20, top: 20, bottom: 8),
child: Text(
AppLocalizations.of(context)!.realmNew,
style: Theme.of(context).textTheme.headlineSmall,
),
),
Expanded(
child: ListView(
children: [
ListTile(
leading: const Icon(Icons.add),
title: Text(AppLocalizations.of(context)!.realmNewCreate),
onTap: () {
SolianRouter.router.pushNamed('realms.editor').then((did) {
if (did == true) {
onUpdate();
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
}
});
},
),
ListTile(
leading: const Icon(Icons.travel_explore),
title: Text(AppLocalizations.of(context)!.realmNewJoin),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class RealmShortcuts extends StatelessWidget {
const RealmShortcuts({super.key});
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
final realm = context.watch<RealmProvider>();
if (realm.realms.isEmpty) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 280,
child: Text(
AppLocalizations.of(context)!.shortcutsEmpty,
style: const TextStyle(fontWeight: FontWeight.normal, fontSize: 16),
textAlign: TextAlign.center,
),
)
],
);
}
return ListView.builder(
itemCount: realm.realms.length,
scrollDirection: Axis.horizontal,
itemBuilder: (context, index) {
final element = realm.realms[index];
return InkWell(
child: SizedBox(
width: 80,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Padding(
padding: EdgeInsets.only(top: 16, bottom: 8),
child: CircleAvatar(
backgroundColor: Colors.teal,
child: Icon(Icons.supervised_user_circle, color: Colors.white),
),
),
Text(element.name, textAlign: TextAlign.center),
],
),
),
onTap: () async {
if (SolianTheme.isLargeScreen(context)) {
await realm.fetchSingle(auth, element.alias);
SolianRouter.router.pushNamed('realms');
} else {
SolianRouter.router.pushNamed(
'realms.details',
pathParameters: {'realm': element.alias},
);
}
},
);
},
);
}
}

67
lib/widgets/scaffold.dart Normal file
View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/theme.dart';
import 'package:solian/widgets/navigation_drawer.dart';
class IndentScaffold extends StatelessWidget {
final Widget? body;
final Widget? floatingActionButton;
final Widget? appBarLeading;
final List<Widget>? appBarActions;
final bool hideDrawer;
final bool showSafeArea;
final bool fixedAppBarColor;
final String title;
const IndentScaffold({
super.key,
this.body,
required this.title,
this.floatingActionButton,
this.appBarLeading,
this.appBarActions,
this.hideDrawer = false,
this.showSafeArea = false,
this.fixedAppBarColor = false,
});
@override
Widget build(BuildContext context) {
final backButton = IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
onPressed: () {
if (SolianRouter.router.canPop()) {
SolianRouter.router.pop();
}
},
);
final drawerButton = Builder(
builder: (context) {
return IconButton(
icon: const Icon(Icons.menu),
tooltip: MaterialLocalizations.of(context).openAppDrawerTooltip,
onPressed: () {
Scaffold.of(context).openDrawer();
},
);
}
);
return Scaffold(
appBar: AppBar(
title: Text(title),
leading: appBarLeading ?? (hideDrawer ? backButton : drawerButton),
actions: appBarActions,
centerTitle: false,
elevation: fixedAppBarColor ? 4 : null,
automaticallyImplyLeading: false,
),
floatingActionButton: floatingActionButton,
drawer: !hideDrawer ? const SolianNavigationDrawer() : null,
drawerScrimColor: SolianTheme.isLargeScreen(context) ? Colors.transparent : null,
body: showSafeArea ? SafeArea(child: body ?? Container()) : body,
);
}
}

View File

@@ -32,7 +32,7 @@ class SignInRequiredScreen extends StatelessWidget {
),
),
onTap: () {
router.goNamed('account');
SolianRouter.router.goNamed('account');
},
);
}

View File

@@ -6,6 +6,7 @@
#include "generated_plugin_registrant.h"
#include <desktop_drop/desktop_drop_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
@@ -14,6 +15,9 @@
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) desktop_drop_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopDropPlugin");
desktop_drop_plugin_register_with_registrar(desktop_drop_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
desktop_drop
file_selector_linux
flutter_secure_storage_linux
flutter_webrtc

View File

@@ -6,6 +6,7 @@ import FlutterMacOS
import Foundation
import connectivity_plus
import desktop_drop
import device_info_plus
import file_selector_macos
import flutter_local_notifications
@@ -17,11 +18,13 @@ import media_kit_video
import package_info_plus
import path_provider_foundation
import screen_brightness_macos
import sqflite
import url_launcher_macos
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
@@ -33,6 +36,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
}

View File

@@ -2,6 +2,8 @@ PODS:
- connectivity_plus (0.0.1):
- Flutter
- FlutterMacOS
- desktop_drop (0.0.1):
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_selector_macos (0.0.1):
@@ -30,6 +32,9 @@ PODS:
- FlutterMacOS
- screen_brightness_macos (0.1.0):
- FlutterMacOS
- sqflite (0.0.3):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- wakelock_plus (0.0.1):
@@ -38,6 +43,7 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin`)
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
@@ -51,6 +57,7 @@ DEPENDENCIES:
- package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`)
- sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
@@ -61,6 +68,8 @@ SPEC REPOS:
EXTERNAL SOURCES:
connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/darwin
desktop_drop:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_selector_macos:
@@ -87,6 +96,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
screen_brightness_macos:
:path: Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos
sqflite:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
wakelock_plus:
@@ -94,6 +105,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
device_info_plus: ce1b7762849d3ec103d0e0517299f2db7ad60720
file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9
flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4
@@ -107,6 +119,7 @@ SPEC CHECKSUMS:
package_info_plus: fa739dd842b393193c5ca93c26798dff6e3d0e0c
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
WebRTC-SDK: efc3e67e0355b1ee14bfe3c91188cada6000cb94

View File

@@ -17,6 +17,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.5.0"
asn1lib:
dependency: transitive
description:
name: asn1lib
sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda"
url: "https://pub.dev"
source: hosted
version: "1.5.3"
async:
dependency: transitive
description:
@@ -33,6 +41,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
basic_utils:
dependency: "direct main"
description:
name: basic_utils
sha256: "2064b21d3c41ed7654bc82cc476fd65542e04d60059b74d5eed490a4da08fc6c"
url: "https://pub.dev"
source: hosted
version: "5.7.0"
boolean_selector:
dependency: transitive
description:
@@ -41,6 +57,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "28ea9690a8207179c319965c13cd8df184d5ee721ae2ce60f398ced1219cea1f"
url: "https://pub.dev"
source: hosted
version: "3.3.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "9e90e78ae72caa874a323d78fa6301b3fb8fa7ea76a8f96dc5b5bf79f283bf2f"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "205d6a9f1862de34b93184f22b9d2d94586b2f05c581d546695e3d8f6a805cd7"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
characters:
dependency: transitive
description:
@@ -153,6 +193,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.10"
desktop_drop:
dependency: "direct main"
description:
name: desktop_drop
sha256: d55a010fe46c8e8fcff4ea4b451a9ff84a162217bdb3b2a0aa1479776205e15d
url: "https://pub.dev"
source: hosted
version: "0.4.4"
device_info_plus:
dependency: transitive
description:
@@ -177,6 +225,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.1.0"
easy_debounce:
dependency: "direct main"
description:
name: easy_debounce
sha256: f082609cfb8f37defb9e37fc28bc978c6712dedf08d4c5a26f820fa10165a236
url: "https://pub.dev"
source: hosted
version: "2.0.3"
encrypt:
dependency: "direct main"
description:
name: encrypt
sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2"
url: "https://pub.dev"
source: hosted
version: "5.0.3"
fake_async:
dependency: transitive
description:
@@ -201,6 +265,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.0"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: "29c90806ac5f5fb896547720b73b17ee9aed9bba540dc5d91fe29f8c5745b10a"
url: "https://pub.dev"
source: hosted
version: "8.0.3"
file_selector_linux:
dependency: transitive
description:
@@ -262,6 +334,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "395d6b7831f21f3b989ebedbb785545932adb9afe2622c1ffacf7f4b53a7e544"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
flutter_carousel_widget:
dependency: "direct main"
description:
@@ -421,6 +501,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "13.2.4"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: b1ac0fe2832c9cc95e5e88b57d627c5e68c223b9657f4b96e1487aa9098c7b82
url: "https://pub.dev"
source: hosted
version: "6.2.1"
hive:
dependency: transitive
description:
@@ -741,8 +829,16 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
package_info_plus:
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "45b40f99622f11901238e18d48f5f12ea36426d8eced9f4cbf58479c7aa2430d"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "2c582551839386fa7ddbc7770658be7c0f87f388a4bff72066478f597c34d17f"
@@ -897,10 +993,10 @@ packages:
dependency: transitive
description:
name: pointycastle
sha256: "79fbafed02cfdbe85ef3fd06c7f4bc2cbcba0177e61b765264853d4253b21744"
sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe"
url: "https://pub.dev"
source: hosted
version: "3.9.0"
version: "3.9.1"
protobuf:
dependency: transitive
description:
@@ -925,6 +1021,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
url: "https://pub.dev"
source: hosted
version: "0.27.7"
safe_local_storage:
dependency: transitive
description:
@@ -1018,6 +1122,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: a43e5a27235518c03ca238e7b4732cf35eabe863a369ceba6cbefa537a66f16d
url: "https://pub.dev"
source: hosted
version: "2.3.3+1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "3da423ce7baf868be70e2c0976c28a1bb2f73644268b7ffa7d2e08eab71f16a4"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
stack_trace:
dependency: transitive
description:
@@ -1316,4 +1436,4 @@ packages:
version: "3.1.2"
sdks:
dart: ">=3.3.3 <4.0.0"
flutter: ">=3.19.0"
flutter: ">=3.19.2"

View File

@@ -67,6 +67,14 @@ dependencies:
wakelock_plus: ^1.2.4
flutter_local_notifications: ^17.1.0
draggable_float_widget: ^0.1.0
file_picker: ^8.0.3
package_info_plus: ^7.0.0
cached_network_image: ^3.3.1
desktop_drop: ^0.4.4
easy_debounce: ^2.0.3
google_fonts: ^6.2.1
basic_utils: ^5.7.0
encrypt: ^5.0.3
dev_dependencies:
flutter_test:

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