Compare commits
66 Commits
Author | SHA1 | Date | |
---|---|---|---|
57279eb3e4 | |||
c403a2914a | |||
bcb176344c | |||
ecf362cffc | |||
f4ab7671d8 | |||
a2a3018917 | |||
0bdb664000 | |||
9c3b61ce57 | |||
d06df3d278 | |||
547ba19e61 | |||
cb05ff2e9e | |||
f614da7918 | |||
a3c8dafff9 | |||
fa978a7cd1 | |||
aaa0a562b4 | |||
590a4ce2a6 | |||
f26edce071 | |||
603799ea32 | |||
a32baf7798 | |||
498c9af663 | |||
202dbff6d3 | |||
96fd64d85d | |||
e236b7f98b | |||
5c7929e618 | |||
7ba5260246 | |||
a6d4947a23 | |||
7fbd4e9647 | |||
95d926b29f | |||
f6cf6d0440 | |||
e503c3f02f | |||
d4fbdd397e | |||
03943a7138 | |||
44f2c5fe0e | |||
bb66d5b684 | |||
1fca36293d | |||
2c7dc8c2ea | |||
cf0df91d8c | |||
91c85e8a58 | |||
2851780dda | |||
00fd58fb97 | |||
ee7d0ddd25 | |||
7656c08832 | |||
619c90cdd9 | |||
168d51c9fe | |||
d4b831f98e | |||
4d96a15c31 | |||
06dd3e092a | |||
82fe9e287a | |||
dc1c285de1 | |||
5a3313e94f | |||
61032c84f1 | |||
36a5b8fb39 | |||
3eda464e03 | |||
7a3ab6fd7d | |||
3d15c0b9f9 | |||
67a29b4305 | |||
594f57e0d3 | |||
d1eb51c596 | |||
85d2eff7f8 | |||
2375c46852 | |||
fd2eb5cda6 | |||
1256f440bd | |||
5b05ca67b6 | |||
95af7140cd | |||
77e9994204 | |||
3f6c186c13 |
@ -15,6 +15,7 @@ analyzer:
|
||||
- "**/*.freezed.dart"
|
||||
errors:
|
||||
invalid_annotation_target: ignore # Due to freezed + json_serializable issue, ref https://github.com/rrousselGit/freezed/issues/488#issuecomment-894358980
|
||||
deprecated_member_use: ignore
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
|
26
api/Paperclip/Activate Boost.bru
Normal file
26
api/Paperclip/Activate Boost.bru
Normal file
@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: Activate Boost
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{endpoint}}/cgi/uc/boosts/1/activate
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"client_id": "{{third_client_id}}",
|
||||
"client_secret":"{{third_client_tk}}",
|
||||
"type": "general",
|
||||
"subject": "Merry Christmas!",
|
||||
"subtitle": "一条来自 Solar Network 团队的信息",
|
||||
"content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄",
|
||||
"metadata": {
|
||||
"image": "6EqsYQwmFRCkbmhR"
|
||||
},
|
||||
"priority": 10
|
||||
}
|
||||
}
|
19
api/Paperclip/Stickers/Create Sticker Pack.bru
Normal file
19
api/Paperclip/Stickers/Create Sticker Pack.bru
Normal file
@ -0,0 +1,19 @@
|
||||
meta {
|
||||
name: Create Sticker Pack
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{endpoint}}/cgi/uc/stickers/packs
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"prefix": "cat",
|
||||
"name": "Solar Network full of Cats!",
|
||||
"description": "The sticker packs is full of stickers which related with cats!"
|
||||
}
|
||||
}
|
20
api/Paperclip/Stickers/Create Sticker.bru
Normal file
20
api/Paperclip/Stickers/Create Sticker.bru
Normal file
@ -0,0 +1,20 @@
|
||||
meta {
|
||||
name: Create Sticker
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{endpoint}}/cgi/uc/stickers
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"alias": "AteChip",
|
||||
"name": "Cat ate chips",
|
||||
"attachment_id": "d0b692cc64054463",
|
||||
"pack_id": 2
|
||||
}
|
||||
}
|
26
api/Passport/Developer Notify All Users.bru
Normal file
26
api/Passport/Developer Notify All Users.bru
Normal file
@ -0,0 +1,26 @@
|
||||
meta {
|
||||
name: Developer Notify All Users
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{endpoint}}/cgi/id/dev/notify/all
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"client_id": "{{third_client_id}}",
|
||||
"client_secret":"{{third_client_tk}}",
|
||||
"type": "general",
|
||||
"subject": "Merry Christmas!",
|
||||
"subtitle": "一条来自 Solar Network 团队的信息",
|
||||
"content": "今天是 12 月 25 日 (UTC+8),小羊祝您圣诞快乐 🎄",
|
||||
"metadata": {
|
||||
"image": "6EqsYQwmFRCkbmhR"
|
||||
},
|
||||
"priority": 10
|
||||
}
|
||||
}
|
9
api/bruno.json
Normal file
9
api/bruno.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Solar Network",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
7
api/collection.bru
Normal file
7
api/collection.bru
Normal file
@ -0,0 +1,7 @@
|
||||
auth {
|
||||
mode: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{atk}}
|
||||
}
|
8
api/environments/Prod.bru
Normal file
8
api/environments/Prod.bru
Normal file
@ -0,0 +1,8 @@
|
||||
vars {
|
||||
endpoint: https://api.sn.solsynth.dev
|
||||
third_client_id: alphabot
|
||||
}
|
||||
vars:secret [
|
||||
atk,
|
||||
third_client_tk
|
||||
]
|
@ -57,7 +57,7 @@
|
||||
"reply": "Reply",
|
||||
"unset": "Unset",
|
||||
"untitled": "Untitled",
|
||||
"postDetail": "Post detail",
|
||||
"postDetail": "Post Detail",
|
||||
"postNoun": "Post",
|
||||
"postReadMore": "Read more",
|
||||
"postReadEstimate": "Est read time {}",
|
||||
@ -139,6 +139,7 @@
|
||||
"fieldPostTitle": "Title",
|
||||
"fieldPostDescription": "Description",
|
||||
"fieldPostTags": "Tags",
|
||||
"fieldPostCategories": "Categories",
|
||||
"fieldPostAlias": "Alias",
|
||||
"fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.",
|
||||
"postPublish": "Publish",
|
||||
@ -178,12 +179,18 @@
|
||||
"other": "{} comments"
|
||||
},
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsAppBarTransparent": "Transparent App Bar",
|
||||
"settingsAppBarTransparentDescription": "Enable transparent effect for the app bar.",
|
||||
"settingsBackgroundImage": "Background Image",
|
||||
"settingsBackgroundImageDescription": "Set the background image that will be applied globally.",
|
||||
"settingsBackgroundImageClear": "Clear Existing Background Image",
|
||||
"settingsBackgroundImageClearDescription": "Reset the background image to blank.",
|
||||
"settingsThemeMaterial3": "Use Material You Design",
|
||||
"settingsThemeMaterial3Description": "Set the application theme to Material 3 Design.",
|
||||
"settingsColorScheme": "Color Scheme",
|
||||
"settingsColorSchemeDescription": "Set the application primary color.",
|
||||
"settingsColorSeed": "Color Seed",
|
||||
"settingsColorSeedDescription": "Select one of the present color schemes.",
|
||||
"settingsNetwork": "Network",
|
||||
"settingsNetworkServer": "HyperNet Server",
|
||||
"settingsNetworkServerDescription": "Set the HyperNet server address, choose ours or build your own.",
|
||||
@ -274,16 +281,49 @@
|
||||
"one": "{} attachment",
|
||||
"other": "{} attachments"
|
||||
},
|
||||
"messageTyping": {
|
||||
"one": "{} is typing...",
|
||||
"other": "{} are typing..."
|
||||
},
|
||||
"fieldAttachmentRandomId": "Random ID",
|
||||
"fieldAttachmentAlt": "Alternative text",
|
||||
"addAttachmentFromAlbum": "Add from album",
|
||||
"addAttachmentFromClipboard": "Paste file",
|
||||
"addAttachmentFromCameraPhoto": "Take photo",
|
||||
"addAttachmentFromCameraVideo": "Take video",
|
||||
"addAttachmentFromRandomId": "Link via RID",
|
||||
"attachmentPastedImage": "Pasted Image",
|
||||
"attachmentInsertLink": "Insert Link",
|
||||
"attachmentSetAsPostThumbnail": "Set as post thumbnail",
|
||||
"attachmentUnsetAsPostThumbnail": "Unset as post thumbnail",
|
||||
"attachmentCompressVideo": "Re-encode video",
|
||||
"attachmentSetThumbnail": "Set thumbnail",
|
||||
"attachmentSetAlt": "Set alternative text",
|
||||
"attachmentCopyRandomId": "Copy RID",
|
||||
"attachmentUpload": "Upload",
|
||||
"attachmentInputDialog": "Upload attachments",
|
||||
"attachmentInputUseRandomId": "Use Random ID",
|
||||
"attachmentInputNew": "New Upload",
|
||||
"waitingForUpload": "Waiting for upload",
|
||||
"attachmentVideoCompressHint": "Compress a copy of this video",
|
||||
"attachmentVideoCompressHintDescription": "Do you want to upload a compress copy of video {}? It will help your audience to preview this video faster and they still can watch the original video. It will take some while to process the video on your device, so please be patient.",
|
||||
"attachmentCompressQuality": "Compress quality",
|
||||
"attachmentCompressQualityHighest": "Highest",
|
||||
"attachmentCompressQualityDefault": "Default",
|
||||
"attachmentCompressQualityMedium": "Medium",
|
||||
"attachmentCompressQualityLow": "Low",
|
||||
"attachmentCompressQualityHint": "Solar Network doesn't prevent you from uploading large files, high resolution, high bitrate videos. But for your network conditions, we suggest you choose a suitable compression quality.",
|
||||
"attachmentUploaded": "Uploaded",
|
||||
"attachmentPending": "Pending",
|
||||
"attachmentCopyCompressed": "Copy compressed",
|
||||
"attachmentGotBoosted": "Boosted",
|
||||
"attachmentBoost": "Boost",
|
||||
"attachmentCreateBoost": "Create Boost",
|
||||
"attachmentBoostHint": "Boost is a feature that allows you to upload attachments to a server closer to your audience or a faster content network. This feature is currently in beta and is subject to change. It's all free for now, you can feel free to try, you will get notified when the pricing plan changed.",
|
||||
"attachmentDestinationRegion": "Destination Region",
|
||||
"attachmentDestinationRegionAPAC": "Asia Pacific",
|
||||
"attachmentDestinationRegionNGB": "Ning Bo, China, Zhejiang",
|
||||
"attachmentDestinationRegionHKG": "Hong Kong",
|
||||
"notification": "Notification",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "All notifications read",
|
||||
@ -371,9 +411,32 @@
|
||||
"dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
|
||||
"dailyCheckNegativeHint6": "Going out",
|
||||
"dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
|
||||
"happyBirthday": "Happy birthday, {}!",
|
||||
"celebrateBirthday": "Happy birthday, {}!",
|
||||
"celebrateMerryXmas": "Merry christmas, {}!",
|
||||
"celebrateNewYear": "Happy new year, {}!",
|
||||
"celebrateLunarNewYear": "Happy lunar new year, {}!",
|
||||
"celebrateMidAutumn": "Happy mid-autumn festival, {}!",
|
||||
"celebrateDragonBoat": "Happy dragon boat festival, {}!",
|
||||
"celebrateValentineDay": "Today is valentine's day, {}!",
|
||||
"celebrateLaborDay": "Today is labor day, {}.",
|
||||
"celebrateMotherDay": "Today is mother's day, {}.",
|
||||
"celebrateChildrenDay": "Today is children's day, {}!",
|
||||
"celebrateFatherDay": "Today is father's day, {}.",
|
||||
"celebrateHalloween": "Happy halloween, {}!",
|
||||
"celebrateThanksgiving": "Today is thanksgiving day, {}!",
|
||||
"pendingBirthday": "Birthday in {}",
|
||||
"pendingMerryXmas": "Christmas in {}",
|
||||
"pendingLunarNewYear": "Lunar new year in {}",
|
||||
"pendingMidAutumn": "Mid-autumn festival in {}",
|
||||
"pendingDragonBoat": "Dragon boat festival in {}",
|
||||
"pendingNewYear": "New year in {}",
|
||||
"pendingValentineDay": "Valentine's day in {}",
|
||||
"pendingLaborDay": "Labor day in {}",
|
||||
"pendingMotherDay": "Mother's day in {}",
|
||||
"pendingChildrenDay": "Children's day in {}",
|
||||
"pendingFatherDay": "Father's day in {}",
|
||||
"pendingHalloween": "Halloween in {}",
|
||||
"pendingThanksgiving": "Thanksgiving day in {}",
|
||||
"friendNew": "Add Friend",
|
||||
"friendRequests": "Friend Requests",
|
||||
"friendRequestsDescription": {
|
||||
@ -407,6 +470,7 @@
|
||||
"accountJoinedAt": "Joined at {}",
|
||||
"accountBirthday": "Born on {}",
|
||||
"accountBadge": "Badge",
|
||||
"accountCheckInNoRecords": "No check-in records",
|
||||
"badgeCompanyStaff": "Solsynth Staff",
|
||||
"badgeSiteMigration": "Solar Network Native",
|
||||
"accountStatus": "Status",
|
||||
@ -415,6 +479,7 @@
|
||||
"accountStatusLastSeen": "Last seen at {}",
|
||||
"postArticle": "Article on the Solar Network",
|
||||
"postStory": "Story on the Solar Network",
|
||||
"postLocalDraftRestored": "Restored from device",
|
||||
"articleWrittenAt": "Written at {}",
|
||||
"articleEditedAt": "Edited at {}",
|
||||
"attachmentSaved": "Saved to album",
|
||||
@ -450,7 +515,7 @@
|
||||
"publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.",
|
||||
"userUnblocked": "{} has been unblocked.",
|
||||
"userBlocked": "{} has been blocked.",
|
||||
"postSharingViaPicture": "Capturing post as picture, please stand by...",
|
||||
"postSharingViaPicture": "Capturing post as picture, please wait...",
|
||||
"postImageShareReadMore": "Scan the QR code to read full post",
|
||||
"postImageShareAds": "Explore posts on the Solar Network",
|
||||
"postShare": "Share",
|
||||
@ -458,8 +523,29 @@
|
||||
"appInitializing": "Initializing",
|
||||
"poweredBy": "Powered by {}",
|
||||
"shareIntent": "Share",
|
||||
"shareIntentDescription": "What do you want to do with the content you are sharing?",
|
||||
"shareIntentDescription": "What do you want to do with the content you are sharing?",
|
||||
"shareIntentPostStory": "Post a Story",
|
||||
"updateAvailable": "Update Available",
|
||||
"updateOngoing": "正在更新,请稍后..."
|
||||
"updateOngoing": "Updating, please wait...",
|
||||
"custom": "Custom",
|
||||
"colorSchemeIndigo": "Indigo",
|
||||
"colorSchemeBlue": "Blue",
|
||||
"colorSchemeGreen": "Green",
|
||||
"colorSchemeYellow": "Yellow",
|
||||
"colorSchemeOrange": "Orange",
|
||||
"colorSchemeRed": "Red",
|
||||
"colorSchemeWhite": "White",
|
||||
"colorSchemeBlack": "Black",
|
||||
"colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
|
||||
"postCategoryTechnology": "Technology",
|
||||
"postCategoryGaming": "Gaming",
|
||||
"postCategoryLife": "Life",
|
||||
"postCategoryArts": "Arts",
|
||||
"postCategorySports": "Sports",
|
||||
"postCategoryMusic": "Music",
|
||||
"postCategoryNews": "News",
|
||||
"postCategoryKnowledge": "Knowledge",
|
||||
"postCategoryLiterature": "Literature",
|
||||
"postCategoryFunny": "Funny",
|
||||
"postCategoryUncategorized": "Uncategorized"
|
||||
}
|
||||
|
@ -123,6 +123,7 @@
|
||||
"fieldPostTitle": "标题",
|
||||
"fieldPostDescription": "描述",
|
||||
"fieldPostTags": "标签",
|
||||
"fieldPostCategories": "分类",
|
||||
"fieldPostAlias": "别名",
|
||||
"fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。",
|
||||
"postPublish": "发布",
|
||||
@ -182,6 +183,12 @@
|
||||
"settingsBackgroundImageClearDescription": "将应用背景图重置为空白。",
|
||||
"settingsThemeMaterial3": "使用 Material You 设计范式",
|
||||
"settingsThemeMaterial3Description": "将应用主题设置为 Material 3 设计范式的主题。",
|
||||
"settingsAppBarTransparent": "透明顶栏",
|
||||
"settingsAppBarTransparentDescription": "为顶栏启用透明效果。",
|
||||
"settingsColorScheme": "主题色",
|
||||
"settingsColorSchemeDescription": "设置应用主题色。",
|
||||
"settingsColorSeed": "预设色彩主题",
|
||||
"settingsColorSeedDescription": "选择一个预设色彩主题。",
|
||||
"settingsNetwork": "网络",
|
||||
"settingsNetworkServer": "HyperNet 服务器",
|
||||
"settingsNetworkServerDescription": "设置 HyperNet 服务器地址,选择我们提供的,或者自己搭建。",
|
||||
@ -272,16 +279,49 @@
|
||||
"one": "{} 个附件",
|
||||
"other": "{} 个附件"
|
||||
},
|
||||
"messageTyping": {
|
||||
"one": "{} 正在输入",
|
||||
"other": "{} 正在输入"
|
||||
},
|
||||
"fieldAttachmentRandomId": "访问 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "从相册中添加附件",
|
||||
"addAttachmentFromClipboard": "粘贴附件",
|
||||
"addAttachmentFromCameraPhoto": "拍摄照片",
|
||||
"addAttachmentFromCameraVideo": "拍摄视频",
|
||||
"addAttachmentFromRandomId": "通过访问 ID 链接",
|
||||
"attachmentPastedImage": "粘贴的图片",
|
||||
"attachmentInsertLink": "插入连接",
|
||||
"attachmentSetAsPostThumbnail": "设置为帖子缩略图",
|
||||
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
|
||||
"attachmentCompressVideo": "重新编码视频",
|
||||
"attachmentSetThumbnail": "设置缩略图",
|
||||
"attachmentSetAlt": "设置概述文字",
|
||||
"attachmentCopyRandomId": "复制访问 ID",
|
||||
"attachmentUpload": "上传",
|
||||
"attachmentInputDialog": "上传附件",
|
||||
"attachmentInputUseRandomId": "使用访问 ID",
|
||||
"attachmentInputNew": "新上传附件",
|
||||
"waitingForUpload": "等待上传",
|
||||
"attachmentVideoCompressHint": "压缩一份视频的副本",
|
||||
"attachmentVideoCompressHintDescription": "你想上传压缩视频 {} 的副本吗?它将帮助你的观众快速预览视频,并且他们仍然可以观看原始视频。这将会在在你的设备上处理视频,所以需要一些时间,所以请耐心等待。",
|
||||
"attachmentCompressQuality": "压缩质量",
|
||||
"attachmentCompressQualityHighest": "最高",
|
||||
"attachmentCompressQualityDefault": "默认",
|
||||
"attachmentCompressQualityMedium": "中等",
|
||||
"attachmentCompressQualityLow": "低",
|
||||
"attachmentCompressQualityHint": "Solar Network 并没有阻止你上传大文件、高分辨率、高码率的视频,但是为了你的网络情况观众考虑,我们建议你选择一个合适的压缩质量。",
|
||||
"attachmentUploaded": "已上传",
|
||||
"attachmentPending": "未上传",
|
||||
"attachmentCopyCompressed": "有压缩副本",
|
||||
"attachmentGotBoosted": "有加速传递",
|
||||
"attachmentBoost": "加速包",
|
||||
"attachmentCreateBoost": "加速传递",
|
||||
"attachmentBoostHint": "加速传递允许您将附件上传到更近的受众或更快的内容网络。该功能目前处于 Beta 阶段。该功能限时免费,当有价格计划更改时,您将会被通知。",
|
||||
"attachmentDestinationRegion": "目标节点",
|
||||
"attachmentDestinationRegionAPAC": "亚太地区",
|
||||
"attachmentDestinationRegionNGB": "中国 · 浙江 · 宁波",
|
||||
"attachmentDestinationRegionHKG": "香港",
|
||||
"notification": "通知",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "无未读通知",
|
||||
@ -369,9 +409,32 @@
|
||||
"dailyCheckNegativeHint5Description": "关键时刻断网",
|
||||
"dailyCheckNegativeHint6": "出门",
|
||||
"dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
|
||||
"happyBirthday": "生日快乐,{}!",
|
||||
"celebrateBirthday": "生日快乐,{}!",
|
||||
"celebrateLunarNewYear": "春节快乐,{}!",
|
||||
"celebrateMidAutumn": "中秋节快乐,{}!",
|
||||
"celebrateDragonBoat": "端午节快乐,{}!",
|
||||
"celebrateMerryXmas": "圣诞快乐,{}!",
|
||||
"celebrateNewYear": "新年快乐,{}!",
|
||||
"celebrateValentineDay": "今天是情人节,{}!",
|
||||
"celebrateLaborDay": "今天是劳动节,{}。",
|
||||
"celebrateMotherDay": "今天是母亲节,{}。",
|
||||
"celebrateChildrenDay": "今天是儿童节,{}!",
|
||||
"celebrateFatherDay": "今天是父亲节,{}。",
|
||||
"celebrateHalloween": "快乐在圣诞节,{}!",
|
||||
"celebrateThanksgiving": "今天是感恩节,{}!",
|
||||
"pendingLunarNewYear": "{} 过春节",
|
||||
"pendingMidAutumn": "{} 过中秋节",
|
||||
"pendingDragonBoat": "{} 过端午节",
|
||||
"pendingBirthday": "{} 过生日",
|
||||
"pendingMerryXmas": "{} 过圣诞节",
|
||||
"pendingNewYear": "{} 跨年",
|
||||
"pendingValentineDay": "{} 过情人节",
|
||||
"pendingLaborDay": "{} 过劳动节",
|
||||
"pendingMotherDay": "{} 过母亲节",
|
||||
"pendingChildrenDay": "{} 过儿童节",
|
||||
"pendingFatherDay": "{} 过父亲节",
|
||||
"pendingHalloween": "{} 过圣诞节",
|
||||
"pendingThanksgiving": "{} 过感恩节",
|
||||
"friendNew": "添加好友",
|
||||
"friendRequests": "好友请求",
|
||||
"friendRequestsDescription": {
|
||||
@ -405,14 +468,16 @@
|
||||
"accountJoinedAt": "加入于 {}",
|
||||
"accountBirthday": "出生于 {}",
|
||||
"accountBadge": "徽章",
|
||||
"accountCheckInNoRecords": "暂无运势记录",
|
||||
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"accountStatus": "状态",
|
||||
"accountStatusOnline": "在线",
|
||||
"accountStatusOffline": "离线",
|
||||
"accountStatusLastSeen": "最后一次在 {} 上线",
|
||||
"accountStatusLastSeen": "最后一次上线于 {}",
|
||||
"postArticle": "Solar Network 上的文章",
|
||||
"postStory": "Solar Network 上的故事",
|
||||
"postLocalDraftRestored": "从本地恢复草稿",
|
||||
"articleWrittenAt": "发表于 {}",
|
||||
"articleEditedAt": "编辑于 {}",
|
||||
"attachmentSaved": "已保存到相册",
|
||||
@ -459,5 +524,26 @@
|
||||
"shareIntentDescription": "您想对您分享的内容做些什么?",
|
||||
"shareIntentPostStory": "发布动态",
|
||||
"updateAvailable": "检测到更新可用",
|
||||
"updateOngoing": "正在更新,请稍后……"
|
||||
"updateOngoing": "正在更新,请稍后……",
|
||||
"custom": "自定义",
|
||||
"colorSchemeIndigo": "靛蓝",
|
||||
"colorSchemeBlue": "蓝色",
|
||||
"colorSchemeGreen": "绿色",
|
||||
"colorSchemeYellow": "黄色",
|
||||
"colorSchemeOrange": "橙色",
|
||||
"colorSchemeRed": "红色",
|
||||
"colorSchemeWhite": "白色",
|
||||
"colorSchemeBlack": "黑色",
|
||||
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
|
||||
"postCategoryTechnology": "技术",
|
||||
"postCategoryGaming": "游戏",
|
||||
"postCategoryLife": "生活",
|
||||
"postCategoryArts": "艺术",
|
||||
"postCategorySports": "体育",
|
||||
"postCategoryMusic": "音乐",
|
||||
"postCategoryNews": "新闻",
|
||||
"postCategoryKnowledge": "知识",
|
||||
"postCategoryLiterature": "文学",
|
||||
"postCategoryFunny": "搞笑",
|
||||
"postCategoryUncategorized": "未分类"
|
||||
}
|
||||
|
@ -123,6 +123,9 @@
|
||||
"fieldPostTitle": "標題",
|
||||
"fieldPostDescription": "描述",
|
||||
"fieldPostTags": "標籤",
|
||||
"fieldPostCategories": "分類",
|
||||
"fieldPostAlias": "別名",
|
||||
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
|
||||
"postPublish": "發佈",
|
||||
"postPublishedAt": "發佈於",
|
||||
"postPublishedUntil": "取消發佈於",
|
||||
@ -180,6 +183,12 @@
|
||||
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
|
||||
"settingsThemeMaterial3": "使用 Material You 設計範式",
|
||||
"settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。",
|
||||
"settingsAppBarTransparent": "透明頂欄",
|
||||
"settingsAppBarTransparentDescription": "為頂欄啓用透明效果。",
|
||||
"settingsColorScheme": "主題色",
|
||||
"settingsColorSchemeDescription": "設置應用主題色。",
|
||||
"settingsColorSeed": "預設色彩主題",
|
||||
"settingsColorSeedDescription": "選擇一個預設色彩主題。",
|
||||
"settingsNetwork": "網絡",
|
||||
"settingsNetworkServer": "HyperNet 服務器",
|
||||
"settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
|
||||
@ -270,16 +279,49 @@
|
||||
"one": "{} 個附件",
|
||||
"other": "{} 個附件"
|
||||
},
|
||||
"messageTyping": {
|
||||
"one": "{} 正在輸入",
|
||||
"other": "{} 正在輸入"
|
||||
},
|
||||
"fieldAttachmentRandomId": "訪問 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||
"addAttachmentFromClipboard": "粘貼附件",
|
||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||
"addAttachmentFromRandomId": "通過訪問 ID 鏈接",
|
||||
"attachmentPastedImage": "粘貼的圖片",
|
||||
"attachmentInsertLink": "插入連接",
|
||||
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
|
||||
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
|
||||
"attachmentCompressVideo": "重新編碼視頻",
|
||||
"attachmentSetThumbnail": "設置縮略圖",
|
||||
"attachmentSetAlt": "設置概述文字",
|
||||
"attachmentCopyRandomId": "複製訪問 ID",
|
||||
"attachmentUpload": "上傳",
|
||||
"attachmentInputDialog": "上傳附件",
|
||||
"attachmentInputUseRandomId": "使用訪問 ID",
|
||||
"attachmentInputNew": "新上傳附件",
|
||||
"waitingForUpload": "等待上傳",
|
||||
"attachmentVideoCompressHint": "壓縮一份視頻的副本",
|
||||
"attachmentVideoCompressHintDescription": "你想上傳壓縮視頻 {} 的副本嗎?它將幫助你的觀眾快速預覽視頻,並且他們仍然可以觀看原始視頻。這將會在在你的設備上處理視頻,所以需要一些時間,所以請耐心等待。",
|
||||
"attachmentCompressQuality": "壓縮質量",
|
||||
"attachmentCompressQualityHighest": "最高",
|
||||
"attachmentCompressQualityDefault": "默認",
|
||||
"attachmentCompressQualityMedium": "中等",
|
||||
"attachmentCompressQualityLow": "低",
|
||||
"attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。",
|
||||
"attachmentUploaded": "已上傳",
|
||||
"attachmentPending": "未上傳",
|
||||
"attachmentCopyCompressed": "有壓縮副本",
|
||||
"attachmentGotBoosted": "有加速傳遞",
|
||||
"attachmentBoost": "加速包",
|
||||
"attachmentCreateBoost": "加速傳遞",
|
||||
"attachmentBoostHint": "加速傳遞允許您將附件上傳到更近的受眾或更快的內容網絡。該功能目前處於 Beta 階段。該功能限時免費,當有價格計劃更改時,您將會被通知。",
|
||||
"attachmentDestinationRegion": "目標節點",
|
||||
"attachmentDestinationRegionAPAC": "亞太地區",
|
||||
"attachmentDestinationRegionNGB": "中國 · 浙江 · 寧波",
|
||||
"attachmentDestinationRegionHKG": "香港",
|
||||
"notification": "通知",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "無未讀通知",
|
||||
@ -367,7 +409,32 @@
|
||||
"dailyCheckNegativeHint5Description": "關鍵時刻斷網",
|
||||
"dailyCheckNegativeHint6": "出門",
|
||||
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
|
||||
"happyBirthday": "生日快樂,{}!",
|
||||
"celebrateBirthday": "生日快樂,{}!",
|
||||
"celebrateLunarNewYear": "春節快樂,{}!",
|
||||
"celebrateMidAutumn": "中秋節快樂,{}!",
|
||||
"celebrateDragonBoat": "端午節快樂,{}!",
|
||||
"celebrateMerryXmas": "聖誕快樂,{}!",
|
||||
"celebrateNewYear": "新年快樂,{}!",
|
||||
"celebrateValentineDay": "今天是情人節,{}!",
|
||||
"celebrateLaborDay": "今天是勞動節,{}。",
|
||||
"celebrateMotherDay": "今天是母親節,{}。",
|
||||
"celebrateChildrenDay": "今天是兒童節,{}!",
|
||||
"celebrateFatherDay": "今天是父親節,{}。",
|
||||
"celebrateHalloween": "快樂在聖誕節,{}!",
|
||||
"celebrateThanksgiving": "今天是感恩節,{}!",
|
||||
"pendingLunarNewYear": "{} 過春節",
|
||||
"pendingMidAutumn": "{} 過中秋節",
|
||||
"pendingDragonBoat": "{} 過端午節",
|
||||
"pendingBirthday": "{} 過生日",
|
||||
"pendingMerryXmas": "{} 過聖誕節",
|
||||
"pendingNewYear": "{} 跨年",
|
||||
"pendingValentineDay": "{} 過情人節",
|
||||
"pendingLaborDay": "{} 過勞動節",
|
||||
"pendingMotherDay": "{} 過母親節",
|
||||
"pendingChildrenDay": "{} 過兒童節",
|
||||
"pendingFatherDay": "{} 過父親節",
|
||||
"pendingHalloween": "{} 過聖誕節",
|
||||
"pendingThanksgiving": "{} 過感恩節",
|
||||
"friendNew": "添加好友",
|
||||
"friendRequests": "好友請求",
|
||||
"friendRequestsDescription": {
|
||||
@ -401,14 +468,16 @@
|
||||
"accountJoinedAt": "加入於 {}",
|
||||
"accountBirthday": "出生於 {}",
|
||||
"accountBadge": "徽章",
|
||||
"accountCheckInNoRecords": "暫無運勢記錄",
|
||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"accountStatus": "狀態",
|
||||
"accountStatusOnline": "在線",
|
||||
"accountStatusOffline": "離線",
|
||||
"accountStatusLastSeen": "最後一次在 {} 上線",
|
||||
"accountStatusLastSeen": "最後一次上線於 {}",
|
||||
"postArticle": "Solar Network 上的文章",
|
||||
"postStory": "Solar Network 上的故事",
|
||||
"postLocalDraftRestored": "從本地恢復草稿",
|
||||
"articleWrittenAt": "發表於 {}",
|
||||
"articleEditedAt": "編輯於 {}",
|
||||
"attachmentSaved": "已保存到相冊",
|
||||
@ -453,5 +522,28 @@
|
||||
"poweredBy": "由 {} 提供支持",
|
||||
"shareIntent": "分享",
|
||||
"shareIntentDescription": "您想對您分享的內容做些什麼?",
|
||||
"shareIntentPostStory": "發佈動態"
|
||||
"shareIntentPostStory": "發佈動態",
|
||||
"updateAvailable": "檢測到更新可用",
|
||||
"updateOngoing": "正在更新,請稍後……",
|
||||
"custom": "自定義",
|
||||
"colorSchemeIndigo": "靛藍",
|
||||
"colorSchemeBlue": "藍色",
|
||||
"colorSchemeGreen": "綠色",
|
||||
"colorSchemeYellow": "黃色",
|
||||
"colorSchemeOrange": "橙色",
|
||||
"colorSchemeRed": "紅色",
|
||||
"colorSchemeWhite": "白色",
|
||||
"colorSchemeBlack": "黑色",
|
||||
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
|
||||
"postCategoryTechnology": "技術",
|
||||
"postCategoryGaming": "遊戲",
|
||||
"postCategoryLife": "生活",
|
||||
"postCategoryArts": "藝術",
|
||||
"postCategorySports": "體育",
|
||||
"postCategoryMusic": "音樂",
|
||||
"postCategoryNews": "新聞",
|
||||
"postCategoryKnowledge": "知識",
|
||||
"postCategoryLiterature": "文學",
|
||||
"postCategoryFunny": "搞笑",
|
||||
"postCategoryUncategorized": "未分類"
|
||||
}
|
||||
|
@ -7,15 +7,15 @@
|
||||
"screenAuthLogin": "登陸",
|
||||
"screenAuthLoginSubtitle": "使用 Solarpass 登陸 Solar Network",
|
||||
"screenAuthLoginGreeting": "歡迎回來",
|
||||
"screenAuthRegister": "建立賬號",
|
||||
"screenAuthRegisterSubtitle": "建立一個 Solarpass 賬號",
|
||||
"screenAccountPublishers": "釋出者",
|
||||
"screenAccountPublisherNew": "新建釋出者",
|
||||
"screenAccountPublisherEdit": "編輯釋出者",
|
||||
"screenAuthRegister": "創建賬號",
|
||||
"screenAuthRegisterSubtitle": "創建一個 Solarpass 賬號",
|
||||
"screenAccountPublishers": "發佈者",
|
||||
"screenAccountPublisherNew": "新建發佈者",
|
||||
"screenAccountPublisherEdit": "編輯發佈者",
|
||||
"screenAccountProfileEdit": "編輯資料",
|
||||
"screenAbuseReport": "濫用檢舉",
|
||||
"screenSettings": "設定",
|
||||
"screenAlbum": "相簿",
|
||||
"screenSettings": "設置",
|
||||
"screenAlbum": "相冊",
|
||||
"screenChat": "聊天",
|
||||
"screenChatManage": "編輯聊天頻道",
|
||||
"screenChatNew": "新建聊天頻道",
|
||||
@ -23,37 +23,37 @@
|
||||
"screenRealmManage": "編輯領域",
|
||||
"screenRealmNew": "新建領域",
|
||||
"screenNotification": "通知",
|
||||
"screenPostSearch": "搜尋帖子",
|
||||
"screenPostSearch": "搜索帖子",
|
||||
"screenFriend": "好友",
|
||||
"dialogOkay": "好的",
|
||||
"dialogCancel": "取消",
|
||||
"dialogConfirm": "確認",
|
||||
"dialogDismiss": "忽略",
|
||||
"dialogError": "出了點問題",
|
||||
"errorRequestBad": "伺服器拒絕了您的請求,請檢查您的輸入。",
|
||||
"errorRequestUnauthorized": "未授權的請求,請登入或者嘗試重新登陸。",
|
||||
"errorRequestForbidden": "被禁止的請求,您沒有足夠的許可權去做那件事。",
|
||||
"errorRequestNotFound": "您正查詢的資源無法被找到。",
|
||||
"errorRequestConnection": "網路連線錯誤,請檢查您的網路狀態或者檢查我們的服務狀態。",
|
||||
"errorRequestUnknown": "未知請求錯誤,您可能想將此對話方塊截圖併發送給我們。",
|
||||
"errorRequestBad": "服務器拒絕了您的請求,請檢查您的輸入。",
|
||||
"errorRequestUnauthorized": "未授權的請求,請登錄或者嘗試重新登陸。",
|
||||
"errorRequestForbidden": "被禁止的請求,您沒有足夠的權限去做那件事。",
|
||||
"errorRequestNotFound": "您正查找的資源無法被找到。",
|
||||
"errorRequestConnection": "網絡連接錯誤,請檢查您的網絡狀態或者檢查我們的服務狀態。",
|
||||
"errorRequestUnknown": "未知請求錯誤,您可能想將此對話框截圖併發送給我們。",
|
||||
"unknown": "未知",
|
||||
"loading": "載入中…",
|
||||
"loading": "加載中…",
|
||||
"prev": "上一步",
|
||||
"next": "下一步",
|
||||
"edit": "編輯",
|
||||
"apply": "應用",
|
||||
"cancel": "取消",
|
||||
"create": "建立",
|
||||
"create": "創建",
|
||||
"preview": "預覽",
|
||||
"delete": "刪除",
|
||||
"unlink": "解除連結",
|
||||
"unlink": "解除鏈接",
|
||||
"crop": "裁剪",
|
||||
"compress": "壓縮",
|
||||
"report": "檢舉",
|
||||
"repost": "轉帖",
|
||||
"replyPost": "回貼",
|
||||
"reply": "回覆",
|
||||
"unset": "未設定",
|
||||
"unset": "未設置",
|
||||
"untitled": "無題",
|
||||
"postDetail": "帖子詳情",
|
||||
"postNoun": "帖子",
|
||||
@ -64,20 +64,20 @@
|
||||
"one": "總計 {} 字",
|
||||
"other": "總計 {} 字"
|
||||
},
|
||||
"fieldUsername": "使用者名稱",
|
||||
"fieldUsername": "用戶名",
|
||||
"fieldNickname": "顯示名",
|
||||
"fieldEmail": "電子郵箱地址",
|
||||
"fieldPassword": "密碼",
|
||||
"fieldUsernameAlphanumOnly": "使用者名稱只能包含英文大小寫字母和數字。",
|
||||
"fieldUsernameLengthLimit": "使用者名稱必須在 {} 和 {} 之間。",
|
||||
"fieldUsernameCannotEditHint": "使用者名稱在建立後無法修改",
|
||||
"fieldUsernameLookupHint": "支援使用者名稱、電話號碼或郵箱地址",
|
||||
"fieldUsernameAlphanumOnly": "用戶名只能包含英文大小寫字母和數字。",
|
||||
"fieldUsernameLengthLimit": "用戶名必須在 {} 和 {} 之間。",
|
||||
"fieldUsernameCannotEditHint": "用戶名在創建後無法修改",
|
||||
"fieldUsernameLookupHint": "支持用戶名、電話號碼或郵箱地址",
|
||||
"fieldNicknameLengthLimit": "暱稱必須在 {} 和 {} 之間。",
|
||||
"fieldEmailAddressMustBeValid": "電子郵箱地址必須是一個電子郵箱地址。",
|
||||
"fieldFirstName": "名",
|
||||
"fieldLastName": "姓",
|
||||
"fieldBirthday": "生日",
|
||||
"fieldImageHint": "你可以點選這些個人頭像來編輯它們。",
|
||||
"fieldImageHint": "你可以點擊這些個人頭像來編輯它們。",
|
||||
"fieldDescription": "簡介",
|
||||
"forgotPassword": "忘記密碼",
|
||||
"loginPickFactor": "選擇方式驗證",
|
||||
@ -85,24 +85,24 @@
|
||||
"one": "{} 步驗證",
|
||||
"other": "{} 步驗證"
|
||||
},
|
||||
"loginEnterPassword": "驗證程式碼",
|
||||
"loginSuccess": "登入為 {}",
|
||||
"loginEnterPassword": "驗證代碼",
|
||||
"loginSuccess": "登錄為 {}",
|
||||
"authFactorPassword": "密碼",
|
||||
"authFactorEmail": "電郵一次性驗證碼",
|
||||
"accountIntroTitle": "喜歡您來!",
|
||||
"accountIntroSubtitle": "登陸以探索更廣大的世界。",
|
||||
"accountLogout": "退出登入",
|
||||
"accountLogoutSubtitle": "登出當前賬戶的登陸狀態。",
|
||||
"accountLogoutConfirmTitle": "您確定要退出登入嗎?",
|
||||
"accountLogout": "退出登錄",
|
||||
"accountLogoutSubtitle": "註銷當前賬戶的登陸狀態。",
|
||||
"accountLogoutConfirmTitle": "您確定要退出登錄嗎?",
|
||||
"accountLogoutConfirm": "您需要重新輸入賬號密碼,甚至可能需要多步驗證來再次登陸。",
|
||||
"accountPublishers": "你的釋出者",
|
||||
"accountPublishers": "你的發佈者",
|
||||
"accountPublishersSubtitle": "管理你的公共形象。",
|
||||
"accountProfileEdit": "編輯資料",
|
||||
"accountProfileEditSubtitle": "使你的 Solarpass 賬戶更像你。",
|
||||
"accountProfileEditApplied": "個人資料修改已被應用。",
|
||||
"publishersNew": "新發布者",
|
||||
"publisherNewSubtitle": "建立一個新的公共身份。",
|
||||
"publisherSyncWithAccount": "同步賬戶資訊",
|
||||
"publisherNewSubtitle": "創建一個新的公共身份。",
|
||||
"publisherSyncWithAccount": "同步賬戶信息",
|
||||
"publisherTotalUpvote": "總頂數",
|
||||
"publisherTotalDownvote": "總踩數",
|
||||
"publisherSocialPoint": "社會信用點",
|
||||
@ -115,34 +115,37 @@
|
||||
"publisherAffiliatedBy": "隸屬於 {}",
|
||||
"publisherRunBy": "由 {} 管理",
|
||||
"fieldPublisherBelongToRealm": "所屬領域",
|
||||
"fieldPublisherBelongToRealmUnset": "未設定釋出者所屬領域",
|
||||
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
||||
"writePostTypeStory": "發動態",
|
||||
"writePostTypeArticle": "寫文章",
|
||||
"fieldPostPublisher": "帖子釋出者",
|
||||
"fieldPostPublisher": "帖子發佈者",
|
||||
"fieldPostContent": "發生什麼事了?!",
|
||||
"fieldPostTitle": "標題",
|
||||
"fieldPostDescription": "描述",
|
||||
"fieldPostTags": "標籤",
|
||||
"postPublish": "釋出",
|
||||
"postPublishedAt": "釋出於",
|
||||
"postPublishedUntil": "取消釋出於",
|
||||
"fieldPostCategories": "分類",
|
||||
"fieldPostAlias": "別名",
|
||||
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
|
||||
"postPublish": "發佈",
|
||||
"postPublishedAt": "發佈於",
|
||||
"postPublishedUntil": "取消發佈於",
|
||||
"postVisibility": "可見性",
|
||||
"postVisibilityDescription": "帖子可見性決定了誰能檢視該篇帖子。",
|
||||
"postVisibilityDescription": "帖子可見性決定了誰能查看該篇帖子。",
|
||||
"postVisibilityAll": "所有人可見",
|
||||
"postVisibilityFriends": "僅限好友可見",
|
||||
"postVisibilitySelected": "選定的使用者可見",
|
||||
"postVisibilityFiltered": "選定使用者不可見",
|
||||
"postVisibilitySelected": "選定的用戶可見",
|
||||
"postVisibilityFiltered": "選定用戶不可見",
|
||||
"postVisibilityNone": "僅自己可見",
|
||||
"postVisibleUsers": "可見的使用者",
|
||||
"postInvisibleUsers": "不可見的使用者",
|
||||
"postVisibleUsers": "可見的用戶",
|
||||
"postInvisibleUsers": "不可見的用戶",
|
||||
"postSelectedUsers": {
|
||||
"zero": "未選擇使用者",
|
||||
"one": "選擇了 {} 個使用者",
|
||||
"other": "選擇了 {} 個使用者"
|
||||
"zero": "未選擇用戶",
|
||||
"one": "選擇了 {} 個用戶",
|
||||
"other": "選擇了 {} 個用戶"
|
||||
},
|
||||
"postEditingNotice": "你正在修改由 {} 釋出的帖子。",
|
||||
"postReplyingNotice": "你正在回覆由 {} 釋出的帖子。",
|
||||
"postRepostingNotice": "你正在轉發由 {} 釋出的帖子。",
|
||||
"postEditingNotice": "你正在修改由 {} 發佈的帖子。",
|
||||
"postReplyingNotice": "你正在回覆由 {} 發佈的帖子。",
|
||||
"postRepostingNotice": "你正在轉發由 {} 發佈的帖子。",
|
||||
"postReact": "反應",
|
||||
"postPosted": "帖子已經發表。",
|
||||
"postReactions": "帖子的反應",
|
||||
@ -161,7 +164,7 @@
|
||||
"one": "{} 點社會信用點變更",
|
||||
"other": "{} 點社會信用點變更"
|
||||
},
|
||||
"postReactCompleted": "反應已被新增。",
|
||||
"postReactCompleted": "反應已被添加。",
|
||||
"postReactUncompleted": "反應已被移除。",
|
||||
"postComments": {
|
||||
"zero": "評論",
|
||||
@ -175,70 +178,76 @@
|
||||
},
|
||||
"settingsAppearance": "外觀",
|
||||
"settingsBackgroundImage": "背景圖片",
|
||||
"settingsBackgroundImageDescription": "設定應用全域性生效的的背景圖片。",
|
||||
"settingsBackgroundImageDescription": "設置應用全局生效的的背景圖片。",
|
||||
"settingsBackgroundImageClear": "清除現存背景圖",
|
||||
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
|
||||
"settingsThemeMaterial3": "使用 Material You 設計正規化",
|
||||
"settingsThemeMaterial3Description": "將應用主題設定為 Material 3 設計正規化的主題。",
|
||||
"settingsNetwork": "網路",
|
||||
"settingsNetworkServer": "HyperNet 伺服器",
|
||||
"settingsNetworkServerDescription": "設定 HyperNet 伺服器地址,選擇我們提供的,或者自己搭建。",
|
||||
"settingsNetworkServerReset": "重設為官方伺服器",
|
||||
"settingsNetworkServerResetDescription": "重設為 Solar Network 的伺服器地址。",
|
||||
"settingsNetworkServerPreset": "預設的 HyperNet 伺服器",
|
||||
"settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 伺服器地址。",
|
||||
"settingsNetworkServerSaved": "伺服器地址已儲存。",
|
||||
"settingsPerformance": "效能",
|
||||
"settingsThemeMaterial3": "使用 Material You 設計範式",
|
||||
"settingsThemeMaterial3Description": "將應用主題設置為 Material 3 設計範式的主題。",
|
||||
"settingsAppBarTransparent": "透明頂欄",
|
||||
"settingsAppBarTransparentDescription": "為頂欄啟用透明效果。",
|
||||
"settingsColorScheme": "主題色",
|
||||
"settingsColorSchemeDescription": "設置應用主題色。",
|
||||
"settingsColorSeed": "預設色彩主題",
|
||||
"settingsColorSeedDescription": "選擇一個預設色彩主題。",
|
||||
"settingsNetwork": "網絡",
|
||||
"settingsNetworkServer": "HyperNet 服務器",
|
||||
"settingsNetworkServerDescription": "設置 HyperNet 服務器地址,選擇我們提供的,或者自己搭建。",
|
||||
"settingsNetworkServerReset": "重設為官方服務器",
|
||||
"settingsNetworkServerResetDescription": "重設為 Solar Network 的服務器地址。",
|
||||
"settingsNetworkServerPreset": "預設的 HyperNet 服務器",
|
||||
"settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 服務器地址。",
|
||||
"settingsNetworkServerSaved": "服務器地址已保存。",
|
||||
"settingsPerformance": "性能",
|
||||
"settingsImageQuality": "圖片預覽質量",
|
||||
"settingsImageQualityDescription": "設定圖片預覽質量,會影響圖片解碼速度。",
|
||||
"settingsImageQualityDescription": "設置圖片預覽質量,會影響圖片解碼速度。",
|
||||
"settingsImageQualityLowest": "極低",
|
||||
"settingsImageQualityLow": "低",
|
||||
"settingsImageQualityMedium": "中",
|
||||
"settingsImageQualityHigh": "高",
|
||||
"settingsMisc": "雜項",
|
||||
"settingsMiscAbout": "關於",
|
||||
"settingsMiscAboutDescription": "檢視 Solian 的版本資訊。",
|
||||
"settingsMiscAboutDescription": "查看 Solian 的版本信息。",
|
||||
"sensitiveContent": "敏感內容",
|
||||
"sensitiveContentCollapsed": "敏感內容已摺疊。",
|
||||
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人檢視。",
|
||||
"sensitiveContentDescription": "此內容已被標記,可能不適合所有人查看。",
|
||||
"sensitiveContentReveal": "顯示內容",
|
||||
"serverConnecting": "正在連線伺服器…",
|
||||
"serverDisconnected": "已與伺服器斷開連線",
|
||||
"serverConnecting": "正在連接服務器…",
|
||||
"serverDisconnected": "已與服務器斷開連接",
|
||||
"fieldChatAlias": "頻道別名",
|
||||
"fieldChatAliasHint": "全站範圍內唯一的頻道別名,用於在 URL 中表示該頻道,留空則自動生成。應遵循 URL-Safe 的原則。",
|
||||
"fieldChatName": "名稱",
|
||||
"fieldChatDescription": "描述",
|
||||
"fieldChatBelongToRealm": "所屬領域",
|
||||
"fieldChatBelongToRealmUnset": "未設定頻道所屬領域",
|
||||
"fieldChatBelongToRealmUnset": "未設置頻道所屬領域",
|
||||
"channelEditingNotice": "您正在編輯頻道 {}",
|
||||
"channelDeleted": "聊天頻道 {} 已被刪除",
|
||||
"channelDelete": "刪除聊天頻道 {}",
|
||||
"channelDeleteDescription": "你確定要刪除這個聊天頻道嗎?該操作不可撤銷,其頻道內的所有訊息將被永久刪除。",
|
||||
"channelDeleteDescription": "你確定要刪除這個聊天頻道嗎?該操作不可撤銷,其頻道內的所有消息將被永久刪除。",
|
||||
"channelDetailPersonalRegion": "個人區域",
|
||||
"channelDetailMemberRegion": "成員管理",
|
||||
"channelMemberManage": "管理成員",
|
||||
"channelMemberManageDescription": "管理頻道內現有成員。",
|
||||
"channelMemberAdd": "新增成員",
|
||||
"channelMemberAddDescription": "給當前頻道新增新成員。",
|
||||
"channelMemberAdded": "頻道成員已新增。",
|
||||
"channelMemberAdd": "添加成員",
|
||||
"channelMemberAddDescription": "給當前頻道添加新成員。",
|
||||
"channelMemberAdded": "頻道成員已添加。",
|
||||
"fieldMemberRelatedName": "成員名 / 賬戶 ID",
|
||||
"channelDetailAdminRegion": "管理區域",
|
||||
"channelEditProfile": "更改頻道身份",
|
||||
"channelEdit": "編輯頻道",
|
||||
"channelEditDescription": "更改頻道基本資訊,元資料等。",
|
||||
"channelEditDescription": "更改頻道基本信息,元數據等。",
|
||||
"channelProfileEdit": "編輯頻道身份",
|
||||
"channelActionDelete": "刪除頻道",
|
||||
"channelActionDeleteDescription": "刪除整個頻道,並且刪除頻道里的所有資訊。",
|
||||
"channelActionDeleteDescription": "刪除整個頻道,並且刪除頻道里的所有信息。",
|
||||
"channelLeave": "退出頻道 {}",
|
||||
"channelLeaveDescription": "退出該頻道,但是你頻道內的資訊不會被移除。",
|
||||
"channelLeaveDescription": "退出該頻道,但是你頻道內的信息不會被移除。",
|
||||
"channelActionLeave": "退出頻道",
|
||||
"channelActionLeaveDescription": "刪除你在這個頻道的身份。",
|
||||
"channelNotifyLevel": "通知級別",
|
||||
"channelNotifyLevelDescription": "有您決定要接受多少來自這個頻道的訊息。",
|
||||
"channelNotifyLevelDescription": "有您決定要接受多少來自這個頻道的消息。",
|
||||
"channelNotifyLevelAll": "全部通知",
|
||||
"channelNotifyLevelMentioned": "僅提及",
|
||||
"channelNotifyLevelNone": "全部靜音",
|
||||
"channelNotifyLevelApplied": "已經儲存並應用頻道通知級別配置。",
|
||||
"channelNotifyLevelApplied": "已經保存並應用頻道通知級別配置。",
|
||||
"fieldChannelProfileNick": "頻道內顯示名",
|
||||
"fieldChannelProfileNickHint": "在頻道內顯示的暱稱,留空則使用賬號顯示名。",
|
||||
"fieldRealmAlias": "領域別名",
|
||||
@ -248,38 +257,71 @@
|
||||
"realmEditingNotice": "您正在編輯領域 {}",
|
||||
"realmDeleted": "領域 {} 已被刪除",
|
||||
"realmDelete": "刪除領域 {}",
|
||||
"realmDeleteDescription": "你確定要刪除這個領域嗎?該操作不可撤銷,其隸屬於該領域的所有資源(帖子、聊天頻道、釋出者、製品等)都將被永久刪除。三思而後行!",
|
||||
"realmDeleteDescription": "你確定要刪除這個領域嗎?該操作不可撤銷,其隸屬於該領域的所有資源(帖子、聊天頻道、發佈者、製品等)都將被永久刪除。三思而後行!",
|
||||
"realmActionDelete": "刪除領域",
|
||||
"realmActionDeleteDescription": "刪除整個領域及其附屬的資源。",
|
||||
"realmEdit": "編輯領域",
|
||||
"realmEditDescription": "更改領域基本資訊,元資料等。",
|
||||
"realmMemberAdd": "新增成員",
|
||||
"realmMemberAddDescription": "給當前領域新增新成員。",
|
||||
"realmMemberAdded": "領域成員已新增。",
|
||||
"fieldChatMessage": "在 {} 中發訊息",
|
||||
"fieldChatMessageDirect": "給 {} 發訊息",
|
||||
"eventResourceTag": "訊息 {}",
|
||||
"messageDelete": "刪除訊息 {}",
|
||||
"messageDeleteDescription": "你確定要刪除這個訊息嗎?該操作不可撤銷。同時您將留下一條刪除訊息的記錄。",
|
||||
"messageDeleted": "訊息 {} 已被刪除",
|
||||
"messageEdited": "訊息 {} 已被編輯",
|
||||
"realmEditDescription": "更改領域基本信息,元數據等。",
|
||||
"realmMemberAdd": "添加成員",
|
||||
"realmMemberAddDescription": "給當前領域添加新成員。",
|
||||
"realmMemberAdded": "領域成員已添加。",
|
||||
"fieldChatMessage": "在 {} 中發消息",
|
||||
"fieldChatMessageDirect": "給 {} 發消息",
|
||||
"eventResourceTag": "消息 {}",
|
||||
"messageDelete": "刪除消息 {}",
|
||||
"messageDeleteDescription": "你確定要刪除這個消息嗎?該操作不可撤銷。同時您將留下一條刪除消息的記錄。",
|
||||
"messageDeleted": "消息 {} 已被刪除",
|
||||
"messageEdited": "消息 {} 已被編輯",
|
||||
"messageEditedHint": "已編輯",
|
||||
"messageUnsupported": "不支援的訊息 {}",
|
||||
"messageUnsupported": "不支持的消息 {}",
|
||||
"messageFileHint": {
|
||||
"zero": "沒有附件",
|
||||
"one": "{} 個附件",
|
||||
"other": "{} 個附件"
|
||||
},
|
||||
"addAttachmentFromAlbum": "從相簿中新增附件",
|
||||
"addAttachmentFromClipboard": "貼上附件",
|
||||
"messageTyping": {
|
||||
"one": "{} 正在輸入",
|
||||
"other": "{} 正在輸入"
|
||||
},
|
||||
"fieldAttachmentRandomId": "訪問 ID",
|
||||
"fieldAttachmentAlt": "概述文字",
|
||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||
"addAttachmentFromClipboard": "粘貼附件",
|
||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||
"addAttachmentFromCameraVideo": "拍攝影片",
|
||||
"attachmentPastedImage": "貼上的圖片",
|
||||
"attachmentInsertLink": "插入連線",
|
||||
"attachmentSetAsPostThumbnail": "設定為帖子縮圖",
|
||||
"attachmentUnsetAsPostThumbnail": "取消設定為帖子縮圖",
|
||||
"attachmentSetThumbnail": "設定縮圖",
|
||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||
"addAttachmentFromRandomId": "通過訪問 ID 鏈接",
|
||||
"attachmentPastedImage": "粘貼的圖片",
|
||||
"attachmentInsertLink": "插入連接",
|
||||
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
|
||||
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
|
||||
"attachmentCompressVideo": "重新編碼視頻",
|
||||
"attachmentSetThumbnail": "設置縮略圖",
|
||||
"attachmentSetAlt": "設置概述文字",
|
||||
"attachmentCopyRandomId": "複製訪問 ID",
|
||||
"attachmentUpload": "上傳",
|
||||
"attachmentInputDialog": "上傳附件",
|
||||
"attachmentInputUseRandomId": "使用訪問 ID",
|
||||
"attachmentInputNew": "新上傳附件",
|
||||
"waitingForUpload": "等待上傳",
|
||||
"attachmentVideoCompressHint": "壓縮一份視頻的副本",
|
||||
"attachmentVideoCompressHintDescription": "你想上傳壓縮視頻 {} 的副本嗎?它將幫助你的觀眾快速預覽視頻,並且他們仍然可以觀看原始視頻。這將會在在你的設備上處理視頻,所以需要一些時間,所以請耐心等待。",
|
||||
"attachmentCompressQuality": "壓縮質量",
|
||||
"attachmentCompressQualityHighest": "最高",
|
||||
"attachmentCompressQualityDefault": "默認",
|
||||
"attachmentCompressQualityMedium": "中等",
|
||||
"attachmentCompressQualityLow": "低",
|
||||
"attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。",
|
||||
"attachmentUploaded": "已上傳",
|
||||
"attachmentPending": "未上傳",
|
||||
"attachmentCopyCompressed": "有壓縮副本",
|
||||
"attachmentGotBoosted": "有加速傳遞",
|
||||
"attachmentBoost": "加速包",
|
||||
"attachmentCreateBoost": "加速傳遞",
|
||||
"attachmentBoostHint": "加速傳遞允許您將附件上傳到更近的受眾或更快的內容網絡。該功能目前處於 Beta 階段。該功能限時免費,當有價格計劃更改時,您將會被通知。",
|
||||
"attachmentDestinationRegion": "目標節點",
|
||||
"attachmentDestinationRegionAPAC": "亞太地區",
|
||||
"attachmentDestinationRegionNGB": "中國 · 浙江 · 寧波",
|
||||
"attachmentDestinationRegionHKG": "香港",
|
||||
"notification": "通知",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "無未讀通知",
|
||||
@ -289,18 +331,18 @@
|
||||
"notificationUnread": "未讀",
|
||||
"notificationRead": "已讀",
|
||||
"notificationMarkAllRead": "已讀所有通知",
|
||||
"notificationMarkAllReadDescription": "您確定要將所有通知設定為已讀嗎?該操作不可撤銷。",
|
||||
"notificationMarkAllReadDescription": "您確定要將所有通知設置為已讀嗎?該操作不可撤銷。",
|
||||
"notificationMarkAllReadPrompt": {
|
||||
"zero": "已將 0 個通知標記為已讀。",
|
||||
"one": "已將 {} 個通知標記為已讀。",
|
||||
"other": "已將 {} 個通知標記為已讀。"
|
||||
},
|
||||
"notificationMarkOneReadPrompt": "已將通知 {} 標記為已讀。",
|
||||
"search": "搜尋",
|
||||
"search": "搜索",
|
||||
"postSearchResult": {
|
||||
"zero": "沒有搜尋到結果",
|
||||
"one": "搜尋到 {} 個結果",
|
||||
"other": "搜尋到 {} 個結果"
|
||||
"zero": "沒有搜索到結果",
|
||||
"one": "搜索到 {} 個結果",
|
||||
"other": "搜索到 {} 個結果"
|
||||
},
|
||||
"postSearchTook": "耗時 {}",
|
||||
"postDelete": "刪除帖子 {}",
|
||||
@ -312,26 +354,26 @@
|
||||
"callResume": "恢復",
|
||||
"callMicrophone": "麥克風",
|
||||
"callCamera": "攝像頭",
|
||||
"callMicrophoneDisabled": "麥克風已停用",
|
||||
"callMicrophoneDisabled": "麥克風已禁用",
|
||||
"callMicrophoneSelect": "選擇麥克風",
|
||||
"callCameraDisabled": "攝像頭已停用",
|
||||
"callCameraDisabled": "攝像頭已禁用",
|
||||
"callCameraSelect": "選擇攝像頭",
|
||||
"callDisconnected": "通話已斷開",
|
||||
"callEnded": "通話已結束",
|
||||
"callStatusConnected": "已連線",
|
||||
"callStatusDisconnected": "未連線",
|
||||
"callStatusConnecting": "正在連線",
|
||||
"callStatusConnected": "已連接",
|
||||
"callStatusDisconnected": "未連接",
|
||||
"callStatusConnecting": "正在連接",
|
||||
"callStatusReconnecting": "正在重連",
|
||||
"callDisconnect": "斷開連線",
|
||||
"callDisconnectDescription": "您確定要與通話斷開連線嗎?",
|
||||
"callDisconnect": "斷開連接",
|
||||
"callDisconnectDescription": "您確定要與通話斷開連接嗎?",
|
||||
"callMicrophoneOff": "關閉麥克風",
|
||||
"callMicrophoneOn": "開啟麥克風",
|
||||
"callMicrophoneOn": "打開麥克風",
|
||||
"callCameraOff": "關閉攝像頭",
|
||||
"callCameraOn": "開啟攝像頭",
|
||||
"callVideoFlip": "映象畫面",
|
||||
"callCameraOn": "打開攝像頭",
|
||||
"callVideoFlip": "鏡像畫面",
|
||||
"callSpeakerphoneToggle": "切換揚聲器",
|
||||
"callScreenOff": "關閉螢幕共享",
|
||||
"callScreenOn": "開啟螢幕共享",
|
||||
"callScreenOff": "關閉屏幕共享",
|
||||
"callScreenOn": "開啟屏幕共享",
|
||||
"callMessageEnded": "通話持續了 {}",
|
||||
"callMessageStarted": "通話開始了",
|
||||
"dailyCheckIn": "每日簽到",
|
||||
@ -367,28 +409,53 @@
|
||||
"dailyCheckNegativeHint5Description": "關鍵時刻斷網",
|
||||
"dailyCheckNegativeHint6": "出門",
|
||||
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
|
||||
"happyBirthday": "生日快樂,{}!",
|
||||
"friendNew": "新增好友",
|
||||
"celebrateBirthday": "生日快樂,{}!",
|
||||
"celebrateLunarNewYear": "春節快樂,{}!",
|
||||
"celebrateMidAutumn": "中秋節快樂,{}!",
|
||||
"celebrateDragonBoat": "端午節快樂,{}!",
|
||||
"celebrateMerryXmas": "聖誕快樂,{}!",
|
||||
"celebrateNewYear": "新年快樂,{}!",
|
||||
"celebrateValentineDay": "今天是情人節,{}!",
|
||||
"celebrateLaborDay": "今天是勞動節,{}。",
|
||||
"celebrateMotherDay": "今天是母親節,{}。",
|
||||
"celebrateChildrenDay": "今天是兒童節,{}!",
|
||||
"celebrateFatherDay": "今天是父親節,{}。",
|
||||
"celebrateHalloween": "快樂在聖誕節,{}!",
|
||||
"celebrateThanksgiving": "今天是感恩節,{}!",
|
||||
"pendingLunarNewYear": "{} 過春節",
|
||||
"pendingMidAutumn": "{} 過中秋節",
|
||||
"pendingDragonBoat": "{} 過端午節",
|
||||
"pendingBirthday": "{} 過生日",
|
||||
"pendingMerryXmas": "{} 過聖誕節",
|
||||
"pendingNewYear": "{} 跨年",
|
||||
"pendingValentineDay": "{} 過情人節",
|
||||
"pendingLaborDay": "{} 過勞動節",
|
||||
"pendingMotherDay": "{} 過母親節",
|
||||
"pendingChildrenDay": "{} 過兒童節",
|
||||
"pendingFatherDay": "{} 過父親節",
|
||||
"pendingHalloween": "{} 過聖誕節",
|
||||
"pendingThanksgiving": "{} 過感恩節",
|
||||
"friendNew": "添加好友",
|
||||
"friendRequests": "好友請求",
|
||||
"friendRequestsDescription": {
|
||||
"zero": "你沒有好友請求",
|
||||
"one": "你有 {} 個好友請求",
|
||||
"other": "你有 {} 個好友請求"
|
||||
},
|
||||
"friendBlocklist": "遮蔽列表",
|
||||
"friendBlocklist": "屏蔽列表",
|
||||
"friendBlocklistDescription": {
|
||||
"zero": "你沒有遮蔽任何人",
|
||||
"one": "你遮蔽了 {} 個使用者",
|
||||
"other": "你遮蔽了 {} 個使用者"
|
||||
"zero": "你沒有屏蔽任何人",
|
||||
"one": "你屏蔽了 {} 個用戶",
|
||||
"other": "你屏蔽了 {} 個用戶"
|
||||
},
|
||||
"friendStatusPending": "待處理",
|
||||
"friendStatusWaiting": "等待中",
|
||||
"friendStatusActive": "正活躍",
|
||||
"friendStatusBlocked": "已遮蔽",
|
||||
"friendRequestSent": "好友請求已傳送。",
|
||||
"friendStatusBlocked": "已屏蔽",
|
||||
"friendRequestSent": "好友請求已發送。",
|
||||
"fieldFriendRelatedName": "好友名 / 賬戶 ID",
|
||||
"friendBlock": "遮蔽",
|
||||
"friendUnblock": "解除遮蔽",
|
||||
"friendBlock": "屏蔽",
|
||||
"friendUnblock": "解除屏蔽",
|
||||
"friendDeleteAction": "遺忘",
|
||||
"friendDelete": "遺忘跟 {} 的關係",
|
||||
"friendDeleteDescription": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。",
|
||||
@ -401,23 +468,25 @@
|
||||
"accountJoinedAt": "加入於 {}",
|
||||
"accountBirthday": "出生於 {}",
|
||||
"accountBadge": "徽章",
|
||||
"accountCheckInNoRecords": "暫無運勢記錄",
|
||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"accountStatus": "狀態",
|
||||
"accountStatusOnline": "線上",
|
||||
"accountStatusOnline": "在線",
|
||||
"accountStatusOffline": "離線",
|
||||
"accountStatusLastSeen": "最後一次在 {} 上線",
|
||||
"accountStatusLastSeen": "最後一次上線於 {}",
|
||||
"postArticle": "Solar Network 上的文章",
|
||||
"postStory": "Solar Network 上的故事",
|
||||
"postLocalDraftRestored": "從本地恢復草稿",
|
||||
"articleWrittenAt": "發表於 {}",
|
||||
"articleEditedAt": "編輯於 {}",
|
||||
"attachmentSaved": "已儲存到相簿",
|
||||
"attachmentSavedDesktop": "已儲存到下載目錄",
|
||||
"openInAlbum": "在相簿中開啟",
|
||||
"attachmentSaved": "已保存到相冊",
|
||||
"attachmentSavedDesktop": "已保存到下載目錄",
|
||||
"openInAlbum": "在相冊中打開",
|
||||
"postAbuseReport": "檢舉帖子",
|
||||
"postAbuseReportDescription": "檢舉不符合我們使用者協議以及社群準則的帖子,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述該帖子如何違反我麼的相關規定。請勿填寫任何敏感資訊。我們將會在 24 小時內處理您的檢舉。",
|
||||
"postAbuseReportDescription": "檢舉不符合我們用戶協議以及社區準則的帖子,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述該帖子如何違反我麼的相關規定。請勿填寫任何敏感信息。我們將會在 24 小時內處理您的檢舉。",
|
||||
"abuseReport": "檢舉",
|
||||
"abuseReportDescription": "檢舉不符合我們使用者協議以及社群準則的任何資源,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述資源的位置(提供資源 ID 為佳)以及如何違反我麼的相關規定。請勿填寫任何敏感資訊。我們將會在 24 小時內處理您的檢舉。",
|
||||
"abuseReportDescription": "檢舉不符合我們用戶協議以及社區準則的任何資源,來幫助我們更好的維護 Solar Network 上的內容。請在下面描述資源的位置(提供資源 ID 為佳)以及如何違反我麼的相關規定。請勿填寫任何敏感信息。我們將會在 24 小時內處理您的檢舉。",
|
||||
"abuseReportAction": "提交檢舉",
|
||||
"abuseReportActionDescription": "檢舉不合規行為。",
|
||||
"abuseReportResource": "資源位置 / ID",
|
||||
@ -425,33 +494,56 @@
|
||||
"abuseReportSubmitted": "檢舉已提交,感謝你的貢獻。",
|
||||
"submit": "提交",
|
||||
"accountDeletion": "刪除帳戶",
|
||||
"accountDeletionDescription": "你確定要刪除這個帳戶嗎?該操作不可撤銷,其隸屬於該帳戶的所有資源(帖子、聊天頻道、釋出者、製品等)都將被永久刪除。三思而後行!",
|
||||
"accountDeletionDescription": "你確定要刪除這個帳戶嗎?該操作不可撤銷,其隸屬於該帳戶的所有資源(帖子、聊天頻道、發佈者、製品等)都將被永久刪除。三思而後行!",
|
||||
"accountDeletionActionDescription": "刪除你的 Solarpass 帳戶。",
|
||||
"accountDeletionSubmitted": "帳戶刪除申請已發出,你可以檢查你的收件箱並根據郵件內的指示完成刪除操作。",
|
||||
"channelNewChannel": "新建頻道",
|
||||
"channelNewDirectMessage": "發起私信",
|
||||
"channelDirectMessageDescription": "與 {} 的私聊",
|
||||
"fieldCannotBeEmpty": "此欄位不能為空。",
|
||||
"fieldCannotBeEmpty": "此字段不能為空。",
|
||||
"termAcceptLink": "瀏覽條款",
|
||||
"termAcceptNextWithAgree": "點選 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
|
||||
"termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
|
||||
"unauthorized": "未登陸",
|
||||
"unauthorizedDescription": "登陸以探索整個 Solar Network。",
|
||||
"serviceStatus": "服務狀態",
|
||||
"termRelated": "相關條款",
|
||||
"appDetails": "應用程式詳情",
|
||||
"appDetails": "應用程序詳情",
|
||||
"postRecommendation": "推薦帖子",
|
||||
"publisherBlockHint": "遮蔽 {}",
|
||||
"publisherBlockHintDescription": "你正要遮蔽此釋出者的運營者,該操作也將遮蔽由同一使用者運營的釋出者。",
|
||||
"userUnblocked": "已解除遮蔽使用者 {}",
|
||||
"userBlocked": "已遮蔽使用者 {}",
|
||||
"publisherBlockHint": "屏蔽 {}",
|
||||
"publisherBlockHintDescription": "你正要屏蔽此發佈者的運營者,該操作也將屏蔽由同一用戶運營的發佈者。",
|
||||
"userUnblocked": "已解除屏蔽用戶 {}",
|
||||
"userBlocked": "已屏蔽用戶 {}",
|
||||
"postSharingViaPicture": "正在生成帖子截圖,請稍等片刻……",
|
||||
"postImageShareReadMore": "掃描右側 QRCode 檢視全文",
|
||||
"postImageShareReadMore": "掃描右側 QRCode 查看全文",
|
||||
"postImageShareAds": "來 Solar Network 探索更多有趣帖子",
|
||||
"postShare": "分享",
|
||||
"postShareImage": "分享帖圖",
|
||||
"appInitializing": "正在初始化",
|
||||
"poweredBy": "由 {} 提供支援",
|
||||
"poweredBy": "由 {} 提供支持",
|
||||
"shareIntent": "分享",
|
||||
"shareIntentDescription": "您想對您分享的內容做些什麼?",
|
||||
"shareIntentPostStory": "釋出動態"
|
||||
"shareIntentPostStory": "發佈動態",
|
||||
"updateAvailable": "檢測到更新可用",
|
||||
"updateOngoing": "正在更新,請稍後……",
|
||||
"custom": "自定義",
|
||||
"colorSchemeIndigo": "靛藍",
|
||||
"colorSchemeBlue": "藍色",
|
||||
"colorSchemeGreen": "綠色",
|
||||
"colorSchemeYellow": "黃色",
|
||||
"colorSchemeOrange": "橙色",
|
||||
"colorSchemeRed": "紅色",
|
||||
"colorSchemeWhite": "白色",
|
||||
"colorSchemeBlack": "黑色",
|
||||
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
|
||||
"postCategoryTechnology": "技術",
|
||||
"postCategoryGaming": "遊戲",
|
||||
"postCategoryLife": "生活",
|
||||
"postCategoryArts": "藝術",
|
||||
"postCategorySports": "體育",
|
||||
"postCategoryMusic": "音樂",
|
||||
"postCategoryNews": "新聞",
|
||||
"postCategoryKnowledge": "知識",
|
||||
"postCategoryLiterature": "文學",
|
||||
"postCategoryFunny": "搞笑",
|
||||
"postCategoryUncategorized": "未分類"
|
||||
}
|
||||
|
@ -173,7 +173,7 @@ PODS:
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- Kingfisher (8.1.3)
|
||||
- livekit_client (2.3.2):
|
||||
- livekit_client (2.3.4):
|
||||
- Flutter
|
||||
- flutter_webrtc
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
@ -217,6 +217,8 @@ PODS:
|
||||
- SwiftyGif (5.4.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_compress (0.3.0):
|
||||
- Flutter
|
||||
- volume_controller (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
@ -259,6 +261,7 @@ DEPENDENCIES:
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_compress (from `.symlinks/plugins/video_compress/ios`)
|
||||
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
@ -348,6 +351,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_compress:
|
||||
:path: ".symlinks/plugins/video_compress/ios"
|
||||
volume_controller:
|
||||
:path: ".symlinks/plugins/volume_controller/ios"
|
||||
wakelock_plus:
|
||||
@ -375,7 +380,7 @@ SPEC CHECKSUMS:
|
||||
FirebaseMessaging: f8a160d99c2c2e5babbbcc90c4a3e15db036aee2
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
|
||||
flutter_native_splash: e8a1e01082d97a8099d973f919f57904c925008a
|
||||
flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a
|
||||
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
|
||||
flutter_webrtc: 1a53bd24f97bcfeff512f13699e721897f261563
|
||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||
@ -386,7 +391,7 @@ SPEC CHECKSUMS:
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
|
||||
Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
|
||||
livekit_client: 6108dad8b77db3142bafd4c630f471d0a54335cd
|
||||
livekit_client: 4eaa7a2968fc7e7c57888f43d90394547cc8d9e9
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
@ -405,6 +410,7 @@ SPEC CHECKSUMS:
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
|
||||
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
|
||||
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
|
||||
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
||||
|
@ -80,7 +80,11 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
let metadataCopy = metadata as? [String: String] ?? [:]
|
||||
let avatarUrl = getAttachmentUrl(for: avatarIdentifier)
|
||||
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, completionHandler: { result in
|
||||
|
||||
let targetSize = 640
|
||||
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, options: [.processor(scaleProcessor)], completionHandler: { result in
|
||||
var image: Data?
|
||||
switch result {
|
||||
case .success(let value):
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
@ -11,6 +12,7 @@ import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/websocket.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatMessageController extends ChangeNotifier {
|
||||
@ -36,8 +38,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
|
||||
int? messageTotal;
|
||||
|
||||
bool get isAllLoaded =>
|
||||
messageTotal != null && messages.length >= messageTotal!;
|
||||
bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!;
|
||||
|
||||
String? _boxKey;
|
||||
SnChannel? channel;
|
||||
@ -50,8 +51,10 @@ class ChatMessageController extends ChangeNotifier {
|
||||
/// Stored as a list of nonce to provide the loading state
|
||||
final List<String> unconfirmedMessages = List.empty(growable: true);
|
||||
|
||||
Box<SnChatMessage>? get _box =>
|
||||
(_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
|
||||
Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
|
||||
|
||||
final List<SnChannelMember> typingMembers = List.empty(growable: true);
|
||||
final Map<int, Timer> typingInactiveTimer = {};
|
||||
|
||||
Future<void> initialize(SnChannel chan) async {
|
||||
channel = chan;
|
||||
@ -78,22 +81,16 @@ class ChatMessageController extends ChangeNotifier {
|
||||
if (event.payload?['channel_id'] != channel?.id) break;
|
||||
final member = SnChannelMember.fromJson(event.payload!['member']);
|
||||
if (member.id == profile?.id) break;
|
||||
// TODO impl typing users
|
||||
// if (!_typingUsers.any((x) => x.id == member.id)) {
|
||||
// setState(() {
|
||||
// _typingUsers.add(member);
|
||||
// });
|
||||
// }
|
||||
// _typingInactiveTimer[member.id]?.cancel();
|
||||
// _typingInactiveTimer[member.id] = Timer(
|
||||
// const Duration(seconds: 3),
|
||||
// () {
|
||||
// setState(() {
|
||||
// _typingUsers.removeWhere((x) => x.id == member.id);
|
||||
// _typingInactiveTimer.remove(member.id);
|
||||
// });
|
||||
// },
|
||||
// );
|
||||
if (!typingMembers.any((x) => x.id == member.id)) {
|
||||
typingMembers.add(member);
|
||||
notifyListeners();
|
||||
}
|
||||
typingInactiveTimer[member.id]?.cancel();
|
||||
typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () {
|
||||
typingMembers.removeWhere((x) => x.id == member.id);
|
||||
typingInactiveTimer.remove(member.id);
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -101,6 +98,35 @@ class ChatMessageController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Timer? _typingNotifyTimer;
|
||||
bool _typingStatus = false;
|
||||
|
||||
Future<void> _sendTypingStatusPackage() async {
|
||||
_ws.conn?.sink.add(jsonEncode(
|
||||
WebSocketPackage(
|
||||
method: 'status.typing',
|
||||
endpoint: 'im',
|
||||
payload: {
|
||||
'channel_id': channel!.id,
|
||||
},
|
||||
).toJson(),
|
||||
));
|
||||
}
|
||||
|
||||
void pingTypingStatus() {
|
||||
if (!_typingStatus) {
|
||||
_sendTypingStatusPackage();
|
||||
_typingStatus = true;
|
||||
}
|
||||
|
||||
if (_typingNotifyTimer == null || !_typingNotifyTimer!.isActive) {
|
||||
_typingNotifyTimer?.cancel();
|
||||
_typingNotifyTimer = Timer(const Duration(milliseconds: 1850), () {
|
||||
_typingStatus = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
|
||||
if (_box == null) return;
|
||||
await _box!.putAll({
|
||||
@ -167,8 +193,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
switch (message.type) {
|
||||
case 'messages.edit':
|
||||
if (message.relatedEventId != null) {
|
||||
final idx =
|
||||
messages.indexWhere((x) => x.id == message.relatedEventId);
|
||||
final idx = messages.indexWhere((x) => x.id == message.relatedEventId);
|
||||
if (idx != -1) {
|
||||
final newBody = message.body;
|
||||
newBody.remove('related_event');
|
||||
@ -207,8 +232,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
'algorithm': 'plain',
|
||||
if (quoteId != null) 'quote_event': quoteId,
|
||||
if (relatedId != null) 'related_event': relatedId,
|
||||
if (attachments != null && attachments.isNotEmpty)
|
||||
'attachments': attachments,
|
||||
if (attachments != null && attachments.isNotEmpty) 'attachments': attachments,
|
||||
};
|
||||
|
||||
// Mock the message locally
|
||||
@ -305,8 +329,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
|
||||
if (out == null) {
|
||||
try {
|
||||
final resp = await _sn.client
|
||||
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
|
||||
final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
|
||||
out = SnChatMessage.fromJson(resp.data);
|
||||
_saveMessageToLocal([out]);
|
||||
} catch (_) {
|
||||
@ -341,9 +364,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
bool forceRemote = false,
|
||||
}) async {
|
||||
late List<SnChatMessage> out;
|
||||
if (_box != null &&
|
||||
(_box!.length >= take + offset || forceLocal) &&
|
||||
!forceRemote) {
|
||||
if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
|
||||
out = _box!.keys
|
||||
.toList()
|
||||
.cast<int>()
|
||||
@ -386,8 +407,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
quoteEvent: quoteEvent,
|
||||
attachments: attachments
|
||||
.where(
|
||||
(ele) =>
|
||||
out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
(ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@ -395,10 +415,7 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Preload sender accounts
|
||||
final accountId = out
|
||||
.where((ele) => ele.sender.accountId >= 0)
|
||||
.map((ele) => ele.sender.accountId)
|
||||
.toSet();
|
||||
final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet();
|
||||
await _ud.listAccount(accountId);
|
||||
|
||||
return out;
|
||||
|
@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
@ -14,17 +19,11 @@ import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
enum PostWriteMediaType {
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
}
|
||||
import 'package:video_compress/video_compress.dart';
|
||||
|
||||
class PostWriteMedia {
|
||||
late String name;
|
||||
late PostWriteMediaType type;
|
||||
late SnMediaType type;
|
||||
final SnAttachment? attachment;
|
||||
final XFile? file;
|
||||
final Uint8List? raw;
|
||||
@ -36,16 +35,16 @@ class PostWriteMedia {
|
||||
|
||||
switch (attachment?.mimetype.split('/').firstOrNull) {
|
||||
case 'image':
|
||||
type = PostWriteMediaType.image;
|
||||
type = SnMediaType.image;
|
||||
break;
|
||||
case 'video':
|
||||
type = PostWriteMediaType.video;
|
||||
type = SnMediaType.video;
|
||||
break;
|
||||
case 'audio':
|
||||
type = PostWriteMediaType.audio;
|
||||
type = SnMediaType.audio;
|
||||
break;
|
||||
default:
|
||||
type = PostWriteMediaType.file;
|
||||
type = SnMediaType.file;
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,16 +56,16 @@ class PostWriteMedia {
|
||||
|
||||
switch (mimetype?.split('/').firstOrNull) {
|
||||
case 'image':
|
||||
type = PostWriteMediaType.image;
|
||||
type = SnMediaType.image;
|
||||
break;
|
||||
case 'video':
|
||||
type = PostWriteMediaType.video;
|
||||
type = SnMediaType.video;
|
||||
break;
|
||||
case 'audio':
|
||||
type = PostWriteMediaType.audio;
|
||||
type = SnMediaType.audio;
|
||||
break;
|
||||
default:
|
||||
type = PostWriteMediaType.file;
|
||||
type = SnMediaType.file;
|
||||
}
|
||||
}
|
||||
|
||||
@ -105,7 +104,7 @@ class PostWriteMedia {
|
||||
if (attachment != null) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
||||
if (width != null && height != null) {
|
||||
if (width != null && height != null && !kIsWeb) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
width: width,
|
||||
@ -156,8 +155,18 @@ class PostWriteController extends ChangeNotifier {
|
||||
final TextEditingController aliasController = TextEditingController();
|
||||
|
||||
PostWriteController() {
|
||||
titleController.addListener(() => notifyListeners());
|
||||
descriptionController.addListener(() => notifyListeners());
|
||||
titleController.addListener(() {
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
});
|
||||
descriptionController.addListener(() {
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
});
|
||||
contentController.addListener(() {
|
||||
_temporaryPlanSave();
|
||||
});
|
||||
_temporaryLoad();
|
||||
}
|
||||
|
||||
String mode = kTitleMap.keys.first;
|
||||
@ -178,6 +187,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
List<int> visibleUsers = List.empty();
|
||||
List<int> invisibleUsers = List.empty();
|
||||
List<String> tags = List.empty();
|
||||
List<String> categories = List.empty();
|
||||
PostWriteMedia? thumbnail;
|
||||
List<PostWriteMedia> attachments = List.empty(growable: true);
|
||||
DateTime? publishedAt, publishedUntil;
|
||||
@ -203,10 +213,11 @@ class PostWriteController extends ChangeNotifier {
|
||||
aliasController.text = post.alias ?? '';
|
||||
publishedAt = post.publishedAt;
|
||||
publishedUntil = post.publishedUntil;
|
||||
visibleUsers = List.from(post.visibleUsersList ?? []);
|
||||
invisibleUsers = List.from(post.invisibleUsersList ?? []);
|
||||
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
|
||||
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
|
||||
visibility = post.visibility;
|
||||
tags = List.from(post.tags.map((ele) => ele.alias));
|
||||
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
|
||||
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
|
||||
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
|
||||
|
||||
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
|
||||
@ -234,7 +245,8 @@ class PostWriteController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media) async {
|
||||
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
|
||||
{bool isCompressed = false}) async {
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
final place = await attach.chunkedUploadInitialize(
|
||||
@ -242,22 +254,140 @@ class PostWriteController extends ChangeNotifier {
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
var item = await attach.chunkedUploadParts(
|
||||
media.toFile()!,
|
||||
place.$1,
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
progress = progress;
|
||||
analyzeNow: media.type == SnMediaType.image,
|
||||
onProgress: (value) {
|
||||
progress = value;
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
|
||||
try {
|
||||
final compressedAttachment = await _tryCompressVideoCopy(context, media);
|
||||
if (compressedAttachment != null) {
|
||||
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
|
||||
if (media.type != SnMediaType.video) return null;
|
||||
if (media.file == null) return null;
|
||||
if (VideoCompress.isCompressing) return null;
|
||||
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'attachmentVideoCompressHint'.tr(),
|
||||
'attachmentVideoCompressHintDescription'.tr(args: [media.file!.name]),
|
||||
);
|
||||
if (!confirm) return null;
|
||||
|
||||
progress = null;
|
||||
notifyListeners();
|
||||
|
||||
final mediaInfo = await VideoCompress.compressVideo(
|
||||
media.file!.path,
|
||||
quality: VideoQuality.LowQuality,
|
||||
frameRate: 30,
|
||||
deleteOrigin: false,
|
||||
);
|
||||
if (mediaInfo == null) return null;
|
||||
if (!context.mounted) return null;
|
||||
|
||||
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
|
||||
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
|
||||
|
||||
return compressedAttachment;
|
||||
}
|
||||
|
||||
static const kTemporaryStorageKey = 'int_draft_post';
|
||||
|
||||
Timer? _temporarySaveTimer;
|
||||
|
||||
void _temporaryPlanSave() {
|
||||
_temporarySaveTimer?.cancel();
|
||||
_temporarySaveTimer = Timer(const Duration(seconds: 1), () {
|
||||
_temporarySave();
|
||||
log("[PostWriter] Temporary save saved.");
|
||||
});
|
||||
}
|
||||
|
||||
void _temporarySave() {
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
if (titleController.text.isEmpty &&
|
||||
descriptionController.text.isEmpty &&
|
||||
contentController.text.isEmpty &&
|
||||
thumbnail == null &&
|
||||
attachments.isEmpty) {
|
||||
prefs.remove(kTemporaryStorageKey);
|
||||
return;
|
||||
}
|
||||
|
||||
prefs.setString(
|
||||
kTemporaryStorageKey,
|
||||
jsonEncode({
|
||||
'publisher': publisher,
|
||||
'content': contentController.text,
|
||||
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
||||
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
||||
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
|
||||
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
|
||||
'attachments':
|
||||
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
|
||||
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
|
||||
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
|
||||
'visibility': visibility,
|
||||
'visible_users_list': visibleUsers,
|
||||
'invisible_users_list': invisibleUsers,
|
||||
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
|
||||
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bool temporaryRestored = false;
|
||||
|
||||
void _temporaryLoad() {
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
final raw = prefs.getString(kTemporaryStorageKey);
|
||||
if (raw == null) return;
|
||||
final data = jsonDecode(raw);
|
||||
contentController.text = data['content'];
|
||||
aliasController.text = data['alias'] ?? '';
|
||||
titleController.text = data['title'] ?? '';
|
||||
descriptionController.text = data['description'] ?? '';
|
||||
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
|
||||
attachments
|
||||
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
|
||||
tags = List.from(data['tags'].map((ele) => ele['alias']));
|
||||
categories = List.from(data['categories'].map((ele) => ele['alias']));
|
||||
visibility = data['visibility'];
|
||||
visibleUsers = List.from(data['visible_users_list'] ?? []);
|
||||
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
|
||||
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
|
||||
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
|
||||
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
|
||||
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
|
||||
temporaryRestored = true;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> uploadSingleAttachment(BuildContext context, int idx) async {
|
||||
if (isBusy) return;
|
||||
|
||||
@ -299,20 +429,31 @@ class PostWriteController extends ChangeNotifier {
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
var item = await attach.chunkedUploadParts(
|
||||
media.toFile()!,
|
||||
place.$1,
|
||||
place.$2,
|
||||
onProgress: (progress) {
|
||||
onProgress: (value) {
|
||||
// Calculate overall progress for attachments
|
||||
progress = math.max(((i + progress) / attachments.length) * kAttachmentProgressWeight, progress);
|
||||
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (context.mounted) {
|
||||
final compressedAttachment = await _tryCompressVideoCopy(context, media);
|
||||
if (compressedAttachment != null) {
|
||||
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) context.showErrorDialog(err);
|
||||
}
|
||||
|
||||
progress = (i + 1) / attachments.length * kAttachmentProgressWeight;
|
||||
attachments[i] = PostWriteMedia(item);
|
||||
notifyListeners();
|
||||
@ -345,6 +486,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
|
||||
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
|
||||
'tags': tags.map((ele) => {'alias': ele}).toList(),
|
||||
'categories': categories.map((ele) => {'alias': ele}).toList(),
|
||||
'visibility': visibility,
|
||||
'visible_users_list': visibleUsers,
|
||||
'invisible_users_list': invisibleUsers,
|
||||
@ -365,6 +507,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
method: editingPost != null ? 'PUT' : 'POST',
|
||||
),
|
||||
);
|
||||
reset();
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -413,65 +556,88 @@ class PostWriteController extends ChangeNotifier {
|
||||
|
||||
void setPublisher(SnPublisher? item) {
|
||||
publisher = item;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPublishedAt(DateTime? value) {
|
||||
publishedAt = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setPublishedUntil(DateTime? value) {
|
||||
publishedUntil = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setTags(List<String> value) {
|
||||
tags = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setCategories(List<String> value) {
|
||||
categories = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setVisibility(int value) {
|
||||
visibility = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setVisibleUsers(List<int> value) {
|
||||
visibleUsers = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setInvisibleUsers(List<int> value) {
|
||||
invisibleUsers = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setProgress(double? value) {
|
||||
progress = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setIsBusy(bool value) {
|
||||
isBusy = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setMode(String value) {
|
||||
mode = value;
|
||||
_temporaryPlanSave();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reset() {
|
||||
publishedAt = null;
|
||||
publishedUntil = null;
|
||||
thumbnail = null;
|
||||
visibility = 0;
|
||||
titleController.clear();
|
||||
descriptionController.clear();
|
||||
contentController.clear();
|
||||
attachments.clear();
|
||||
aliasController.clear();
|
||||
tags = List.empty(growable: true);
|
||||
categories = List.empty(growable: true);
|
||||
attachments = List.empty(growable: true);
|
||||
editingPost = null;
|
||||
replyingPost = null;
|
||||
repostingPost = null;
|
||||
mode = kTitleMap.keys.first;
|
||||
temporaryRestored = false;
|
||||
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -480,6 +646,7 @@ class PostWriteController extends ChangeNotifier {
|
||||
contentController.dispose();
|
||||
titleController.dispose();
|
||||
descriptionController.dispose();
|
||||
aliasController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
@ -10,7 +10,6 @@ import 'package:easy_localization_loader/easy_localization_loader.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
@ -18,7 +17,6 @@ import 'package:provider/provider.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/firebase_options.dart';
|
||||
import 'package:surface/providers/channel.dart';
|
||||
import 'package:surface/providers/chat_call.dart';
|
||||
@ -30,6 +28,8 @@ import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/relationship.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/providers/special_day.dart';
|
||||
import 'package:surface/providers/theme.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
@ -40,7 +40,6 @@ import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/version_label.dart';
|
||||
import 'package:version/version.dart';
|
||||
import 'package:workmanager/workmanager.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
@ -143,11 +142,15 @@ class SolianApp extends StatelessWidget {
|
||||
Provider(create: (ctx) => SnPostContentProvider(ctx)),
|
||||
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
|
||||
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
|
||||
Provider(create: (ctx) => SnStickerProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
||||
|
||||
// Additional helper layer
|
||||
Provider(create: (ctx) => SpecialDayProvider(ctx)),
|
||||
],
|
||||
child: _AppDelegate(),
|
||||
),
|
||||
@ -204,8 +207,6 @@ class _AppSplashScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppSplashScreenState extends State<_AppSplashScreen> {
|
||||
bool _isReady = false;
|
||||
|
||||
void _tryRequestRating() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.containsKey('first_boot_time')) {
|
||||
@ -265,6 +266,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
|
||||
// The Network initialization will also save initialize the Config, so it not need to be initialized again
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.initializeUserAgent();
|
||||
await sn.setConfigWithNative();
|
||||
if (!mounted) return;
|
||||
final ua = context.read<UserProvider>();
|
||||
await ua.initialize();
|
||||
@ -277,8 +279,6 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
await context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isReady = true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -298,32 +298,6 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isReady) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
body: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 180),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (MediaQuery.of(context).platformBrightness == Brightness.dark)
|
||||
Image.asset("assets/icon/icon-dark.png", width: 64, height: 64)
|
||||
else
|
||||
Image.asset("assets/icon/icon.png", width: 64, height: 64),
|
||||
const Gap(6),
|
||||
LinearProgressIndicator(
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
const Gap(20),
|
||||
Text('appInitializing'.tr(), textAlign: TextAlign.center),
|
||||
AppVersionLabel(),
|
||||
],
|
||||
),
|
||||
).center(),
|
||||
);
|
||||
}
|
||||
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,10 @@ const kRtkStoreKey = 'nex_user_rtk';
|
||||
const kNetworkServerDefault = 'https://api.sn.solsynth.dev';
|
||||
const kNetworkServerStoreKey = 'app_server_url';
|
||||
|
||||
const kAppbarTransparentStoreKey = 'app_bar_transparent';
|
||||
const kAppBackgroundStoreKey = 'app_has_background';
|
||||
const kAppColorSchemeStoreKey = 'app_color_scheme';
|
||||
|
||||
const Map<String, FilterQuality> kImageQualityLevel = {
|
||||
'settingsImageQualityLowest': FilterQuality.none,
|
||||
'settingsImageQualityLow': FilterQuality.low,
|
||||
|
41
lib/providers/experience.dart
Normal file
41
lib/providers/experience.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
const List<int> kExperienceToLevelRequirements = [
|
||||
0, // Level 0
|
||||
1000, // Level 1
|
||||
4000, // Level 2
|
||||
9000, // Level 3
|
||||
16000, // Level 4
|
||||
25000, // Level 5
|
||||
36000, // Level 6
|
||||
49000, // Level 7
|
||||
64000, // Level 8
|
||||
81000, // Level 9
|
||||
100000, // Level 10
|
||||
121000, // Level 11
|
||||
144000, // Level 12
|
||||
368000 // Level 13
|
||||
];
|
||||
|
||||
int getLevelFromExp(int experience) {
|
||||
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
|
||||
final idx = kExperienceToLevelRequirements.indexOf(exp);
|
||||
return idx;
|
||||
}
|
||||
|
||||
double calcLevelUpProgress(int experience) {
|
||||
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
|
||||
final idx = kExperienceToLevelRequirements.indexOf(exp);
|
||||
if (idx + 1 >= kExperienceToLevelRequirements.length) return 1;
|
||||
final nextExp = kExperienceToLevelRequirements[idx + 1];
|
||||
return (experience - exp).abs() / (exp - nextExp).abs();
|
||||
}
|
||||
|
||||
String calcLevelUpProgressLevel(int experience) {
|
||||
final exp = kExperienceToLevelRequirements.reversed.firstWhere((x) => x <= experience);
|
||||
final idx = kExperienceToLevelRequirements.indexOf(exp);
|
||||
if (idx + 1 >= kExperienceToLevelRequirements.length) return 'Infinity';
|
||||
final nextExp = exp - kExperienceToLevelRequirements[idx + 1];
|
||||
final formatter = NumberFormat.compactCurrency(symbol: '', decimalDigits: 1);
|
||||
return '${formatter.format((exp - experience).abs())}/${formatter.format(nextExp.abs())}';
|
||||
}
|
@ -83,12 +83,16 @@ class SnPostContentProvider {
|
||||
int offset = 0,
|
||||
String? type,
|
||||
String? author,
|
||||
Iterable<String>? categories,
|
||||
Iterable<String>? tags,
|
||||
}) async {
|
||||
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
|
||||
'take': take,
|
||||
'offset': offset,
|
||||
if (type != null) 'type': type,
|
||||
if (author != null) 'author': author,
|
||||
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
|
||||
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
|
||||
});
|
||||
final List<SnPost> out = await _preloadRelatedDataInBatch(
|
||||
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
|
||||
@ -118,12 +122,14 @@ class SnPostContentProvider {
|
||||
int take = 10,
|
||||
int offset = 0,
|
||||
Iterable<String>? tags,
|
||||
Iterable<String>? categories,
|
||||
}) async {
|
||||
final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
|
||||
'take': take,
|
||||
'offset': offset,
|
||||
'probe': searchTerm,
|
||||
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
|
||||
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
|
||||
});
|
||||
final List<SnPost> out = await _preloadRelatedDataInBatch(
|
||||
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
|
||||
|
@ -21,7 +21,7 @@ class SnAttachmentProvider {
|
||||
|
||||
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
|
||||
for (final item in items) {
|
||||
if ((item.isAnalyzed && item.isUploaded) || noCheck) {
|
||||
if (item.isAnalyzed || noCheck) {
|
||||
_cache[item.rid] = item;
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ class SnAttachmentProvider {
|
||||
|
||||
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
|
||||
final out = SnAttachment.fromJson(resp.data);
|
||||
if (out.isAnalyzed && out.isUploaded) {
|
||||
if (out.isAnalyzed) {
|
||||
_cache[rid] = out;
|
||||
}
|
||||
|
||||
@ -62,11 +62,12 @@ class SnAttachmentProvider {
|
||||
'id': pendingFetch.join(','),
|
||||
},
|
||||
);
|
||||
final out = resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).toList();
|
||||
final List<SnAttachment?> out =
|
||||
resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).cast<SnAttachment?>().toList();
|
||||
|
||||
for (final item in out) {
|
||||
if (item == null) continue;
|
||||
if (item.isAnalyzed && item.isUploaded) {
|
||||
if (item.isAnalyzed) {
|
||||
_cache[item.rid] = item;
|
||||
}
|
||||
result[randomMapping[item.rid]!] = item;
|
||||
@ -85,6 +86,7 @@ class SnAttachmentProvider {
|
||||
Map<String, dynamic>? metadata, {
|
||||
String? mimetype,
|
||||
Function(double progress)? onProgress,
|
||||
bool analyzeNow = false,
|
||||
}) async {
|
||||
final filePayload = MultipartFile.fromBytes(data, filename: filename);
|
||||
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
|
||||
@ -107,6 +109,7 @@ class SnAttachmentProvider {
|
||||
final resp = await _sn.client.post(
|
||||
'/cgi/uc/attachments',
|
||||
data: formData,
|
||||
queryParameters: {'analyzeNow': analyzeNow},
|
||||
onSendProgress: (count, total) {
|
||||
if (onProgress != null) {
|
||||
onProgress(count / total);
|
||||
@ -117,7 +120,7 @@ class SnAttachmentProvider {
|
||||
return SnAttachment.fromJson(resp.data);
|
||||
}
|
||||
|
||||
Future<(SnAttachment, int)> chunkedUploadInitialize(
|
||||
Future<(SnAttachmentFragment, int)> chunkedUploadInitialize(
|
||||
int size,
|
||||
String filename,
|
||||
String pool,
|
||||
@ -134,7 +137,7 @@ class SnAttachmentProvider {
|
||||
mimetypeOverride = mimetype;
|
||||
}
|
||||
|
||||
final resp = await _sn.client.post('/cgi/uc/attachments/multipart', data: {
|
||||
final resp = await _sn.client.post('/cgi/uc/fragments', data: {
|
||||
'alt': fileAlt,
|
||||
'name': filename,
|
||||
'pool': pool,
|
||||
@ -143,18 +146,20 @@ class SnAttachmentProvider {
|
||||
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
|
||||
});
|
||||
|
||||
return (SnAttachment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int);
|
||||
return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int);
|
||||
}
|
||||
|
||||
Future<SnAttachment> _chunkedUploadOnePart(
|
||||
Future<dynamic> _chunkedUploadOnePart(
|
||||
Uint8List data,
|
||||
String rid,
|
||||
String cid, {
|
||||
Function(double progress)? onProgress,
|
||||
bool analyzeNow = false,
|
||||
}) async {
|
||||
final resp = await _sn.client.post(
|
||||
'/cgi/uc/attachments/multipart/$rid/$cid',
|
||||
'/cgi/uc/fragments/$rid/$cid',
|
||||
data: data,
|
||||
queryParameters: {'analyzeNow': analyzeNow},
|
||||
options: Options(headers: {'Content-Type': 'application/octet-stream'}),
|
||||
onSendProgress: (count, total) {
|
||||
if (onProgress != null) {
|
||||
@ -163,21 +168,28 @@ class SnAttachmentProvider {
|
||||
},
|
||||
);
|
||||
|
||||
return SnAttachment.fromJson(resp.data);
|
||||
if (resp.data['attachment'] != null) {
|
||||
return SnAttachment.fromJson(resp.data['attachment']);
|
||||
} else {
|
||||
return SnAttachmentFragment.fromJson(resp.data['fragment']);
|
||||
}
|
||||
}
|
||||
|
||||
Future<SnAttachment> chunkedUploadParts(
|
||||
XFile file,
|
||||
SnAttachment place,
|
||||
SnAttachmentFragment place,
|
||||
int chunkSize, {
|
||||
Function(double progress)? onProgress,
|
||||
bool analyzeNow = false,
|
||||
}) async {
|
||||
final Map<String, dynamic> chunks = place.fileChunks ?? {};
|
||||
var currentTask = 0;
|
||||
final Map<String, dynamic> chunks = place.fileChunks;
|
||||
var completedTasks = 0;
|
||||
|
||||
final queue = Queue<Future<void>>();
|
||||
final activeTasks = <Future<void>>[];
|
||||
|
||||
late SnAttachment out;
|
||||
|
||||
for (final entry in chunks.entries) {
|
||||
queue.add(() async {
|
||||
final beginCursor = entry.value * chunkSize;
|
||||
@ -187,16 +199,26 @@ class SnAttachmentProvider {
|
||||
);
|
||||
final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList());
|
||||
|
||||
place = await _chunkedUploadOnePart(
|
||||
final result = await _chunkedUploadOnePart(
|
||||
data,
|
||||
place.rid,
|
||||
entry.key,
|
||||
analyzeNow: analyzeNow,
|
||||
onProgress: (progress) {
|
||||
final overallProgress = (completedTasks + progress) / chunks.length;
|
||||
onProgress?.call(overallProgress);
|
||||
},
|
||||
);
|
||||
|
||||
final overallProgress = currentTask / chunks.length;
|
||||
completedTasks++;
|
||||
final overallProgress = completedTasks / chunks.length;
|
||||
onProgress?.call(overallProgress);
|
||||
|
||||
currentTask++;
|
||||
if (result is SnAttachmentFragment) {
|
||||
place = result;
|
||||
} else {
|
||||
out = result as SnAttachment;
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@ -213,6 +235,24 @@ class SnAttachmentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
return place;
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<SnAttachment> updateOne(
|
||||
SnAttachment item, {
|
||||
String? alt,
|
||||
int? thumbnailId,
|
||||
int? compressedId,
|
||||
Map<String, dynamic>? metadata,
|
||||
bool? isIndexable,
|
||||
}) async {
|
||||
final resp = await _sn.client.put('/cgi/uc/attachments/${item.id}', data: {
|
||||
'alt': alt ?? item.alt,
|
||||
'thumbnail': thumbnailId ?? item.thumbnailId,
|
||||
'compressed': compressedId ?? item.compressedId,
|
||||
'metadata': metadata ?? item.usermeta,
|
||||
'is_indexable': isIndexable ?? item.isIndexable,
|
||||
});
|
||||
return SnAttachment.fromJson(resp.data);
|
||||
}
|
||||
}
|
||||
|
@ -68,9 +68,8 @@ class SnNetworkProvider {
|
||||
_config.initialize().then((_) {
|
||||
_prefs = _config.prefs;
|
||||
client.options.baseUrl = _config.serverUrl;
|
||||
if (!context.mounted) return;
|
||||
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
static Future<Dio> createOffContextClient() async {
|
||||
@ -109,6 +108,10 @@ class SnNetworkProvider {
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> setConfigWithNative() async {
|
||||
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
|
||||
}
|
||||
|
||||
static Future<String> _getUserAgent() async {
|
||||
final String platformInfo;
|
||||
if (kIsWeb) {
|
||||
|
38
lib/providers/sn_sticker.dart
Normal file
38
lib/providers/sn_sticker.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
|
||||
class SnStickerProvider {
|
||||
late final SnNetworkProvider _sn;
|
||||
final Map<String, SnSticker?> _cache = {};
|
||||
|
||||
SnStickerProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
}
|
||||
|
||||
bool hasNotSticker(String alias) {
|
||||
return _cache.containsKey(alias) && _cache[alias] == null;
|
||||
}
|
||||
|
||||
Future<SnSticker?> lookupSticker(String alias) async {
|
||||
if (_cache.containsKey(alias)) {
|
||||
return _cache[alias];
|
||||
}
|
||||
|
||||
try {
|
||||
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
|
||||
final sticker = SnSticker.fromJson(resp.data);
|
||||
_cache[alias] = sticker;
|
||||
|
||||
return sticker;
|
||||
} catch (err) {
|
||||
_cache[alias] = null;
|
||||
log('[Sticker] Failed to lookup sticker $alias: $err');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
184
lib/providers/special_day.dart
Normal file
184
lib/providers/special_day.dart
Normal file
@ -0,0 +1,184 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
|
||||
// Stored as key: month, day
|
||||
final Map<String, (int, int)> kSpecialDays = {
|
||||
// Birthday is dynamically generated according to the user's profile
|
||||
'NewYear': (1, 1),
|
||||
'LunarNewYear': (lunarToGregorian(null, 1, 1).month, lunarToGregorian(null, 1, 1).day),
|
||||
'MidAutumn': (lunarToGregorian(null, 8, 15).month, lunarToGregorian(null, 8, 15).day),
|
||||
'DragonBoat': (lunarToGregorian(null, 5, 5).month, lunarToGregorian(null, 5, 5).day),
|
||||
'ValentineDay': (2, 14),
|
||||
'LaborDay': (5, 1),
|
||||
'MotherDay': (5, 11),
|
||||
'ChildrenDay': (6, 1),
|
||||
'FatherDay': (8, 8),
|
||||
'Halloween': (10, 31),
|
||||
'Thanksgiving': (11, 28),
|
||||
'MerryXmas': (12, 25),
|
||||
};
|
||||
|
||||
const Map<String, String> kSpecialDaysSymbol = {
|
||||
'Birthday': '🎂',
|
||||
'NewYear': '🎉',
|
||||
'LunarNewYear': '🎉',
|
||||
'MidAutumn': '🥮',
|
||||
'DragonBoat': '🐲',
|
||||
'MerryXmas': '🎄',
|
||||
'ValentineDay': '💑',
|
||||
'LaborDay': '🏋️',
|
||||
'MotherDay': '👩',
|
||||
'ChildrenDay': '👶',
|
||||
'FatherDay': '👨',
|
||||
'Halloween': '🎃',
|
||||
'Thanksgiving': '🎅',
|
||||
};
|
||||
|
||||
class SpecialDayProvider {
|
||||
late final UserProvider _user;
|
||||
|
||||
SpecialDayProvider(BuildContext context) {
|
||||
_user = context.read<UserProvider>();
|
||||
}
|
||||
|
||||
List<String> getSpecialDays() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final birthday = _user.user?.profile?.birthday?.toLocal();
|
||||
final isBirthday = birthday != null && birthday.day == now.day && birthday.month == now.month;
|
||||
|
||||
return [
|
||||
if (isBirthday) 'Birthday',
|
||||
...kSpecialDays.keys.where(
|
||||
(key) => kSpecialDays[key]!.$1 == now.month && kSpecialDays[key]!.$2 == now.day,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
(String, DateTime)? getLastSpecialDay() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final birthday = _user.user?.profile?.birthday?.toLocal();
|
||||
|
||||
final Map<String, (int, int)> specialDays = {
|
||||
if (birthday != null) 'Birthday': (birthday.month, birthday.day),
|
||||
...kSpecialDays,
|
||||
};
|
||||
|
||||
DateTime? lastDate;
|
||||
String? lastEvent;
|
||||
|
||||
for (final entry in specialDays.entries) {
|
||||
final eventName = entry.key;
|
||||
final (month, day) = entry.value;
|
||||
|
||||
var specialDayThisYear = DateTime(now.year, month, day);
|
||||
var specialDayLastYear = DateTime(now.year - 1, month, day);
|
||||
|
||||
if (specialDayThisYear.isBefore(now)) {
|
||||
if (lastDate == null || specialDayThisYear.isAfter(lastDate)) {
|
||||
lastDate = specialDayThisYear;
|
||||
lastEvent = eventName;
|
||||
}
|
||||
} else if (specialDayLastYear.isBefore(now)) {
|
||||
if (lastDate == null || specialDayLastYear.isAfter(lastDate)) {
|
||||
lastDate = specialDayLastYear;
|
||||
lastEvent = eventName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEvent != null && lastDate != null) {
|
||||
return (lastEvent, lastDate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
(String, DateTime)? getNextSpecialDay() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final birthday = _user.user?.profile?.birthday?.toLocal();
|
||||
|
||||
// Stored as key: month, day
|
||||
final Map<String, (int, int)> specialDays = {
|
||||
if (birthday != null) 'Birthday': (birthday.month, birthday.day),
|
||||
...kSpecialDays,
|
||||
};
|
||||
|
||||
DateTime? closestDate;
|
||||
String? closestEvent;
|
||||
|
||||
for (final entry in specialDays.entries) {
|
||||
final eventName = entry.key;
|
||||
final (month, day) = entry.value;
|
||||
|
||||
// Calculate the special day's DateTime in the current year
|
||||
var specialDay = DateTime(now.year, month, day);
|
||||
|
||||
// If the special day has already passed this year, consider it for the next year
|
||||
if (specialDay.isBefore(now)) {
|
||||
specialDay = DateTime(now.year + 1, month, day);
|
||||
}
|
||||
|
||||
// Check if this special day is closer than the previously found one
|
||||
if (closestDate == null || specialDay.isBefore(closestDate)) {
|
||||
closestDate = specialDay;
|
||||
closestEvent = eventName;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestEvent != null && closestDate != null) {
|
||||
return (closestEvent, closestDate);
|
||||
}
|
||||
|
||||
// No special day found
|
||||
return null;
|
||||
}
|
||||
|
||||
double getSpecialDayProgress(DateTime last, DateTime next) {
|
||||
final totalDuration = next.add(-const Duration(days: 1)).difference(last).inSeconds.toDouble();
|
||||
final elapsedDuration = DateTime.now().difference(last).inSeconds.toDouble();
|
||||
return (elapsedDuration / totalDuration).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
final Map<int, LunarYear> lunarYearData = {
|
||||
2025: LunarYear(
|
||||
startDate: DateTime(2025, 1, 29),
|
||||
months: [29, 30, 30, 29, 30, 29, 29, 30, 30, 29, 30, 29],
|
||||
leapMonth: 0,
|
||||
),
|
||||
};
|
||||
|
||||
class LunarYear {
|
||||
final DateTime startDate;
|
||||
final List<int> months;
|
||||
final int leapMonth;
|
||||
|
||||
LunarYear({required this.startDate, required this.months, required this.leapMonth});
|
||||
}
|
||||
|
||||
DateTime lunarToGregorian(int? year, int month, int day, {bool isLeapMonth = false}) {
|
||||
year = year ?? DateTime.now().year;
|
||||
final lunarYear = lunarYearData[year];
|
||||
if (lunarYear == null) {
|
||||
throw Exception('Lunar data for year $year not found');
|
||||
}
|
||||
|
||||
int leapMonth = lunarYear.leapMonth;
|
||||
if (isLeapMonth && (leapMonth == 0 || leapMonth != month)) {
|
||||
throw Exception('Invalid leap month for year $year');
|
||||
}
|
||||
|
||||
int daysFromStart = 0;
|
||||
for (int i = 0; i < month - 1; i++) {
|
||||
daysFromStart += lunarYear.months[i];
|
||||
}
|
||||
|
||||
if (isLeapMonth) {
|
||||
daysFromStart += lunarYear.months[month - 1];
|
||||
}
|
||||
|
||||
daysFromStart += day - 1;
|
||||
|
||||
return lunarYear.startDate.add(Duration(days: daysFromStart));
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:surface/theme.dart';
|
||||
|
||||
@ -11,8 +13,8 @@ class ThemeProvider extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
void reloadTheme({bool? useMaterial3}) {
|
||||
createAppThemeSet().then((value) {
|
||||
void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
|
||||
createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
|
||||
theme = value;
|
||||
notifyListeners();
|
||||
});
|
||||
|
@ -1,12 +1,10 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/widget.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
|
||||
class UserProvider extends ChangeNotifier {
|
||||
@ -14,12 +12,10 @@ class UserProvider extends ChangeNotifier {
|
||||
SnAccount? user;
|
||||
|
||||
late final SnNetworkProvider _sn;
|
||||
late final HomeWidgetProvider _home;
|
||||
late final ConfigProvider _config;
|
||||
|
||||
UserProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_home = context.read<HomeWidgetProvider>();
|
||||
_config = context.read<ConfigProvider>();
|
||||
}
|
||||
|
||||
@ -32,10 +28,10 @@ class UserProvider extends ChangeNotifier {
|
||||
final value = _config.prefs.getString(kAtkStoreKey);
|
||||
isAuthorized = value != null;
|
||||
notifyListeners();
|
||||
refreshUser().then((value) {
|
||||
refreshUser().then((value) async {
|
||||
if (value != null) {
|
||||
log('Logged in as @${value.name}');
|
||||
_home.saveWidgetData('user', value.toJson());
|
||||
log('Atk: ${await atk}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -35,7 +35,7 @@ class WebSocketProvider extends ChangeNotifier {
|
||||
|
||||
Future<void> connect({noRetry = false}) async {
|
||||
if (!_ua.isAuthorized) return;
|
||||
if (isConnected) {
|
||||
if (isConnected || conn != null) {
|
||||
disconnect();
|
||||
}
|
||||
|
||||
@ -97,7 +97,7 @@ class WebSocketProvider extends ChangeNotifier {
|
||||
onError: (err) {
|
||||
isConnected = false;
|
||||
notifyListeners();
|
||||
Future.delayed(const Duration(seconds: 11), () => connect());
|
||||
Future.delayed(const Duration(seconds: 1), () => connect());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ class HomeWidgetProvider {
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
if (Platform.isIOS) {
|
||||
await HomeWidget.setAppGroupId("group.solsynth.solian");
|
||||
}
|
||||
}
|
||||
|
@ -77,8 +77,11 @@ final _appRoutes = [
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
name: 'postSearch',
|
||||
builder: (context, state) => const AppBackground(
|
||||
child: PostSearchScreen(),
|
||||
builder: (context, state) => AppBackground(
|
||||
child: PostSearchScreen(
|
||||
initialTags: state.uri.queryParameters['tags']?.split(','),
|
||||
initialCategories: state.uri.queryParameters['categories']?.split(','),
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@ -9,10 +10,12 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/experience.dart';
|
||||
import 'package:surface/providers/relationship.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/screens/abuse_report.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/types/check_in.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
@ -61,6 +64,19 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
|
||||
return List.from(
|
||||
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
|
||||
);
|
||||
} catch (err) {
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
SnAccountStatusInfo? _status;
|
||||
|
||||
Future<void> _fetchStatus() async {
|
||||
@ -228,65 +244,72 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
expandedHeight: _appBarHeight,
|
||||
title: _account == null
|
||||
? Text('loading').tr()
|
||||
: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _account!.nick,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: '@${_account!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
]),
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
appBarTheme: Theme.of(context).appBarTheme.copyWith(
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
pinned: true,
|
||||
flexibleSpace: _account != null
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
UniversalImage(
|
||||
sn.getAttachmentUrl(_account!.banner),
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
width: _appBarWidth,
|
||||
cacheHeight: imageHeight,
|
||||
cacheWidth: _appBarWidth,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 56 + MediaQuery.of(context).padding.top,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: _appBarBlur,
|
||||
sigmaY: _appBarBlur,
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(
|
||||
clampDouble(_appBarBlur * 0.1, 0, 0.5),
|
||||
),
|
||||
child: SliverAppBar(
|
||||
expandedHeight: _appBarHeight,
|
||||
title: _account == null
|
||||
? Text('loading').tr()
|
||||
: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _account!.nick,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: '@${_account!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
pinned: true,
|
||||
flexibleSpace: _account != null
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
UniversalImage(
|
||||
sn.getAttachmentUrl(_account!.banner),
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
width: _appBarWidth,
|
||||
cacheHeight: imageHeight,
|
||||
cacheWidth: _appBarWidth,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 56 + MediaQuery.of(context).padding.top,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: _appBarBlur,
|
||||
sigmaY: _appBarBlur,
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(
|
||||
clampDouble(_appBarBlur * 0.1, 0, 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_account != null)
|
||||
SliverToBoxAdapter(
|
||||
@ -430,6 +453,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.calendar_add_on),
|
||||
const Gap(8),
|
||||
@ -437,6 +461,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.cake),
|
||||
const Gap(8),
|
||||
@ -450,6 +475,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.identity_platform),
|
||||
const Gap(8),
|
||||
@ -459,6 +485,26 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
).opacity(0.8),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.star),
|
||||
const Gap(8),
|
||||
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
|
||||
const Gap(8),
|
||||
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
|
||||
const Gap(8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: LinearProgressIndicator(
|
||||
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
).alignment(Alignment.centerLeft),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
],
|
||||
@ -466,6 +512,33 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
SliverToBoxAdapter(child: const Divider()),
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(
|
||||
child: FutureBuilder<List<SnCheckInRecord>>(
|
||||
future: _getCheckInRecords(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
if (snapshot.data!.length <= 1) {
|
||||
return Text(
|
||||
'accountCheckInNoRecords',
|
||||
textAlign: TextAlign.center,
|
||||
).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8);
|
||||
}
|
||||
final records = snapshot.data!;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 240,
|
||||
child: CheckInRecordChart(records: records),
|
||||
).padding(
|
||||
right: 24,
|
||||
left: 16,
|
||||
top: 12,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(child: const Divider()),
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -534,3 +607,105 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CheckInRecordChart extends StatelessWidget {
|
||||
const CheckInRecordChart({
|
||||
super.key,
|
||||
required this.records,
|
||||
});
|
||||
|
||||
final List<SnCheckInRecord> records;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
colors: List.filled(
|
||||
records.length,
|
||||
Theme.of(context).colorScheme.primary.withOpacity(0.3),
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
spots: records
|
||||
.map(
|
||||
(x) => FlSpot(
|
||||
x.createdAt
|
||||
.copyWith(
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
)
|
||||
.millisecondsSinceEpoch
|
||||
.toDouble(),
|
||||
x.resultTier.toDouble(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots
|
||||
.map(
|
||||
(spot) => LineTooltipItem(
|
||||
'${kCheckInResultTierSymbols[spot.y.toInt()]}\n${DateFormat('MM/dd').format(DateTime.fromMillisecondsSinceEpoch(spot.x.toInt()))}',
|
||||
TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: 1,
|
||||
getTitlesWidget: (value, _) => Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
kCheckInResultTierSymbols[value.toInt()],
|
||||
textAlign: TextAlign.right,
|
||||
).padding(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 28,
|
||||
interval: 86400000,
|
||||
getTitlesWidget: (value, _) => Text(
|
||||
DateFormat('dd').format(
|
||||
DateTime.fromMillisecondsSinceEpoch(
|
||||
value.toInt(),
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
).padding(top: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -22,15 +22,14 @@ import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
class AccountPublisherEditScreen extends StatefulWidget {
|
||||
final String name;
|
||||
|
||||
const AccountPublisherEditScreen({super.key, required this.name});
|
||||
|
||||
@override
|
||||
State<AccountPublisherEditScreen> createState() =>
|
||||
_AccountPublisherEditScreenState();
|
||||
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
|
||||
}
|
||||
|
||||
class _AccountPublisherEditScreenState
|
||||
extends State<AccountPublisherEditScreen> {
|
||||
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
SnPublisher? _publisher;
|
||||
@ -54,7 +53,7 @@ class _AccountPublisherEditScreenState
|
||||
_publisher = SnPublisher.fromJson(resp.data);
|
||||
_syncWidget();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@ -75,9 +74,9 @@ class _AccountPublisherEditScreenState
|
||||
'name': _nameController.text,
|
||||
'description': _descriptionController.text,
|
||||
});
|
||||
Navigator.pop(context, true);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if(mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@ -108,11 +107,9 @@ class _AccountPublisherEditScreenState
|
||||
if (image == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final ImageProvider imageProvider =
|
||||
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios = place == 'banner'
|
||||
? [CropAspectRatio(width: 16, height: 7)]
|
||||
: [CropAspectRatio(width: 1, height: 1)];
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
@ -134,10 +131,7 @@ class _AccountPublisherEditScreenState
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final rawBytes =
|
||||
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
|
||||
try {
|
||||
final attachment = await attach.directUploadOne(
|
||||
@ -199,9 +193,7 @@ class _AccountPublisherEditScreenState
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: _banner != null
|
||||
? AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(_banner!),
|
||||
@ -240,8 +232,7 @@ class _AccountPublisherEditScreenState
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@ -249,8 +240,7 @@ class _AccountPublisherEditScreenState
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldNickname'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@ -260,8 +250,7 @@ class _AccountPublisherEditScreenState
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldDescription'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
|
@ -201,7 +201,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
|
||||
}
|
||||
|
||||
class _PublisherNewOrganization extends StatefulWidget {
|
||||
const _PublisherNewOrganization({super.key});
|
||||
const _PublisherNewOrganization();
|
||||
|
||||
@override
|
||||
State<_PublisherNewOrganization> createState() =>
|
||||
|
@ -105,6 +105,7 @@ class _LoginCheckScreen extends StatefulWidget {
|
||||
final SnAuthFactor? factor;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function onNext;
|
||||
|
||||
const _LoginCheckScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
@ -204,9 +205,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: [
|
||||
(_factorLabelMap[widget.factor!.type]?.$3 ?? true)
|
||||
? AutofillHints.password
|
||||
: AutofillHints.oneTimeCode
|
||||
(_factorLabelMap[widget.factor!.type]?.$3 ?? true) ? AutofillHints.password : AutofillHints.oneTimeCode
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
@ -243,6 +242,7 @@ class _LoginPickerScreen extends StatefulWidget {
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(SnAuthFactor) onPickFactor;
|
||||
final Function onNext;
|
||||
|
||||
const _LoginPickerScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
@ -260,8 +260,7 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
|
||||
bool _isBusy = false;
|
||||
int? _factorPicked;
|
||||
|
||||
Color get _unFocusColor =>
|
||||
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
void _performGetFactorCode() async {
|
||||
if (_factorPicked == null) return;
|
||||
@ -373,6 +372,7 @@ class _LoginLookupScreen extends StatefulWidget {
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(List<SnAuthFactor>?) onFactor;
|
||||
final Function onNext;
|
||||
|
||||
const _LoginLookupScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
@ -401,14 +401,13 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final lookupResp =
|
||||
await sn.client.get('/cgi/id/users/lookup?probe=$username');
|
||||
final lookupResp = await sn.client.get('/cgi/id/users/lookup?probe=$username');
|
||||
await sn.client.post('/cgi/id/users/me/password-reset', data: {
|
||||
'user_id': lookupResp.data['id'],
|
||||
});
|
||||
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
if (mounted) context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@ -431,8 +430,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
widget.onTicket(result.ticket);
|
||||
|
||||
// Pull factors
|
||||
final factorResp =
|
||||
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
|
||||
final factorResp = await sn.client.get('/cgi/id/auth/factors', queryParameters: {
|
||||
'ticketId': result.ticket!.id.toString(),
|
||||
});
|
||||
widget.onFactor(
|
||||
@ -443,7 +441,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
|
||||
widget.onNext();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if(mounted) context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
@ -526,10 +524,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withAlpha((255 * 0.75).round()),
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
|
@ -443,7 +443,7 @@ class _ChannelProfileDetailDialogState
|
||||
|
||||
class _ChannelMemberListWidget extends StatefulWidget {
|
||||
final SnChannel channel;
|
||||
const _ChannelMemberListWidget({super.key, required this.channel});
|
||||
const _ChannelMemberListWidget({required this.channel});
|
||||
|
||||
@override
|
||||
State<_ChannelMemberListWidget> createState() =>
|
||||
@ -580,7 +580,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
|
||||
class _NewChannelMemberWidget extends StatefulWidget {
|
||||
final SnChannel channel;
|
||||
const _NewChannelMemberWidget({super.key, required this.channel});
|
||||
const _NewChannelMemberWidget({required this.channel});
|
||||
|
||||
@override
|
||||
State<_NewChannelMemberWidget> createState() =>
|
||||
|
@ -87,7 +87,7 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
try {
|
||||
final resp = await sn.client.request(
|
||||
widget.editingChannelAlias != null
|
||||
? '/cgi/im/channels/$scope/${widget.editingChannelAlias}'
|
||||
? '/cgi/im/channels/$scope/${_editingChannel!.id}'
|
||||
: '/cgi/im/channels/$scope',
|
||||
data: payload,
|
||||
options: Options(
|
||||
|
@ -17,6 +17,7 @@ import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
|
||||
import 'package:surface/widgets/chat/chat_message.dart';
|
||||
import 'package:surface/widgets/chat/chat_message_input.dart';
|
||||
import 'package:surface/widgets/chat/chat_typing_indicator.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
@ -97,7 +98,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
print((err as DioException).response?.data);
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isCalling = false);
|
||||
@ -281,11 +281,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
Expanded(
|
||||
child: InfiniteList(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
hasReachedMax: _messageController.isAllLoaded,
|
||||
itemCount: _messageController.messages.length,
|
||||
isLoading: _messageController.isLoading,
|
||||
@ -311,23 +307,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 480),
|
||||
child: ChatMessage(
|
||||
data: message,
|
||||
isMerged: canMerge,
|
||||
hasMerged: canMergePrevious,
|
||||
isPending: _messageController.unconfirmedMessages.contains(message.uuid),
|
||||
onReply: (value) {
|
||||
_inputGlobalKey.currentState?.setReply(value);
|
||||
},
|
||||
onEdit: (value) {
|
||||
_inputGlobalKey.currentState?.setEdit(value);
|
||||
},
|
||||
onDelete: (value) {
|
||||
_inputGlobalKey.currentState?.deleteMessage(value);
|
||||
},
|
||||
),
|
||||
child: ChatMessage(
|
||||
data: message,
|
||||
isMerged: canMerge,
|
||||
hasMerged: canMergePrevious,
|
||||
isPending: _messageController.unconfirmedMessages.contains(message.uuid),
|
||||
onReply: (value) {
|
||||
_inputGlobalKey.currentState?.setReply(value);
|
||||
},
|
||||
onEdit: (value) {
|
||||
_inputGlobalKey.currentState?.setEdit(value);
|
||||
},
|
||||
onDelete: (value) {
|
||||
_inputGlobalKey.currentState?.deleteMessage(value);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -336,11 +329,17 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
if (!_messageController.isPending)
|
||||
Material(
|
||||
elevation: 2,
|
||||
child: ChatMessageInput(
|
||||
key: _inputGlobalKey,
|
||||
otherMember: _otherMember,
|
||||
controller: _messageController,
|
||||
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
||||
child: Column(
|
||||
children: [
|
||||
ChatTypingIndicator(controller: _messageController),
|
||||
ChatMessageInput(
|
||||
key: _inputGlobalKey,
|
||||
otherMember: _otherMember,
|
||||
controller: _messageController,
|
||||
),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@ -5,12 +5,28 @@ import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
const Map<String, IconData> kCategoryIcons = {
|
||||
'technology': Symbols.tools_wrench,
|
||||
'gaming': Symbols.gamepad,
|
||||
'life': Symbols.nightlife,
|
||||
'arts': Symbols.format_paint,
|
||||
'sports': Symbols.sports_soccer,
|
||||
'music': Symbols.music_note,
|
||||
'news': Symbols.newspaper,
|
||||
'knowledge': Symbols.library_books,
|
||||
'literature': Symbols.book,
|
||||
'funny': Symbols.attractions,
|
||||
};
|
||||
|
||||
class ExploreScreen extends StatefulWidget {
|
||||
const ExploreScreen({super.key});
|
||||
|
||||
@ -24,15 +40,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
bool _isBusy = true;
|
||||
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
final List<SnPostCategory> _categories = List.empty(growable: true);
|
||||
int? _postCount;
|
||||
|
||||
String? _selectedCategory;
|
||||
|
||||
Future<void> _fetchCategories() async {
|
||||
_categories.clear();
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/categories?take=100');
|
||||
_categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final result = await pt.listPosts(take: 10, offset: _posts.length);
|
||||
final result = await pt.listPosts(
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
categories: _selectedCategory != null ? [_selectedCategory!] : null,
|
||||
);
|
||||
final out = result.$1;
|
||||
|
||||
if (!mounted) return;
|
||||
@ -43,10 +78,17 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
}
|
||||
|
||||
Future<void> _refreshPosts() {
|
||||
_postCount = null;
|
||||
_posts.clear();
|
||||
return _fetchPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPosts();
|
||||
_fetchCategories();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -59,27 +101,20 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
type: ExpandableFabType.up,
|
||||
childrenAnimation: ExpandableFabAnimation.none,
|
||||
overlayStyle: ExpandableFabOverlayStyle(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withAlpha((255 * 0.5).round()),
|
||||
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
|
||||
),
|
||||
openButtonBuilder: RotateFloatingActionButtonBuilder(
|
||||
child: const Icon(Symbols.add, size: 28),
|
||||
fabSize: ExpandableFabSize.regular,
|
||||
foregroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
||||
child: const Icon(Symbols.close, size: 28),
|
||||
fabSize: ExpandableFabSize.regular,
|
||||
foregroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
children: [
|
||||
@ -95,8 +130,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
'mode': 'stories',
|
||||
}).then((value) {
|
||||
if (value == true) {
|
||||
_posts.clear();
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
}
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
@ -117,8 +151,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
'mode': 'articles',
|
||||
}).then((value) {
|
||||
if (value == true) {
|
||||
_posts.clear();
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
}
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
@ -131,10 +164,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
displacement: 40 + MediaQuery.of(context).padding.top,
|
||||
onRefresh: () {
|
||||
_posts.clear();
|
||||
return _fetchPosts();
|
||||
},
|
||||
onRefresh: () => _refreshPosts(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
@ -151,6 +181,34 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(50),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _categories.map((ele) {
|
||||
return StyledWidget(ChoiceChip(
|
||||
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
|
||||
label: Text(
|
||||
'postCategory${ele.alias.capitalize()}'.trExists()
|
||||
? 'postCategory${ele.alias.capitalize()}'.tr()
|
||||
: ele.name,
|
||||
),
|
||||
selected: _selectedCategory == ele.alias,
|
||||
onSelected: (value) {
|
||||
_selectedCategory = value ? ele.alias : null;
|
||||
_refreshPosts();
|
||||
},
|
||||
)).padding(horizontal: 4);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverInfiniteList(
|
||||
itemCount: _posts.length,
|
||||
@ -167,8 +225,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
_posts.clear();
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
|
@ -289,7 +289,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
}
|
||||
|
||||
class _NewFriendWidget extends StatefulWidget {
|
||||
const _NewFriendWidget({super.key});
|
||||
const _NewFriendWidget();
|
||||
|
||||
@override
|
||||
State<_NewFriendWidget> createState() => _NewFriendWidgetState();
|
||||
@ -365,7 +365,7 @@ class _NewFriendWidgetState extends State<_NewFriendWidget> {
|
||||
|
||||
class _FriendshipListWidget extends StatefulWidget {
|
||||
final List<SnRelationship> relations;
|
||||
const _FriendshipListWidget({super.key, required this.relations});
|
||||
const _FriendshipListWidget({required this.relations});
|
||||
|
||||
@override
|
||||
State<_FriendshipListWidget> createState() => _FriendshipListWidgetState();
|
||||
|
@ -11,20 +11,22 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:slide_countdown/slide_countdown.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/special_day.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/widget.dart';
|
||||
import 'package:surface/types/check_in.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
|
||||
import '../providers/widget.dart';
|
||||
|
||||
class HomeScreenDashEntry {
|
||||
final String name;
|
||||
final Widget child;
|
||||
@ -80,8 +82,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||||
children: [
|
||||
_HomeDashSpecialDayWidget().padding(bottom: 8, horizontal: 8),
|
||||
_HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
|
||||
_HomeDashSpecialDayWidget().padding(horizontal: 8),
|
||||
StaggeredGrid.extent(
|
||||
maxCrossAxisExtent: 280,
|
||||
mainAxisSpacing: 8,
|
||||
@ -108,7 +110,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeDashUpdateWidget extends StatelessWidget {
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const _HomeDashUpdateWidget({super.key, this.padding});
|
||||
const _HomeDashUpdateWidget({this.padding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -151,47 +153,84 @@ class _HomeDashUpdateWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeDashSpecialDayWidget extends StatelessWidget {
|
||||
const _HomeDashSpecialDayWidget({super.key});
|
||||
class _HomeDashSpecialDayWidget extends StatefulWidget {
|
||||
const _HomeDashSpecialDayWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
|
||||
}
|
||||
|
||||
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ua = context.watch<UserProvider>();
|
||||
final today = DateTime.now();
|
||||
final birthday = ua.user?.profile?.birthday?.toLocal();
|
||||
final isBirthday = birthday != null && birthday.day == today.day && birthday.month == today.month;
|
||||
final dayz = context.watch<SpecialDayProvider>();
|
||||
|
||||
return Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (isBirthday)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Text('🎂').fontSize(24),
|
||||
title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']),
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
if (today.month == 12 && today.day == 25)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Text('🎄').fontSize(24),
|
||||
title: Text('celebrateMerryXmas').tr(args: [ua.user?.nick ?? 'user']),
|
||||
final days = dayz.getSpecialDays();
|
||||
|
||||
if (days.isNotEmpty) {
|
||||
return Column(
|
||||
children: days.map((ele) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
|
||||
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
|
||||
subtitle: Text(
|
||||
DateFormat('y/M/d').format(DateTime.now().copyWith(
|
||||
month: kSpecialDays[ele]?.$1,
|
||||
day: kSpecialDays[ele]?.$2,
|
||||
)),
|
||||
),
|
||||
),
|
||||
if (today.month == 1 && today.day == 1)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Text('🎉').fontSize(24),
|
||||
title: Text('celebrateNewYear').tr(args: [ua.user?.nick ?? 'user']),
|
||||
),
|
||||
).padding(bottom: 8);
|
||||
}).toList());
|
||||
}
|
||||
|
||||
final nextOne = dayz.getNextSpecialDay();
|
||||
final lastOne = dayz.getLastSpecialDay();
|
||||
|
||||
if (nextOne != null && lastOne != null) {
|
||||
var (name, date) = nextOne;
|
||||
date = date.add(Duration(days: 1));
|
||||
final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
|
||||
final diff = nextOne.$2.difference(DateTime.now());
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
|
||||
title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]),
|
||||
subtitle: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SlideCountdown(
|
||||
duration: diff,
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
separatorStyle: GoogleFonts.robotoMono(fontSize: 13),
|
||||
separatorType: SeparatorType.symbol,
|
||||
decoration: BoxDecoration(),
|
||||
padding: EdgeInsets.zero,
|
||||
onDone: () {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
).padding(bottom: 8);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeDashCheckInWidget extends StatefulWidget {
|
||||
const _HomeDashCheckInWidget({super.key});
|
||||
const _HomeDashCheckInWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
|
||||
@ -212,7 +251,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
final home = context.read<HomeWidgetProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/check-in/today');
|
||||
_todayRecord = SnCheckInRecord.fromJson(resp.data);
|
||||
home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
|
||||
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@ -225,7 +264,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
final home = context.read<HomeWidgetProvider>();
|
||||
final resp = await sn.client.post('/cgi/id/check-in');
|
||||
_todayRecord = SnCheckInRecord.fromJson(resp.data);
|
||||
home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
|
||||
await home.saveWidgetData('pas_check_in_record', _todayRecord!.toJson());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@ -409,7 +448,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
}
|
||||
|
||||
class _HomeDashNotificationWidget extends StatefulWidget {
|
||||
const _HomeDashNotificationWidget({super.key});
|
||||
const _HomeDashNotificationWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
|
||||
@ -480,7 +519,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
|
||||
}
|
||||
|
||||
class _HomeDashRecommendationPostWidget extends StatefulWidget {
|
||||
const _HomeDashRecommendationPostWidget({super.key});
|
||||
const _HomeDashRecommendationPostWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
|
||||
@ -494,9 +533,7 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final home = context.read<HomeWidgetProvider>();
|
||||
_posts = await pt.listRecommendations();
|
||||
home.saveWidgetData('post_featured', _posts!.first.toJson());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
|
@ -82,24 +82,15 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
if (!mounted) return;
|
||||
setState(() => _isSubmitting = true);
|
||||
|
||||
List<int> markList = List.empty(growable: true);
|
||||
for (final element in _notifications) {
|
||||
if (element.id <= 0) continue;
|
||||
if (element.readAt != null) continue;
|
||||
markList.add(element.id);
|
||||
}
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.put('/cgi/id/notifications/read', data: {
|
||||
'messages': markList,
|
||||
});
|
||||
final resp = await sn.client.put('/cgi/id/notifications/read/all');
|
||||
_notifications.clear();
|
||||
_fetchNotifications();
|
||||
|
||||
if (!mounted) return;
|
||||
context.showSnackbar(
|
||||
'notificationMarkAllReadPrompt'.plural(markList.length),
|
||||
'notificationMarkAllReadPrompt'.plural(resp.data['count']),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
|
@ -96,6 +96,8 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
|
||||
),
|
||||
),
|
||||
]),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text('postDetail').tr(),
|
||||
),
|
||||
|
@ -1,16 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
@ -96,38 +91,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
void _takeMedia(bool isVideo) async {
|
||||
final result = isVideo
|
||||
? await _imagePicker.pickVideo(source: ImageSource.camera)
|
||||
: await _imagePicker.pickImage(source: ImageSource.camera);
|
||||
if (result == null) return;
|
||||
_writeController.addAttachments([
|
||||
PostWriteMedia.fromFile(result),
|
||||
]);
|
||||
}
|
||||
|
||||
void _selectMedia() async {
|
||||
final result = await _imagePicker.pickMultipleMedia();
|
||||
if (result.isEmpty) return;
|
||||
_writeController.addAttachments(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
}
|
||||
|
||||
void _pasteMedia() async {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
if (imageBytes == null) return;
|
||||
_writeController.addAttachments([
|
||||
PostWriteMedia.fromBytes(
|
||||
imageBytes,
|
||||
'attachmentPastedImage'.tr(),
|
||||
PostWriteMediaType.image,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_writeController.dispose();
|
||||
@ -292,18 +255,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (_writeController.replyingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.reply).padding(left: 4),
|
||||
title: Text('postReplyingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
|
||||
),
|
||||
ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.reply).padding(left: 4),
|
||||
title: Text('postReplyingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
@ -312,22 +270,17 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (_writeController.repostingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.forward).padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
|
||||
children: <Widget>[
|
||||
PostItem(
|
||||
data: _writeController.repostingPost!,
|
||||
)
|
||||
],
|
||||
),
|
||||
ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.forward).padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
|
||||
children: <Widget>[
|
||||
PostItem(
|
||||
data: _writeController.repostingPost!,
|
||||
)
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
@ -336,18 +289,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (_writeController.editingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit_note).padding(left: 4),
|
||||
title: Text('postEditingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.editingPost!)],
|
||||
),
|
||||
ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit_note).padding(left: 4),
|
||||
title: Text('postEditingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.editingPost!)],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
@ -416,6 +364,36 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
child: _writeController.temporaryRestored
|
||||
? Container(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 4, left: 28, right: 22),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.restore, size: 20),
|
||||
const Gap(8),
|
||||
Expanded(child: Text('postLocalDraftRestored').tr()),
|
||||
InkWell(
|
||||
child: Text('dialogDismiss').tr(),
|
||||
onTap: () {
|
||||
_writeController.reset();
|
||||
},
|
||||
),
|
||||
],
|
||||
))
|
||||
: const SizedBox.shrink(),
|
||||
)
|
||||
.height(_writeController.temporaryRestored ? 32 : 0, animate: true)
|
||||
.animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
|
||||
LoadingIndicator(isActive: _isLoading),
|
||||
if (_writeController.isBusy && _writeController.progress != null)
|
||||
TweenAnimationBuilder<double>(
|
||||
@ -435,63 +413,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Row(
|
||||
children: [
|
||||
PopupMenuButton(
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_camera),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraPhoto').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(false);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.videocam),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraVideo').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(true);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_library),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromAlbum').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_selectMedia();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.content_paste),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromClipboard').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_pasteMedia();
|
||||
},
|
||||
),
|
||||
],
|
||||
AddPostMediaButton(
|
||||
onAdd: (items) {
|
||||
setState(() {
|
||||
_writeController.addAttachments(items);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -13,7 +13,10 @@ import 'package:surface/widgets/post/post_tags_field.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class PostSearchScreen extends StatefulWidget {
|
||||
const PostSearchScreen({super.key});
|
||||
final Iterable<String>? initialTags;
|
||||
final Iterable<String>? initialCategories;
|
||||
|
||||
const PostSearchScreen({super.key, this.initialTags, this.initialCategories});
|
||||
|
||||
@override
|
||||
State<PostSearchScreen> createState() => _PostSearchScreenState();
|
||||
@ -23,6 +26,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
List<String> _searchTags = List.empty(growable: true);
|
||||
List<String> _searchCategories = List.empty(growable: true);
|
||||
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
int? _postCount;
|
||||
@ -30,8 +34,18 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
String _searchTerm = '';
|
||||
Duration? _lastTook;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchTags.addAll(widget.initialTags ?? []);
|
||||
_searchCategories.addAll(widget.initialCategories ?? []);
|
||||
if (_searchTags.isNotEmpty || _searchCategories.isNotEmpty) {
|
||||
_fetchPosts();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
if (_searchTerm.isEmpty && _searchTags.isEmpty) return;
|
||||
if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
|
||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
@ -45,6 +59,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
tags: _searchTags,
|
||||
categories: _searchCategories,
|
||||
);
|
||||
final List<SnPost> out = result.$1;
|
||||
_postCount = result.$2;
|
||||
@ -73,9 +88,25 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
setState(() => _searchTags = value);
|
||||
},
|
||||
),
|
||||
const Gap(4),
|
||||
PostCategoriesField(
|
||||
labelText: 'fieldPostCategories'.tr(),
|
||||
initialCategories: _searchCategories,
|
||||
onUpdate: (value) {
|
||||
setState(() => _searchCategories = value);
|
||||
},
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
);
|
||||
).then((_) {
|
||||
_refreshPosts();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshPosts() {
|
||||
_postCount = null;
|
||||
_posts.clear();
|
||||
return _fetchPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
@ -118,8 +149,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
_posts.clear();
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
@ -150,10 +180,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
_searchTerm = value;
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
setState(() => _posts.clear());
|
||||
|
||||
_searchTerm = value;
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
},
|
||||
),
|
||||
if (_lastTook != null)
|
||||
|
@ -45,17 +45,9 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
Future<void> _fetchPublisher() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final rel = context.read<SnRelationshipProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/publishers/${widget.name}');
|
||||
if (!mounted) return;
|
||||
_publisher = SnPublisher.fromJson(resp.data);
|
||||
_account = await ud.getAccount(_publisher?.accountId);
|
||||
_accountRelationship = await rel.getRelationship(_account!.id);
|
||||
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
|
||||
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
|
||||
_realm = SnRealm.fromJson(resp.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err).then((_) {
|
||||
@ -65,6 +57,20 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
} finally {
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final rel = context.read<SnRelationshipProvider>();
|
||||
_account = await ud.getAccount(_publisher?.accountId);
|
||||
_accountRelationship = await rel.getRelationship(_account!.id);
|
||||
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
|
||||
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
|
||||
_realm = SnRealm.fromJson(resp.data);
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
bool _isSubscribing = false;
|
||||
@ -277,70 +283,77 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: MultiSliver(
|
||||
children: [
|
||||
SliverAppBar(
|
||||
expandedHeight: _appBarHeight,
|
||||
title: _publisher == null
|
||||
? Text('loading').tr()
|
||||
: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _publisher!.nick,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: '@${_publisher!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
pinned: true,
|
||||
flexibleSpace: _publisher != null
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_publisher!.banner.isNotEmpty)
|
||||
UniversalImage(
|
||||
sn.getAttachmentUrl(_publisher!.banner),
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
width: _appBarWidth,
|
||||
cacheHeight: imageHeight,
|
||||
cacheWidth: _appBarWidth,
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
appBarTheme: Theme.of(context).appBarTheme.copyWith(
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
child: SliverAppBar(
|
||||
expandedHeight: _appBarHeight,
|
||||
title: _publisher == null
|
||||
? Text('loading').tr()
|
||||
: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _publisher!.nick,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 56 + MediaQuery.of(context).padding.top,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: _appBarBlur,
|
||||
sigmaY: _appBarBlur,
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(
|
||||
clampDouble(_appBarBlur * 0.1, 0, 0.5),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: '@${_publisher!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
pinned: true,
|
||||
flexibleSpace: _publisher != null
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_publisher!.banner.isNotEmpty)
|
||||
UniversalImage(
|
||||
sn.getAttachmentUrl(_publisher!.banner),
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
width: _appBarWidth,
|
||||
cacheHeight: imageHeight,
|
||||
cacheWidth: _appBarWidth,
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 56 + MediaQuery.of(context).padding.top,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: _appBarBlur,
|
||||
sigmaY: _appBarBlur,
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(
|
||||
clampDouble(_appBarBlur * 0.1, 0, 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (_publisher != null)
|
||||
SliverToBoxAdapter(
|
||||
@ -567,7 +580,6 @@ class _PublisherPostList extends StatelessWidget {
|
||||
final void Function() onDeleted;
|
||||
|
||||
const _PublisherPostList({
|
||||
super.key,
|
||||
required this.isBusy,
|
||||
required this.postCount,
|
||||
required this.posts,
|
||||
|
@ -88,9 +88,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(icon: const Icon(Symbols.home)),
|
||||
Tab(icon: const Icon(Symbols.group)),
|
||||
Tab(icon: const Icon(Symbols.settings)),
|
||||
Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -119,7 +119,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
||||
final SnRealm? realm;
|
||||
final List<SnPublisher>? publishers;
|
||||
|
||||
const _RealmDetailHomeWidget({super.key, required this.realm, this.publishers});
|
||||
const _RealmDetailHomeWidget({required this.realm, this.publishers});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -175,7 +175,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
||||
class _RealmMemberListWidget extends StatefulWidget {
|
||||
final SnRealm? realm;
|
||||
|
||||
const _RealmMemberListWidget({super.key, this.realm});
|
||||
const _RealmMemberListWidget({this.realm});
|
||||
|
||||
@override
|
||||
State<_RealmMemberListWidget> createState() => _RealmMemberListWidgetState();
|
||||
@ -304,7 +304,7 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
|
||||
class _NewRealmMemberWidget extends StatefulWidget {
|
||||
final SnRealm realm;
|
||||
|
||||
const _NewRealmMemberWidget({super.key, required this.realm});
|
||||
const _NewRealmMemberWidget({required this.realm});
|
||||
|
||||
@override
|
||||
State<_NewRealmMemberWidget> createState() => _NewRealmMemberWidgetState();
|
||||
@ -384,7 +384,7 @@ class _RealmSettingsWidget extends StatefulWidget {
|
||||
final SnRealm? realm;
|
||||
final Function() onUpdate;
|
||||
|
||||
const _RealmSettingsWidget({super.key, required this.realm, required this.onUpdate});
|
||||
const _RealmSettingsWidget({required this.realm, required this.onUpdate});
|
||||
|
||||
@override
|
||||
State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState();
|
||||
|
@ -5,6 +5,7 @@ import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@ -18,6 +19,17 @@ import 'package:surface/providers/theme.dart';
|
||||
import 'package:surface/theme.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
const Map<String, Color> kColorSchemes = {
|
||||
'colorSchemeIndigo': Colors.indigo,
|
||||
'colorSchemeBlue': Colors.blue,
|
||||
'colorSchemeGreen': Colors.green,
|
||||
'colorSchemeYellow': Colors.yellow,
|
||||
'colorSchemeOrange': Colors.orange,
|
||||
'colorSchemeRed': Colors.red,
|
||||
'colorSchemeWhite': Colors.white,
|
||||
'colorSchemeBlack': Colors.black,
|
||||
};
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
@ -77,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
if (image == null) return;
|
||||
|
||||
await File(image.path).copy('$_docBasepath/app_background_image');
|
||||
_prefs.setBool('has_background_image', true);
|
||||
_prefs.setBool(kAppBackgroundStoreKey, true);
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
@ -98,7 +110,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
File('$_docBasepath/app_background_image').deleteSync();
|
||||
_prefs.remove('has_background_image');
|
||||
_prefs.remove(kAppBackgroundStoreKey);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
@ -116,10 +128,118 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
value ?? false,
|
||||
);
|
||||
});
|
||||
final th = context.watch<ThemeProvider>();
|
||||
final th = context.read<ThemeProvider>();
|
||||
th.reloadTheme(useMaterial3: value ?? false);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.format_paint),
|
||||
title: Text('settingsColorScheme').tr(),
|
||||
subtitle: Text('settingsColorSchemeDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
|
||||
final color = await showDialog<Color?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
onColorChanged: (color) => pickerColor = color,
|
||||
enableAlpha: false,
|
||||
hexInputBar: true,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('dialogDismiss').tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('dialogConfirm').tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(pickerColor);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (color == null || !context.mounted) return;
|
||||
|
||||
_prefs.setInt(kAppColorSchemeStoreKey, color.value);
|
||||
final th = context.read<ThemeProvider>();
|
||||
th.reloadTheme(seedColorOverride: color);
|
||||
setState(() {});
|
||||
|
||||
context.showSnackbar('colorSchemeApplied'.tr());
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.palette),
|
||||
title: Text('settingsColorSeed').tr(),
|
||||
subtitle: Text('settingsColorSeedDescription').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<int?>(
|
||||
isExpanded: true,
|
||||
items: [
|
||||
...kColorSchemes.entries.mapIndexed((idx, ele) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: idx,
|
||||
child: Text(ele.key).tr(),
|
||||
);
|
||||
}),
|
||||
DropdownMenuItem<int>(
|
||||
value: -1,
|
||||
child: Text('custom').tr(),
|
||||
),
|
||||
],
|
||||
value: _prefs.getInt(kAppColorSchemeStoreKey) == null
|
||||
? 1
|
||||
: kColorSchemes.values
|
||||
.toList()
|
||||
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
|
||||
onChanged: (int? value) {
|
||||
if (value != null && value != -1) {
|
||||
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values.elementAt(value).value);
|
||||
final th = context.read<ThemeProvider>();
|
||||
th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
|
||||
setState(() {});
|
||||
|
||||
context.showSnackbar('colorSchemeApplied'.tr());
|
||||
}
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 5,
|
||||
),
|
||||
height: 40,
|
||||
width: 160,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Symbols.blur_on),
|
||||
title: Text('settingsAppBarTransparent').tr(),
|
||||
subtitle: Text('settingsAppBarTransparentDescription').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
value: _prefs.getBool(kAppbarTransparentStoreKey) ?? false,
|
||||
onChanged: (value) {
|
||||
_prefs.setBool(kAppbarTransparentStoreKey, value ?? false);
|
||||
final th = context.read<ThemeProvider>();
|
||||
th.reloadTheme();
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
@ -189,7 +309,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
horizontal: 16,
|
||||
vertical: 5,
|
||||
),
|
||||
height: 40,
|
||||
height: 56,
|
||||
width: 160,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
|
@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
|
||||
const kMaterialYouToggleStoreKey = 'app_theme_material_you';
|
||||
|
||||
@ -10,7 +11,7 @@ class ThemeSet {
|
||||
ThemeSet({required this.light, required this.dark});
|
||||
}
|
||||
|
||||
Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async {
|
||||
Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async {
|
||||
return ThemeSet(
|
||||
light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3),
|
||||
dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3),
|
||||
@ -19,16 +20,20 @@ Future<ThemeSet> createAppThemeSet({bool? useMaterial3}) async {
|
||||
|
||||
Future<ThemeData> createAppTheme(
|
||||
Brightness brightness, {
|
||||
Color? seedColorOverride,
|
||||
bool? useMaterial3,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
|
||||
final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo;
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: Colors.indigo,
|
||||
seedColor: seedColorOverride ?? seedColor,
|
||||
brightness: brightness,
|
||||
);
|
||||
|
||||
final hasBackground = prefs.getBool('has_background_image') ?? false;
|
||||
final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? false),
|
||||
@ -42,8 +47,9 @@ Future<ThemeData> createAppTheme(
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
centerTitle: true,
|
||||
backgroundColor: hasBackground ? colorScheme.primary.withOpacity(0.75) : colorScheme.primary,
|
||||
foregroundColor: colorScheme.onPrimary,
|
||||
elevation: hasAppBarBlurry ? 0 : null,
|
||||
backgroundColor: hasAppBarBlurry ? colorScheme.surfaceContainer.withAlpha(200) : colorScheme.primary,
|
||||
foregroundColor: hasAppBarBlurry ? colorScheme.onSurface : colorScheme.onPrimary,
|
||||
),
|
||||
scaffoldBackgroundColor: Colors.transparent,
|
||||
);
|
||||
|
@ -1,15 +1,25 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'attachment.freezed.dart';
|
||||
|
||||
part 'attachment.g.dart';
|
||||
|
||||
enum SnMediaType {
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
file,
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachment with _$SnAttachment {
|
||||
const SnAttachment._();
|
||||
|
||||
const factory SnAttachment({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required dynamic deletedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String rid,
|
||||
required String uuid,
|
||||
required int size,
|
||||
@ -19,22 +29,70 @@ class SnAttachment with _$SnAttachment {
|
||||
required String hash,
|
||||
required int destination,
|
||||
required int refCount,
|
||||
required dynamic fileChunks,
|
||||
required dynamic cleanedAt,
|
||||
required bool isMature,
|
||||
@Default(0) int contentRating,
|
||||
@Default(0) int qualityRating,
|
||||
required DateTime? cleanedAt,
|
||||
required bool isAnalyzed,
|
||||
required bool isUploaded,
|
||||
required bool isSelfRef,
|
||||
required dynamic ref,
|
||||
required dynamic refId,
|
||||
required bool isIndexable,
|
||||
required SnAttachment? ref,
|
||||
required int? refId,
|
||||
required SnAttachmentPool? pool,
|
||||
required int poolId,
|
||||
required int? poolId,
|
||||
required int accountId,
|
||||
int? thumbnailId,
|
||||
SnAttachment? thumbnail,
|
||||
int? compressedId,
|
||||
SnAttachment? compressed,
|
||||
@Default([]) List<SnAttachmentBoost> boosts,
|
||||
@Default({}) Map<String, dynamic> usermeta,
|
||||
@Default({}) Map<String, dynamic> metadata,
|
||||
}) = _SnAttachment;
|
||||
|
||||
factory SnAttachment.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAttachmentFromJson(json);
|
||||
factory SnAttachment.fromJson(Map<String, Object?> json) => _$SnAttachmentFromJson(json);
|
||||
|
||||
Map<String, dynamic> get data => {
|
||||
...metadata,
|
||||
...usermeta,
|
||||
};
|
||||
|
||||
SnMediaType get mediaType => switch (mimetype.split('/').firstOrNull) {
|
||||
'image' => SnMediaType.image,
|
||||
'video' => SnMediaType.video,
|
||||
'audio' => SnMediaType.audio,
|
||||
_ => SnMediaType.file,
|
||||
};
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentFragment with _$SnAttachmentFragment {
|
||||
const SnAttachmentFragment._();
|
||||
|
||||
const factory SnAttachmentFragment({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String rid,
|
||||
required String uuid,
|
||||
required int size,
|
||||
required String name,
|
||||
required String alt,
|
||||
required String mimetype,
|
||||
required String hash,
|
||||
String? fingerprint,
|
||||
@Default({}) Map<String, int> fileChunks,
|
||||
@Default([]) List<String> fileChunksMissing,
|
||||
}) = _SnAttachmentFragment;
|
||||
|
||||
factory SnAttachmentFragment.fromJson(Map<String, Object?> json) => _$SnAttachmentFragmentFromJson(json);
|
||||
|
||||
SnMediaType get mediaType => switch (mimetype.split('/').firstOrNull) {
|
||||
'image' => SnMediaType.image,
|
||||
'video' => SnMediaType.video,
|
||||
'audio' => SnMediaType.audio,
|
||||
_ => SnMediaType.file,
|
||||
};
|
||||
}
|
||||
|
||||
@freezed
|
||||
@ -51,6 +109,71 @@ class SnAttachmentPool with _$SnAttachmentPool {
|
||||
required int? accountId,
|
||||
}) = _SnAttachmentPool;
|
||||
|
||||
factory SnAttachmentPool.fromJson(Map<String, Object?> json) =>
|
||||
_$SnAttachmentPoolFromJson(json);
|
||||
factory SnAttachmentPool.fromJson(Map<String, Object?> json) => _$SnAttachmentPoolFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentDestination with _$SnAttachmentDestination {
|
||||
const factory SnAttachmentDestination({
|
||||
@Default(0) int id,
|
||||
required String type,
|
||||
required String label,
|
||||
required String region,
|
||||
required bool isBoost,
|
||||
}) = _SnAttachmentDestination;
|
||||
|
||||
factory SnAttachmentDestination.fromJson(Map<String, Object?> json) => _$SnAttachmentDestinationFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentBoost with _$SnAttachmentBoost {
|
||||
const factory SnAttachmentBoost({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int status,
|
||||
required int destination,
|
||||
required int attachmentId,
|
||||
required SnAttachment attachment,
|
||||
required int account,
|
||||
}) = _SnAttachmentBoost;
|
||||
|
||||
factory SnAttachmentBoost.fromJson(Map<String, Object?> json) => _$SnAttachmentBoostFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnSticker with _$SnSticker {
|
||||
const factory SnSticker({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String alias,
|
||||
required String name,
|
||||
required int attachmentId,
|
||||
required SnAttachment attachment,
|
||||
required int packId,
|
||||
required SnStickerPack pack,
|
||||
required int accountId,
|
||||
}) = _SnSticker;
|
||||
|
||||
factory SnSticker.fromJson(Map<String, Object?> json) => _$SnStickerFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnStickerPack with _$SnStickerPack {
|
||||
const factory SnStickerPack({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String prefix,
|
||||
required String name,
|
||||
required String description,
|
||||
required List<SnSticker>? stickers,
|
||||
required int accountId,
|
||||
}) = _SnStickerPack;
|
||||
|
||||
factory SnStickerPack.fromJson(Map<String, Object?> json) => _$SnStickerPackFromJson(json);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -11,7 +11,9 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'],
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
rid: json['rid'] as String,
|
||||
uuid: json['uuid'] as String,
|
||||
size: (json['size'] as num).toInt(),
|
||||
@ -21,19 +23,37 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
|
||||
hash: json['hash'] as String,
|
||||
destination: (json['destination'] as num).toInt(),
|
||||
refCount: (json['ref_count'] as num).toInt(),
|
||||
fileChunks: json['file_chunks'],
|
||||
cleanedAt: json['cleaned_at'],
|
||||
isMature: json['is_mature'] as bool,
|
||||
contentRating: (json['content_rating'] as num?)?.toInt() ?? 0,
|
||||
qualityRating: (json['quality_rating'] as num?)?.toInt() ?? 0,
|
||||
cleanedAt: json['cleaned_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['cleaned_at'] as String),
|
||||
isAnalyzed: json['is_analyzed'] as bool,
|
||||
isUploaded: json['is_uploaded'] as bool,
|
||||
isSelfRef: json['is_self_ref'] as bool,
|
||||
ref: json['ref'],
|
||||
refId: json['ref_id'],
|
||||
isIndexable: json['is_indexable'] as bool,
|
||||
ref: json['ref'] == null
|
||||
? null
|
||||
: SnAttachment.fromJson(json['ref'] as Map<String, dynamic>),
|
||||
refId: (json['ref_id'] as num?)?.toInt(),
|
||||
pool: json['pool'] == null
|
||||
? null
|
||||
: SnAttachmentPool.fromJson(json['pool'] as Map<String, dynamic>),
|
||||
poolId: (json['pool_id'] as num).toInt(),
|
||||
poolId: (json['pool_id'] as num?)?.toInt(),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
thumbnailId: (json['thumbnail_id'] as num?)?.toInt(),
|
||||
thumbnail: json['thumbnail'] == null
|
||||
? null
|
||||
: SnAttachment.fromJson(json['thumbnail'] as Map<String, dynamic>),
|
||||
compressedId: (json['compressed_id'] as num?)?.toInt(),
|
||||
compressed: json['compressed'] == null
|
||||
? null
|
||||
: SnAttachment.fromJson(json['compressed'] as Map<String, dynamic>),
|
||||
boosts: (json['boosts'] as List<dynamic>?)
|
||||
?.map(
|
||||
(e) => SnAttachmentBoost.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
usermeta: json['usermeta'] as Map<String, dynamic>? ?? const {},
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
|
||||
);
|
||||
|
||||
@ -42,7 +62,7 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt,
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'rid': instance.rid,
|
||||
'uuid': instance.uuid,
|
||||
'size': instance.size,
|
||||
@ -52,20 +72,72 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
|
||||
'hash': instance.hash,
|
||||
'destination': instance.destination,
|
||||
'ref_count': instance.refCount,
|
||||
'file_chunks': instance.fileChunks,
|
||||
'cleaned_at': instance.cleanedAt,
|
||||
'is_mature': instance.isMature,
|
||||
'content_rating': instance.contentRating,
|
||||
'quality_rating': instance.qualityRating,
|
||||
'cleaned_at': instance.cleanedAt?.toIso8601String(),
|
||||
'is_analyzed': instance.isAnalyzed,
|
||||
'is_uploaded': instance.isUploaded,
|
||||
'is_self_ref': instance.isSelfRef,
|
||||
'ref': instance.ref,
|
||||
'is_indexable': instance.isIndexable,
|
||||
'ref': instance.ref?.toJson(),
|
||||
'ref_id': instance.refId,
|
||||
'pool': instance.pool?.toJson(),
|
||||
'pool_id': instance.poolId,
|
||||
'account_id': instance.accountId,
|
||||
'thumbnail_id': instance.thumbnailId,
|
||||
'thumbnail': instance.thumbnail?.toJson(),
|
||||
'compressed_id': instance.compressedId,
|
||||
'compressed': instance.compressed?.toJson(),
|
||||
'boosts': instance.boosts.map((e) => e.toJson()).toList(),
|
||||
'usermeta': instance.usermeta,
|
||||
'metadata': instance.metadata,
|
||||
};
|
||||
|
||||
_$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentFragmentImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
rid: json['rid'] as String,
|
||||
uuid: json['uuid'] as String,
|
||||
size: (json['size'] as num).toInt(),
|
||||
name: json['name'] as String,
|
||||
alt: json['alt'] as String,
|
||||
mimetype: json['mimetype'] as String,
|
||||
hash: json['hash'] as String,
|
||||
fingerprint: json['fingerprint'] as String?,
|
||||
fileChunks: (json['file_chunks'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, (e as num).toInt()),
|
||||
) ??
|
||||
const {},
|
||||
fileChunksMissing: (json['file_chunks_missing'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
|
||||
_$SnAttachmentFragmentImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'rid': instance.rid,
|
||||
'uuid': instance.uuid,
|
||||
'size': instance.size,
|
||||
'name': instance.name,
|
||||
'alt': instance.alt,
|
||||
'mimetype': instance.mimetype,
|
||||
'hash': instance.hash,
|
||||
'fingerprint': instance.fingerprint,
|
||||
'file_chunks': instance.fileChunks,
|
||||
'file_chunks_missing': instance.fileChunksMissing,
|
||||
};
|
||||
|
||||
_$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentPoolImpl(
|
||||
@ -95,3 +167,117 @@ Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
|
||||
'config': instance.config,
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentDestinationImpl(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
type: json['type'] as String,
|
||||
label: json['label'] as String,
|
||||
region: json['region'] as String,
|
||||
isBoost: json['is_boost'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
|
||||
_$SnAttachmentDestinationImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
'label': instance.label,
|
||||
'region': instance.region,
|
||||
'is_boost': instance.isBoost,
|
||||
};
|
||||
|
||||
_$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentBoostImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
status: (json['status'] as num).toInt(),
|
||||
destination: (json['destination'] as num).toInt(),
|
||||
attachmentId: (json['attachment_id'] as num).toInt(),
|
||||
attachment:
|
||||
SnAttachment.fromJson(json['attachment'] as Map<String, dynamic>),
|
||||
account: (json['account'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
|
||||
_$SnAttachmentBoostImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'status': instance.status,
|
||||
'destination': instance.destination,
|
||||
'attachment_id': instance.attachmentId,
|
||||
'attachment': instance.attachment.toJson(),
|
||||
'account': instance.account,
|
||||
};
|
||||
|
||||
_$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnStickerImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
alias: json['alias'] as String,
|
||||
name: json['name'] as String,
|
||||
attachmentId: (json['attachment_id'] as num).toInt(),
|
||||
attachment:
|
||||
SnAttachment.fromJson(json['attachment'] as Map<String, dynamic>),
|
||||
packId: (json['pack_id'] as num).toInt(),
|
||||
pack: SnStickerPack.fromJson(json['pack'] as Map<String, dynamic>),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'alias': instance.alias,
|
||||
'name': instance.name,
|
||||
'attachment_id': instance.attachmentId,
|
||||
'attachment': instance.attachment.toJson(),
|
||||
'pack_id': instance.packId,
|
||||
'pack': instance.pack.toJson(),
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnStickerPackImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
prefix: json['prefix'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
stickers: (json['stickers'] as List<dynamic>?)
|
||||
?.map((e) => SnSticker.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'prefix': instance.prefix,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'stickers': instance.stickers?.map((e) => e.toJson()).toList(),
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
@ -3,6 +3,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
part 'check_in.freezed.dart';
|
||||
part 'check_in.g.dart';
|
||||
|
||||
const List<String> kCheckInResultTierSymbols = ['大凶', '凶', '中平', '吉', '大吉'];
|
||||
|
||||
@freezed
|
||||
class SnCheckInRecord with _$SnCheckInRecord {
|
||||
const SnCheckInRecord._();
|
||||
@ -21,11 +23,5 @@ class SnCheckInRecord with _$SnCheckInRecord {
|
||||
factory SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnCheckInRecordFromJson(json);
|
||||
|
||||
String get symbol => switch (resultTier) {
|
||||
0 => '大凶',
|
||||
1 => '凶',
|
||||
2 => '中平',
|
||||
3 => '吉',
|
||||
_ => '大吉',
|
||||
};
|
||||
String get symbol => kCheckInResultTierSymbols[resultTier];
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ class SnPost with _$SnPost {
|
||||
required String? alias,
|
||||
required String? aliasPrefix,
|
||||
@Default([]) List<SnPostTag> tags,
|
||||
@Default([]) List<dynamic> categories,
|
||||
@Default([]) List<SnPostCategory> categories,
|
||||
required List<SnPost>? replies,
|
||||
required int? replyId,
|
||||
required int? repostId,
|
||||
@ -67,6 +67,23 @@ class SnPostTag with _$SnPostTag {
|
||||
_$SnPostTagFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnPostCategory with _$SnPostCategory {
|
||||
const factory SnPostCategory({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required dynamic deletedAt,
|
||||
required String alias,
|
||||
required String name,
|
||||
required String description,
|
||||
required dynamic posts,
|
||||
}) = _SnPostCategory;
|
||||
|
||||
factory SnPostCategory.fromJson(Map<String, Object?> json) =>
|
||||
_$SnPostCategoryFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnPostPreload with _$SnPostPreload {
|
||||
const factory SnPostPreload({
|
||||
|
@ -30,7 +30,7 @@ mixin _$SnPost {
|
||||
String? get alias => throw _privateConstructorUsedError;
|
||||
String? get aliasPrefix => throw _privateConstructorUsedError;
|
||||
List<SnPostTag> get tags => throw _privateConstructorUsedError;
|
||||
List<dynamic> get categories => throw _privateConstructorUsedError;
|
||||
List<SnPostCategory> get categories => throw _privateConstructorUsedError;
|
||||
List<SnPost>? get replies => throw _privateConstructorUsedError;
|
||||
int? get replyId => throw _privateConstructorUsedError;
|
||||
int? get repostId => throw _privateConstructorUsedError;
|
||||
@ -77,7 +77,7 @@ abstract class $SnPostCopyWith<$Res> {
|
||||
String? alias,
|
||||
String? aliasPrefix,
|
||||
List<SnPostTag> tags,
|
||||
List<dynamic> categories,
|
||||
List<SnPostCategory> categories,
|
||||
List<SnPost>? replies,
|
||||
int? replyId,
|
||||
int? repostId,
|
||||
@ -197,7 +197,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
|
||||
categories: null == categories
|
||||
? _value.categories
|
||||
: categories // ignore: cast_nullable_to_non_nullable
|
||||
as List<dynamic>,
|
||||
as List<SnPostCategory>,
|
||||
replies: freezed == replies
|
||||
? _value.replies
|
||||
: replies // ignore: cast_nullable_to_non_nullable
|
||||
@ -362,7 +362,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
|
||||
String? alias,
|
||||
String? aliasPrefix,
|
||||
List<SnPostTag> tags,
|
||||
List<dynamic> categories,
|
||||
List<SnPostCategory> categories,
|
||||
List<SnPost>? replies,
|
||||
int? replyId,
|
||||
int? repostId,
|
||||
@ -485,7 +485,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
|
||||
categories: null == categories
|
||||
? _value._categories
|
||||
: categories // ignore: cast_nullable_to_non_nullable
|
||||
as List<dynamic>,
|
||||
as List<SnPostCategory>,
|
||||
replies: freezed == replies
|
||||
? _value._replies
|
||||
: replies // ignore: cast_nullable_to_non_nullable
|
||||
@ -584,7 +584,7 @@ class _$SnPostImpl extends _SnPost {
|
||||
required this.alias,
|
||||
required this.aliasPrefix,
|
||||
final List<SnPostTag> tags = const [],
|
||||
final List<dynamic> categories = const [],
|
||||
final List<SnPostCategory> categories = const [],
|
||||
required final List<SnPost>? replies,
|
||||
required this.replyId,
|
||||
required this.repostId,
|
||||
@ -649,10 +649,10 @@ class _$SnPostImpl extends _SnPost {
|
||||
return EqualUnmodifiableListView(_tags);
|
||||
}
|
||||
|
||||
final List<dynamic> _categories;
|
||||
final List<SnPostCategory> _categories;
|
||||
@override
|
||||
@JsonKey()
|
||||
List<dynamic> get categories {
|
||||
List<SnPostCategory> get categories {
|
||||
if (_categories is EqualUnmodifiableListView) return _categories;
|
||||
// ignore: implicit_dynamic_type
|
||||
return EqualUnmodifiableListView(_categories);
|
||||
@ -853,7 +853,7 @@ abstract class _SnPost extends SnPost {
|
||||
required final String? alias,
|
||||
required final String? aliasPrefix,
|
||||
final List<SnPostTag> tags,
|
||||
final List<dynamic> categories,
|
||||
final List<SnPostCategory> categories,
|
||||
required final List<SnPost>? replies,
|
||||
required final int? replyId,
|
||||
required final int? repostId,
|
||||
@ -899,7 +899,7 @@ abstract class _SnPost extends SnPost {
|
||||
@override
|
||||
List<SnPostTag> get tags;
|
||||
@override
|
||||
List<dynamic> get categories;
|
||||
List<SnPostCategory> get categories;
|
||||
@override
|
||||
List<SnPost>? get replies;
|
||||
@override
|
||||
@ -1253,6 +1253,312 @@ abstract class _SnPostTag implements SnPostTag {
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
SnPostCategory _$SnPostCategoryFromJson(Map<String, dynamic> json) {
|
||||
return _SnPostCategory.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnPostCategory {
|
||||
int get id => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
DateTime get updatedAt => throw _privateConstructorUsedError;
|
||||
dynamic get deletedAt => throw _privateConstructorUsedError;
|
||||
String get alias => throw _privateConstructorUsedError;
|
||||
String get name => throw _privateConstructorUsedError;
|
||||
String get description => throw _privateConstructorUsedError;
|
||||
dynamic get posts => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SnPostCategory to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
|
||||
/// Create a copy of SnPostCategory
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$SnPostCategoryCopyWith<SnPostCategory> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SnPostCategoryCopyWith<$Res> {
|
||||
factory $SnPostCategoryCopyWith(
|
||||
SnPostCategory value, $Res Function(SnPostCategory) then) =
|
||||
_$SnPostCategoryCopyWithImpl<$Res, SnPostCategory>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
dynamic deletedAt,
|
||||
String alias,
|
||||
String name,
|
||||
String description,
|
||||
dynamic posts});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SnPostCategoryCopyWithImpl<$Res, $Val extends SnPostCategory>
|
||||
implements $SnPostCategoryCopyWith<$Res> {
|
||||
_$SnPostCategoryCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SnPostCategory
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? alias = null,
|
||||
Object? name = null,
|
||||
Object? description = null,
|
||||
Object? posts = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _value.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
alias: null == alias
|
||||
? _value.alias
|
||||
: alias // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
name: null == name
|
||||
? _value.name
|
||||
: name // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
description: null == description
|
||||
? _value.description
|
||||
: description // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
posts: freezed == posts
|
||||
? _value.posts
|
||||
: posts // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$SnPostCategoryImplCopyWith<$Res>
|
||||
implements $SnPostCategoryCopyWith<$Res> {
|
||||
factory _$$SnPostCategoryImplCopyWith(_$SnPostCategoryImpl value,
|
||||
$Res Function(_$SnPostCategoryImpl) then) =
|
||||
__$$SnPostCategoryImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
dynamic deletedAt,
|
||||
String alias,
|
||||
String name,
|
||||
String description,
|
||||
dynamic posts});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$SnPostCategoryImplCopyWithImpl<$Res>
|
||||
extends _$SnPostCategoryCopyWithImpl<$Res, _$SnPostCategoryImpl>
|
||||
implements _$$SnPostCategoryImplCopyWith<$Res> {
|
||||
__$$SnPostCategoryImplCopyWithImpl(
|
||||
_$SnPostCategoryImpl _value, $Res Function(_$SnPostCategoryImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of SnPostCategory
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? alias = null,
|
||||
Object? name = null,
|
||||
Object? description = null,
|
||||
Object? posts = freezed,
|
||||
}) {
|
||||
return _then(_$SnPostCategoryImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _value.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
alias: null == alias
|
||||
? _value.alias
|
||||
: alias // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
name: null == name
|
||||
? _value.name
|
||||
: name // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
description: null == description
|
||||
? _value.description
|
||||
: description // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
posts: freezed == posts
|
||||
? _value.posts
|
||||
: posts // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$SnPostCategoryImpl implements _SnPostCategory {
|
||||
const _$SnPostCategoryImpl(
|
||||
{required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deletedAt,
|
||||
required this.alias,
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.posts});
|
||||
|
||||
factory _$SnPostCategoryImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$SnPostCategoryImplFromJson(json);
|
||||
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
@override
|
||||
final DateTime updatedAt;
|
||||
@override
|
||||
final dynamic deletedAt;
|
||||
@override
|
||||
final String alias;
|
||||
@override
|
||||
final String name;
|
||||
@override
|
||||
final String description;
|
||||
@override
|
||||
final dynamic posts;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnPostCategory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$SnPostCategoryImpl &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.updatedAt, updatedAt) ||
|
||||
other.updatedAt == updatedAt) &&
|
||||
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
|
||||
(identical(other.alias, alias) || other.alias == alias) &&
|
||||
(identical(other.name, name) || other.name == name) &&
|
||||
(identical(other.description, description) ||
|
||||
other.description == description) &&
|
||||
const DeepCollectionEquality().equals(other.posts, posts));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
const DeepCollectionEquality().hash(deletedAt),
|
||||
alias,
|
||||
name,
|
||||
description,
|
||||
const DeepCollectionEquality().hash(posts));
|
||||
|
||||
/// Create a copy of SnPostCategory
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith =>
|
||||
__$$SnPostCategoryImplCopyWithImpl<_$SnPostCategoryImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$SnPostCategoryImplToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _SnPostCategory implements SnPostCategory {
|
||||
const factory _SnPostCategory(
|
||||
{required final int id,
|
||||
required final DateTime createdAt,
|
||||
required final DateTime updatedAt,
|
||||
required final dynamic deletedAt,
|
||||
required final String alias,
|
||||
required final String name,
|
||||
required final String description,
|
||||
required final dynamic posts}) = _$SnPostCategoryImpl;
|
||||
|
||||
factory _SnPostCategory.fromJson(Map<String, dynamic> json) =
|
||||
_$SnPostCategoryImpl.fromJson;
|
||||
|
||||
@override
|
||||
int get id;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
DateTime get updatedAt;
|
||||
@override
|
||||
dynamic get deletedAt;
|
||||
@override
|
||||
String get alias;
|
||||
@override
|
||||
String get name;
|
||||
@override
|
||||
String get description;
|
||||
@override
|
||||
dynamic get posts;
|
||||
|
||||
/// Create a copy of SnPostCategory
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
|
||||
return _SnPostPreload.fromJson(json);
|
||||
}
|
||||
|
@ -22,7 +22,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
|
||||
?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
categories: json['categories'] as List<dynamic>? ?? const [],
|
||||
categories: (json['categories'] as List<dynamic>?)
|
||||
?.map((e) => SnPostCategory.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
replies: (json['replies'] as List<dynamic>?)
|
||||
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
@ -80,7 +83,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
|
||||
'alias': instance.alias,
|
||||
'alias_prefix': instance.aliasPrefix,
|
||||
'tags': instance.tags.map((e) => e.toJson()).toList(),
|
||||
'categories': instance.categories,
|
||||
'categories': instance.categories.map((e) => e.toJson()).toList(),
|
||||
'replies': instance.replies?.map((e) => e.toJson()).toList(),
|
||||
'reply_id': instance.replyId,
|
||||
'repost_id': instance.repostId,
|
||||
@ -127,6 +130,31 @@ Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) =>
|
||||
'posts': instance.posts,
|
||||
};
|
||||
|
||||
_$SnPostCategoryImpl _$$SnPostCategoryImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnPostCategoryImpl(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'],
|
||||
alias: json['alias'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
posts: json['posts'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnPostCategoryImplToJson(
|
||||
_$SnPostCategoryImpl instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt,
|
||||
'alias': instance.alias,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'posts': instance.posts,
|
||||
};
|
||||
|
||||
_$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnPostPreloadImpl(
|
||||
thumbnail: json['thumbnail'] == null
|
||||
|
@ -32,7 +32,7 @@ class _AccountSelectState extends State<AccountSelect> {
|
||||
final List<SnAccount> _pendingUsers = List.empty(growable: true);
|
||||
final List<SnAccount> _selectedUsers = List.empty(growable: true);
|
||||
|
||||
int _accountId = 0;
|
||||
final int _accountId = 0;
|
||||
|
||||
Future<void> _revertSelectedUsers() async {
|
||||
if (widget.initialSelection?.isEmpty ?? true) return;
|
||||
|
116
lib/widgets/attachment/attachment_input.dart
Normal file
116
lib/widgets/attachment/attachment_input.dart
Normal file
@ -0,0 +1,116 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class AttachmentInputDialog extends StatefulWidget {
|
||||
final String? title;
|
||||
final bool? analyzeNow;
|
||||
const AttachmentInputDialog({super.key, required this.title, this.analyzeNow = false});
|
||||
|
||||
@override
|
||||
State<AttachmentInputDialog> createState() => _AttachmentInputDialogState();
|
||||
}
|
||||
|
||||
class _AttachmentInputDialogState extends State<AttachmentInputDialog> {
|
||||
final _randomIdController = TextEditingController();
|
||||
|
||||
XFile? _thumbnailFile;
|
||||
|
||||
void _pickImage() async {
|
||||
final picker = ImagePicker();
|
||||
final result = await picker.pickImage(source: ImageSource.gallery);
|
||||
if (result == null) return;
|
||||
setState(() => _thumbnailFile = result);
|
||||
}
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
void _finishUp() async {
|
||||
if (_isBusy) return;
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
if (_randomIdController.text.isNotEmpty) {
|
||||
try {
|
||||
final attachment = await attach.getOne(_randomIdController.text);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, attachment);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
} else if (_thumbnailFile != null) {
|
||||
try {
|
||||
final attachment = await attach.directUploadOne(
|
||||
(await _thumbnailFile!.readAsBytes()).buffer.asUint8List(),
|
||||
_thumbnailFile!.path,
|
||||
'interactive',
|
||||
null,
|
||||
analyzeNow: widget.analyzeNow ?? false,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, attachment);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title ?? 'attachmentInputDialog').tr(),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('attachmentInputUseRandomId').tr().fontSize(14),
|
||||
const Gap(8),
|
||||
TextField(
|
||||
controller: _randomIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldAttachmentRandomId'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(24),
|
||||
Text('attachmentInputNew').tr().fontSize(14),
|
||||
Card(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
leading: const Icon(Symbols.add_photo_alternate),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('addAttachmentFromAlbum').tr(),
|
||||
subtitle: _thumbnailFile == null ? Text('unset').tr() : Text('waitingForUpload').tr(),
|
||||
onTap: () {
|
||||
_pickImage();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _finishUp(),
|
||||
child: Text('dialogConfirm').tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -1,7 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@ -18,8 +20,11 @@ import 'package:uuid/uuid.dart';
|
||||
class AttachmentItem extends StatelessWidget {
|
||||
final SnAttachment? data;
|
||||
final String? heroTag;
|
||||
final BoxFit fit;
|
||||
|
||||
const AttachmentItem({
|
||||
super.key,
|
||||
this.fit = BoxFit.cover,
|
||||
required this.data,
|
||||
required this.heroTag,
|
||||
});
|
||||
@ -40,7 +45,7 @@ class AttachmentItem extends StatelessWidget {
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(data!.rid),
|
||||
key: Key('attachment-${data!.rid}-$tag'),
|
||||
fit: BoxFit.cover,
|
||||
fit: fit,
|
||||
),
|
||||
);
|
||||
case 'video':
|
||||
@ -60,10 +65,13 @@ class AttachmentItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (data!.isMature) {
|
||||
return _AttachmentItemSensitiveBlur(
|
||||
child: _buildContent(context),
|
||||
);
|
||||
if (data!.contentRating > 0) {
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
return _AttachmentItemSensitiveBlur(
|
||||
isCompact: constraints.maxHeight < 360,
|
||||
child: _buildContent(context),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return _buildContent(context);
|
||||
@ -72,15 +80,15 @@ class AttachmentItem extends StatelessWidget {
|
||||
|
||||
class _AttachmentItemSensitiveBlur extends StatefulWidget {
|
||||
final Widget child;
|
||||
const _AttachmentItemSensitiveBlur({super.key, required this.child});
|
||||
final bool isCompact;
|
||||
|
||||
const _AttachmentItemSensitiveBlur({required this.child, this.isCompact = false});
|
||||
|
||||
@override
|
||||
State<_AttachmentItemSensitiveBlur> createState() =>
|
||||
_AttachmentItemSensitiveBlurState();
|
||||
State<_AttachmentItemSensitiveBlur> createState() => _AttachmentItemSensitiveBlurState();
|
||||
}
|
||||
|
||||
class _AttachmentItemSensitiveBlurState
|
||||
extends State<_AttachmentItemSensitiveBlur> {
|
||||
class _AttachmentItemSensitiveBlurState extends State<_AttachmentItemSensitiveBlur> {
|
||||
bool _doesShow = false;
|
||||
|
||||
@override
|
||||
@ -104,24 +112,21 @@ class _AttachmentItemSensitiveBlurState
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
const Gap(8),
|
||||
Text('sensitiveContent', textAlign: TextAlign.center)
|
||||
.tr()
|
||||
.fontSize(20)
|
||||
.textColor(Colors.white)
|
||||
.bold(),
|
||||
Text(
|
||||
'sensitiveContentDescription',
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
.tr()
|
||||
.fontSize(14)
|
||||
.textColor(Colors.white.withOpacity(0.8)),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text('sensitiveContentReveal')
|
||||
if (!widget.isCompact) const Gap(8),
|
||||
if (!widget.isCompact)
|
||||
Text('sensitiveContent', textAlign: TextAlign.center)
|
||||
.tr()
|
||||
.textColor(Colors.white),
|
||||
.fontSize(20)
|
||||
.textColor(Colors.white)
|
||||
.bold(),
|
||||
if (!widget.isCompact)
|
||||
Text(
|
||||
'sensitiveContentDescription',
|
||||
textAlign: TextAlign.center,
|
||||
).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)),
|
||||
if (!widget.isCompact) const Gap(16),
|
||||
InkWell(
|
||||
child: Text('sensitiveContentReveal').tr().textColor(Colors.white),
|
||||
onTap: () {
|
||||
setState(() => _doesShow = !_doesShow);
|
||||
},
|
||||
@ -131,9 +136,7 @@ class _AttachmentItemSensitiveBlurState
|
||||
).center(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.opacity(_doesShow ? 0 : 1, animate: true)
|
||||
.animate(const Duration(milliseconds: 300), Curves.easeInOut),
|
||||
).opacity(_doesShow ? 0 : 1, animate: true).animate(const Duration(milliseconds: 300), Curves.easeInOut),
|
||||
if (_doesShow)
|
||||
Positioned(
|
||||
top: 0,
|
||||
@ -163,20 +166,19 @@ class _AttachmentItemSensitiveBlurState
|
||||
class _AttachmentItemContentVideo extends StatefulWidget {
|
||||
final SnAttachment data;
|
||||
final bool isAutoload;
|
||||
|
||||
const _AttachmentItemContentVideo({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.isAutoload = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AttachmentItemContentVideo> createState() =>
|
||||
_AttachmentItemContentVideoState();
|
||||
State<_AttachmentItemContentVideo> createState() => _AttachmentItemContentVideoState();
|
||||
}
|
||||
|
||||
class _AttachmentItemContentVideoState
|
||||
extends State<_AttachmentItemContentVideo> {
|
||||
class _AttachmentItemContentVideoState extends State<_AttachmentItemContentVideo> {
|
||||
bool _showContent = false;
|
||||
bool _showOriginal = false;
|
||||
|
||||
Player? _videoPlayer;
|
||||
VideoController? _videoController;
|
||||
@ -185,15 +187,29 @@ class _AttachmentItemContentVideoState
|
||||
setState(() => _showContent = true);
|
||||
MediaKit.ensureInitialized();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final url = sn.getAttachmentUrl(widget.data.rid);
|
||||
final url = _showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid);
|
||||
_videoPlayer = Player();
|
||||
_videoController = VideoController(_videoPlayer!);
|
||||
_videoPlayer!.open(Media(url), play: !widget.isAutoload);
|
||||
}
|
||||
|
||||
void _toggleOriginal() {
|
||||
if (!mounted) return;
|
||||
if (widget.data.compressedId == null) return;
|
||||
setState(() => _showOriginal = !_showOriginal);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
_videoPlayer?.open(
|
||||
Media(
|
||||
_showOriginal ? sn.getAttachmentUrl(widget.data.rid) : sn.getAttachmentUrl(widget.data.compressed!.rid),
|
||||
),
|
||||
play: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_showOriginal = widget.data.compressedId == null;
|
||||
if (widget.isAutoload) _startLoad();
|
||||
}
|
||||
|
||||
@ -207,7 +223,7 @@ class _AttachmentItemContentVideoState
|
||||
),
|
||||
];
|
||||
|
||||
final ratio = widget.data.metadata['ratio'] ?? 16 / 9;
|
||||
final ratio = widget.data.data['ratio'] ?? 16 / 9;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
@ -216,9 +232,9 @@ class _AttachmentItemContentVideoState
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (widget.data.metadata['thumbnail'] != null)
|
||||
if (widget.data.thumbnail != null)
|
||||
AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
|
||||
sn.getAttachmentUrl(widget.data.thumbnail!.rid),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
else
|
||||
@ -266,10 +282,7 @@ class _AttachmentItemContentVideoState
|
||||
),
|
||||
Text(
|
||||
Duration(
|
||||
milliseconds:
|
||||
(widget.data.metadata['duration'] ?? 0)
|
||||
.toInt() *
|
||||
1000,
|
||||
milliseconds: (widget.data.data['duration'] ?? 0).toInt() * 1000,
|
||||
).toString(),
|
||||
style: GoogleFonts.robotoMono(
|
||||
fontSize: 12,
|
||||
@ -301,9 +314,48 @@ class _AttachmentItemContentVideoState
|
||||
);
|
||||
}
|
||||
|
||||
return Video(
|
||||
controller: _videoController!,
|
||||
aspectRatio: ratio,
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
key: Key('material-desktop-video-controls-theme-$_showOriginal'),
|
||||
normal: MaterialDesktopVideoControlsThemeData(
|
||||
buttonBarButtonSize: 24,
|
||||
buttonBarButtonColor: Colors.white,
|
||||
topButtonBarMargin: EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
topButtonBar: [
|
||||
const Spacer(),
|
||||
MaterialDesktopCustomButton(
|
||||
iconSize: 24,
|
||||
onPressed: _toggleOriginal,
|
||||
icon: Icon(
|
||||
_showOriginal ? Symbols.high_quality : Symbols.sd,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
fullscreen: const MaterialDesktopVideoControlsThemeData(),
|
||||
child: MaterialVideoControlsTheme(
|
||||
key: Key('material-video-controls-theme-$_showOriginal'),
|
||||
normal: MaterialVideoControlsThemeData(
|
||||
buttonBarButtonSize: 24,
|
||||
buttonBarButtonColor: Colors.white,
|
||||
topButtonBarMargin: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
topButtonBar: [
|
||||
const Spacer(),
|
||||
MaterialDesktopCustomButton(
|
||||
iconSize: 24,
|
||||
onPressed: _toggleOriginal,
|
||||
icon: _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
fullscreen: const MaterialVideoControlsThemeData(),
|
||||
child: Video(
|
||||
controller: _videoController!,
|
||||
aspectRatio: ratio,
|
||||
controls:
|
||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS) ? MaterialVideoControls : MaterialDesktopVideoControls,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -317,19 +369,17 @@ class _AttachmentItemContentVideoState
|
||||
class _AttachmentItemContentAudio extends StatefulWidget {
|
||||
final SnAttachment data;
|
||||
final bool isAutoload;
|
||||
|
||||
const _AttachmentItemContentAudio({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.isAutoload = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AttachmentItemContentAudio> createState() =>
|
||||
_AttachmentItemContentAudioState();
|
||||
State<_AttachmentItemContentAudio> createState() => _AttachmentItemContentAudioState();
|
||||
}
|
||||
|
||||
class _AttachmentItemContentAudioState
|
||||
extends State<_AttachmentItemContentAudio> {
|
||||
class _AttachmentItemContentAudioState extends State<_AttachmentItemContentAudio> {
|
||||
bool _showContent = false;
|
||||
|
||||
double? _draggingValue;
|
||||
@ -378,11 +428,11 @@ class _AttachmentItemContentAudioState
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (widget.data.metadata['thumbnail'] != null)
|
||||
if (widget.data.thumbnail != null)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
|
||||
sn.getAttachmentUrl(widget.data.data['thumbnail']),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
)
|
||||
@ -463,11 +513,11 @@ class _AttachmentItemContentAudioState
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
if (widget.data.metadata['thumbnail'] != null)
|
||||
if (widget.data.data['thumbnail'] != null)
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(widget.data.metadata['thumbnail']),
|
||||
sn.getAttachmentUrl(widget.data.data['thumbnail']),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
@ -499,12 +549,8 @@ class _AttachmentItemContentAudioState
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
secondaryTrackValue: _bufferedPosition
|
||||
.inMilliseconds
|
||||
.abs()
|
||||
.toDouble(),
|
||||
value: _draggingValue?.abs() ??
|
||||
_position.inMilliseconds.toDouble().abs(),
|
||||
secondaryTrackValue: _bufferedPosition.inMilliseconds.abs().toDouble(),
|
||||
value: _draggingValue?.abs() ?? _position.inMilliseconds.toDouble().abs(),
|
||||
min: 0,
|
||||
max: math
|
||||
.max(
|
||||
@ -544,9 +590,7 @@ class _AttachmentItemContentAudioState
|
||||
),
|
||||
const Gap(16),
|
||||
IconButton.filled(
|
||||
icon: _isPlaying
|
||||
? const Icon(Symbols.pause)
|
||||
: const Icon(Symbols.play_arrow),
|
||||
icon: _isPlaying ? const Icon(Symbols.pause) : const Icon(Symbols.play_arrow),
|
||||
onPressed: () {
|
||||
_audioPlayer!.playOrPause();
|
||||
},
|
||||
|
@ -4,8 +4,8 @@ import 'package:collection/collection.dart';
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_zoom.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
@ -14,19 +14,23 @@ import 'package:uuid/uuid.dart';
|
||||
class AttachmentList extends StatefulWidget {
|
||||
final List<SnAttachment?> data;
|
||||
final bool bordered;
|
||||
final bool noGrow;
|
||||
final bool isFlatted;
|
||||
final bool gridded;
|
||||
final BoxFit fit;
|
||||
final double? maxHeight;
|
||||
final EdgeInsets? listPadding;
|
||||
final double? minWidth;
|
||||
final double? maxWidth;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const AttachmentList({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.bordered = false,
|
||||
this.noGrow = false,
|
||||
this.isFlatted = false,
|
||||
this.gridded = false,
|
||||
this.fit = BoxFit.cover,
|
||||
this.maxHeight,
|
||||
this.listPadding,
|
||||
this.minWidth,
|
||||
this.maxWidth,
|
||||
this.padding,
|
||||
});
|
||||
|
||||
static const BorderRadius kDefaultRadius = BorderRadius.all(Radius.circular(8));
|
||||
@ -41,8 +45,6 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
(_) => const Uuid().v4(),
|
||||
);
|
||||
|
||||
static const double kAttachmentMaxWidth = 640;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
@ -51,14 +53,13 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
widget.bordered ? BorderSide(width: 1, color: Theme.of(context).dividerColor) : BorderSide.none;
|
||||
final backgroundColor = Theme.of(context).colorScheme.surfaceContainer;
|
||||
final constraints = BoxConstraints(
|
||||
minWidth: 80,
|
||||
maxHeight: widget.maxHeight ?? double.infinity,
|
||||
maxWidth: layoutConstraints.maxWidth - 20,
|
||||
minWidth: widget.minWidth ?? 80,
|
||||
maxHeight: widget.maxHeight ?? MediaQuery.of(context).size.height,
|
||||
);
|
||||
|
||||
if (widget.data.isEmpty) return const SizedBox.shrink();
|
||||
if (widget.data.length == 1) {
|
||||
final singleAspectRatio = widget.data[0]?.metadata['ratio']?.toDouble() ??
|
||||
final singleAspectRatio = widget.data[0]?.data['ratio']?.toDouble() ??
|
||||
switch (widget.data[0]?.mimetype.split('/').firstOrNull) {
|
||||
'audio' => 16 / 9,
|
||||
'video' => 16 / 9,
|
||||
@ -67,115 +68,73 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
.toDouble();
|
||||
|
||||
return Container(
|
||||
constraints: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
|
||||
? constraints.copyWith(
|
||||
maxWidth: math.min(
|
||||
constraints.maxWidth,
|
||||
kAttachmentMaxWidth,
|
||||
padding: widget.padding ?? EdgeInsets.zero,
|
||||
constraints: constraints,
|
||||
child: GestureDetector(
|
||||
child: AspectRatio(
|
||||
aspectRatio: singleAspectRatio,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border.fromBorderSide(borderSide),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: widget.data[0],
|
||||
heroTag: heroTags[0],
|
||||
fit: widget.fit,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
child: AspectRatio(
|
||||
aspectRatio: singleAspectRatio,
|
||||
child: GestureDetector(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (ResponsiveBreakpoints.of(context).largerThan(MOBILE) || widget.noGrow) {
|
||||
return Padding(
|
||||
// Single child list-like displaying
|
||||
padding: widget.listPadding ?? EdgeInsets.zero,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(top: borderSide, bottom: borderSide),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: widget.data[0],
|
||||
heroTag: heroTags[0],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(top: borderSide, bottom: borderSide),
|
||||
),
|
||||
child: AttachmentItem(
|
||||
data: widget.data[0],
|
||||
heroTag: heroTags.first,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null).cast(),
|
||||
initialIndex: 0,
|
||||
heroTags: heroTags,
|
||||
),
|
||||
backgroundColor: Colors.black.withOpacity(0.7),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null).cast(),
|
||||
initialIndex: 0,
|
||||
heroTags: heroTags,
|
||||
),
|
||||
backgroundColor: Colors.black.withOpacity(0.7),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.isFlatted) {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: widget.data
|
||||
.mapIndexed(
|
||||
(idx, ele) => AspectRatio(
|
||||
aspectRatio: (ele?.metadata['ratio'] ?? 1).toDouble(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
if (widget.gridded) {
|
||||
return Container(
|
||||
margin: widget.padding ?? EdgeInsets.zero,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: StaggeredGrid.count(
|
||||
crossAxisCount: math.min(widget.data.length, 2),
|
||||
crossAxisSpacing: 4,
|
||||
mainAxisSpacing: 4,
|
||||
children: widget.data
|
||||
.mapIndexed(
|
||||
(idx, ele) => GestureDetector(
|
||||
child: Container(
|
||||
constraints: constraints,
|
||||
child: AttachmentItem(
|
||||
data: ele,
|
||||
heroTag: heroTags[idx],
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: ele,
|
||||
heroTag: heroTags[idx],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
return AspectRatio(
|
||||
aspectRatio: (widget.data.firstOrNull?.metadata['ratio'] ?? 1).toDouble(),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxHeight: constraints.maxHeight),
|
||||
child: ScrollConfiguration(
|
||||
behavior: _AttachmentListScrollBehavior(),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.data.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return Container(
|
||||
constraints: constraints,
|
||||
child: AspectRatio(
|
||||
aspectRatio: (widget.data[idx]?.metadata['ratio'] ?? 1).toDouble(),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.data[idx]!.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null).cast(),
|
||||
@ -186,44 +145,76 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxHeight: constraints.maxHeight),
|
||||
child: ScrollConfiguration(
|
||||
behavior: _AttachmentListScrollBehavior(),
|
||||
child: ListView.separated(
|
||||
padding: widget.padding,
|
||||
shrinkWrap: true,
|
||||
itemCount: widget.data.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return Container(
|
||||
constraints: constraints.copyWith(maxWidth: widget.maxWidth),
|
||||
child: AspectRatio(
|
||||
aspectRatio: (widget.data[idx]?.data['ratio'] ?? 1).toDouble(),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.data[idx]?.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
|
||||
initialIndex: idx,
|
||||
heroTags: heroTags,
|
||||
),
|
||||
backgroundColor: Colors.black.withOpacity(0.7),
|
||||
rootNavigator: true,
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: Border(
|
||||
top: borderSide,
|
||||
bottom: borderSide,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: widget.data[idx],
|
||||
heroTag: heroTags[idx],
|
||||
),
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: AttachmentList.kDefaultRadius,
|
||||
child: AttachmentItem(
|
||||
data: widget.data[idx],
|
||||
heroTag: heroTags[idx],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Chip(
|
||||
label: Text('${idx + 1}/${widget.data.length}'),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Chip(
|
||||
label: Text('${idx + 1}/${widget.data.length}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
padding: widget.listPadding,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
scrollDirection: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => const Gap(8),
|
||||
physics: const BouncingScrollPhysics(),
|
||||
scrollDirection: Axis.horizontal,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -231,7 +231,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
children: [
|
||||
IgnorePointer(
|
||||
child: AccountImage(
|
||||
content: account!.avatar,
|
||||
content: account?.avatar,
|
||||
radius: 19,
|
||||
),
|
||||
),
|
||||
@ -246,7 +246,7 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
account.nick,
|
||||
account?.nick ?? 'unknown'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
@ -312,11 +312,6 @@ class _AttachmentZoomViewState extends State<AttachmentZoomView> {
|
||||
]),
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['ShutterSpeed'] != null)
|
||||
Text(
|
||||
item.metadata['exif']?['ShutterSpeed'],
|
||||
style: metaTextStyle,
|
||||
).padding(right: 2),
|
||||
if (item.metadata['exif']?['ISO'] != null)
|
||||
Text(
|
||||
'ISO${item.metadata['exif']?['ISO']}',
|
||||
|
86
lib/widgets/attachment/pending_attachment_alt.dart
Normal file
86
lib/widgets/attachment/pending_attachment_alt.dart
Normal file
@ -0,0 +1,86 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class PendingAttachmentAltDialog extends StatefulWidget {
|
||||
final PostWriteMedia media;
|
||||
const PendingAttachmentAltDialog({super.key, required this.media});
|
||||
|
||||
@override
|
||||
State<PendingAttachmentAltDialog> createState() => _PendingAttachmentAltDialogState();
|
||||
}
|
||||
|
||||
class _PendingAttachmentAltDialogState extends State<PendingAttachmentAltDialog> {
|
||||
final _contentController = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_contentController.text = widget.media.attachment!.alt;
|
||||
}
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _performAction() async {
|
||||
if (_isBusy) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
final result = await attach.updateOne(
|
||||
widget.media.attachment!,
|
||||
alt: _contentController.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
attach.putCache([result]);
|
||||
Navigator.pop(context, result);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_contentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('attachmentSetAlt').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _contentController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldAttachmentAlt'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performAction(),
|
||||
child: Text('dialogConfirm'.tr()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
120
lib/widgets/attachment/pending_attachment_boost.dart
Normal file
120
lib/widgets/attachment/pending_attachment_boost.dart
Normal file
@ -0,0 +1,120 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class PendingAttachmentBoostDialog extends StatefulWidget {
|
||||
final PostWriteMedia media;
|
||||
|
||||
const PendingAttachmentBoostDialog({super.key, required this.media});
|
||||
|
||||
@override
|
||||
State<PendingAttachmentBoostDialog> createState() => _PendingAttachmentBoostDialogState();
|
||||
}
|
||||
|
||||
class _PendingAttachmentBoostDialogState extends State<PendingAttachmentBoostDialog> {
|
||||
List<SnAttachmentDestination>? _regions;
|
||||
SnAttachmentDestination? _selectedRegion;
|
||||
|
||||
Future<void> _fetchRegions() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/uc/destinations');
|
||||
setState(() {
|
||||
_regions = List<SnAttachmentDestination>.from(
|
||||
resp.data?.map((e) => SnAttachmentDestination.fromJson(e)) ?? [],
|
||||
).cast<SnAttachmentDestination>().where((ele) => ele.isBoost).toList();
|
||||
});
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _performAction() async {
|
||||
if (_isBusy) return;
|
||||
if (_selectedRegion == null) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.post('/cgi/uc/boosts', data: {
|
||||
'attachment': widget.media.attachment!.id,
|
||||
'destination': _selectedRegion!.id,
|
||||
});
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, SnAttachmentBoost.fromJson(resp.data));
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchRegions();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('attachmentBoost').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('attachmentBoostHint').tr(),
|
||||
const Gap(16),
|
||||
Text('attachmentDestinationRegion').tr().fontSize(18),
|
||||
const Gap(8),
|
||||
Card(
|
||||
child: _regions == null
|
||||
? const CircularProgressIndicator().center().padding(all: 16)
|
||||
: Column(
|
||||
children: _regions!.map(
|
||||
(ele) {
|
||||
return RadioListTile(
|
||||
title: Text(ele.label).tr(),
|
||||
subtitle: Text(
|
||||
'attachmentDestinationRegion${ele.region}'.trExists()
|
||||
? 'attachmentDestinationRegion${ele.region}'.tr()
|
||||
: ele.region,
|
||||
),
|
||||
selected: _selectedRegion == ele,
|
||||
value: ele,
|
||||
groupValue: _selectedRegion,
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _selectedRegion = value);
|
||||
},
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _performAction(),
|
||||
child: Text('dialogConfirm'.tr()),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
163
lib/widgets/attachment/pending_attachment_compress.dart
Normal file
163
lib/widgets/attachment/pending_attachment_compress.dart
Normal file
@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:video_compress/video_compress.dart';
|
||||
|
||||
class PendingVideoCompressDialog extends StatefulWidget {
|
||||
final PostWriteMedia media;
|
||||
|
||||
const PendingVideoCompressDialog({super.key, required this.media});
|
||||
|
||||
@override
|
||||
State<PendingVideoCompressDialog> createState() => _PendingVideoCompressDialogState();
|
||||
}
|
||||
|
||||
class _PendingVideoCompressDialogState extends State<PendingVideoCompressDialog> {
|
||||
VideoQuality _quality = VideoQuality.DefaultQuality;
|
||||
|
||||
bool _isBusy = false;
|
||||
double? _progress;
|
||||
MediaInfo? _mediaInfo;
|
||||
|
||||
Subscription? _progressSubscription;
|
||||
|
||||
Future<void> _startCompress() async {
|
||||
_mediaInfo = await VideoCompress.compressVideo(
|
||||
widget.media.file!.path,
|
||||
quality: _quality,
|
||||
deleteOrigin: false,
|
||||
frameRate: switch (_quality) {
|
||||
VideoQuality.HighestQuality => 60,
|
||||
VideoQuality.DefaultQuality => 60,
|
||||
_ => 30,
|
||||
},
|
||||
);
|
||||
if (_mediaInfo == null) return;
|
||||
setState(() => _isBusy = true);
|
||||
if (!mounted || _mediaInfo == null) return;
|
||||
Navigator.pop(context, PostWriteMedia.fromFile(XFile(_mediaInfo!.path!)));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_progressSubscription = VideoCompress.compressProgress$.subscribe((event) {
|
||||
log('[Compress] Progress: $event');
|
||||
setState(() {
|
||||
_progress = event / 100;
|
||||
_isBusy = event < 100;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progressSubscription?.unsubscribe();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('attachmentCompressVideo').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: widget.media.file?.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
return Text(
|
||||
snapshot.data!.formatBytes(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text('attachmentCompressQuality').tr(),
|
||||
const Gap(8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityHighest').tr(),
|
||||
value: VideoQuality.HighestQuality,
|
||||
groupValue: _quality,
|
||||
selected: _quality == VideoQuality.HighestQuality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityDefault').tr(),
|
||||
value: VideoQuality.DefaultQuality,
|
||||
groupValue: _quality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityMedium').tr(),
|
||||
value: VideoQuality.MediumQuality,
|
||||
groupValue: _quality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityLow').tr(),
|
||||
value: VideoQuality.LowQuality,
|
||||
groupValue: _quality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('attachmentCompressQualityHint', style: Theme.of(context).textTheme.bodySmall!).tr(),
|
||||
if (_isBusy)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: _progress ?? 0),
|
||||
duration: Duration(milliseconds: 100),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
).padding(top: 16),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : _startCompress,
|
||||
child: Text('dialogConfirm').tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_list.dart';
|
||||
import 'package:surface/widgets/context_menu.dart';
|
||||
import 'package:surface/widgets/link_preview.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
import 'package:swipe_to/swipe_to.dart';
|
||||
@ -23,6 +24,7 @@ class ChatMessage extends StatelessWidget {
|
||||
final Function(SnChatMessage)? onReply;
|
||||
final Function(SnChatMessage)? onEdit;
|
||||
final Function(SnChatMessage)? onDelete;
|
||||
final EdgeInsets padding;
|
||||
|
||||
const ChatMessage({
|
||||
super.key,
|
||||
@ -34,6 +36,7 @@ class ChatMessage extends StatelessWidget {
|
||||
this.onReply,
|
||||
this.onEdit,
|
||||
this.onDelete,
|
||||
this.padding = const EdgeInsets.only(left: 12, right: 12),
|
||||
});
|
||||
|
||||
@override
|
||||
@ -52,8 +55,8 @@ class ChatMessage extends StatelessWidget {
|
||||
iconOnRightSwipe: Symbols.edit,
|
||||
swipeSensitivity: 20,
|
||||
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
|
||||
onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null,
|
||||
child: ContextMenuRegion(
|
||||
onRightSwipe: (onEdit != null && isOwner) ? (_) => onEdit!(data) : null,
|
||||
child: ContextMenuArea(
|
||||
contextMenu: ContextMenu(
|
||||
entries: [
|
||||
MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])),
|
||||
@ -86,83 +89,89 @@ class ChatMessage extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged && !isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
)
|
||||
else if (isMerged)
|
||||
const Gap(40),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
if (isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
radius: 12,
|
||||
).padding(right: 6),
|
||||
Text(
|
||||
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
|
||||
).bold(),
|
||||
const Gap(6),
|
||||
Text(
|
||||
dateFormatter.format(data.createdAt.toLocal()),
|
||||
).fontSize(13),
|
||||
],
|
||||
),
|
||||
if (isCompact) const Gap(4),
|
||||
if (data.preload?.quoteEvent != null)
|
||||
StyledWidget(Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
Padding(
|
||||
padding: isCompact ? EdgeInsets.zero : padding,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged && !isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
)
|
||||
else if (isMerged)
|
||||
const Gap(40),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMerged)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (isCompact)
|
||||
AccountImage(
|
||||
content: user?.avatar,
|
||||
radius: 12,
|
||||
).padding(right: 8),
|
||||
Text(
|
||||
(data.sender.nick?.isNotEmpty ?? false) ? data.sender.nick! : user?.nick ?? 'unknown',
|
||||
).bold(),
|
||||
const Gap(8),
|
||||
Text(
|
||||
dateFormatter.format(data.createdAt.toLocal()),
|
||||
).fontSize(13),
|
||||
],
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 4,
|
||||
right: 4,
|
||||
top: 8,
|
||||
bottom: 6,
|
||||
),
|
||||
child: ChatMessage(
|
||||
data: data.preload!.quoteEvent!,
|
||||
isCompact: true,
|
||||
onReply: onReply,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
)).padding(bottom: 4, top: isMerged ? 4 : 2),
|
||||
switch (data.type) {
|
||||
'messages.new' => _ChatMessageText(data: data),
|
||||
_ => _ChatMessageSystemNotify(data: data),
|
||||
},
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
).opacity(isPending ? 0.5 : 1),
|
||||
if (data.body['text'] != null && (data.body['text']?.isNotEmpty ?? false))
|
||||
if (isCompact) const Gap(8),
|
||||
if (data.preload?.quoteEvent != null)
|
||||
StyledWidget(Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 4,
|
||||
right: 4,
|
||||
top: 8,
|
||||
bottom: 6,
|
||||
),
|
||||
child: ChatMessage(
|
||||
data: data.preload!.quoteEvent!,
|
||||
isCompact: true,
|
||||
onReply: onReply,
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete,
|
||||
),
|
||||
)).padding(bottom: 4, top: 4),
|
||||
switch (data.type) {
|
||||
'messages.new' => _ChatMessageText(data: data),
|
||||
_ => _ChatMessageSystemNotify(data: data),
|
||||
},
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
).opacity(isPending ? 0.5 : 1),
|
||||
),
|
||||
if (data.body['text'] != null && data.type == 'messages.new' && (data.body['text']?.isNotEmpty ?? false))
|
||||
LinkPreviewWidget(text: data.body['text']!),
|
||||
if (data.preload?.attachments?.isNotEmpty ?? false)
|
||||
AttachmentList(
|
||||
data: data.preload!.attachments!,
|
||||
bordered: true,
|
||||
noGrow: true,
|
||||
maxHeight: 520,
|
||||
listPadding: const EdgeInsets.only(top: 8),
|
||||
maxHeight: 560,
|
||||
maxWidth: 480,
|
||||
minWidth: 480,
|
||||
padding: padding.copyWith(top: 8),
|
||||
),
|
||||
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6),
|
||||
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(8),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -173,7 +182,7 @@ class ChatMessage extends StatelessWidget {
|
||||
class _ChatMessageText extends StatelessWidget {
|
||||
final SnChatMessage data;
|
||||
|
||||
const _ChatMessageText({super.key, required this.data});
|
||||
const _ChatMessageText({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -213,7 +222,7 @@ class _ChatMessageText extends StatelessWidget {
|
||||
class _ChatMessageSystemNotify extends StatelessWidget {
|
||||
final SnChatMessage data;
|
||||
|
||||
const _ChatMessageSystemNotify({super.key, required this.data});
|
||||
const _ChatMessageSystemNotify({required this.data});
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String negativeSign = duration.isNegative ? '-' : '';
|
||||
|
@ -1,21 +1,16 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/chat_message_controller.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
import 'package:surface/widgets/post/post_media_pending_list.dart';
|
||||
|
||||
class ChatMessageInput extends StatefulWidget {
|
||||
@ -37,6 +32,16 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
final TextEditingController _contentController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_contentController.addListener(() {
|
||||
if (_contentController.text.isNotEmpty) {
|
||||
widget.controller.pingTypingStatus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void setReply(SnChatMessage? value) {
|
||||
setState(() => _replyingMessage = value);
|
||||
}
|
||||
@ -80,13 +85,14 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
media.name,
|
||||
'messaging',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == PostWriteMediaType.image ? 'image/png' : null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
||||
);
|
||||
|
||||
final item = await attach.chunkedUploadParts(
|
||||
media.toFile()!,
|
||||
place.$1,
|
||||
place.$2,
|
||||
analyzeNow: media.type == SnMediaType.image,
|
||||
onProgress: (progress) {
|
||||
// Calculate overall progress for attachments
|
||||
setState(() {
|
||||
@ -123,40 +129,6 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
}
|
||||
|
||||
final List<PostWriteMedia> _attachments = List.empty(growable: true);
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
void _takeMedia(bool isVideo) async {
|
||||
final result = isVideo
|
||||
? await _imagePicker.pickVideo(source: ImageSource.camera)
|
||||
: await _imagePicker.pickImage(source: ImageSource.camera);
|
||||
if (result == null) return;
|
||||
_attachments.add(
|
||||
PostWriteMedia.fromFile(result),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _selectMedia() async {
|
||||
final result = await _imagePicker.pickMultipleMedia();
|
||||
if (result.isEmpty) return;
|
||||
_attachments.addAll(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _pasteMedia() async {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
if (imageBytes == null) return;
|
||||
_attachments.add(
|
||||
PostWriteMedia.fromBytes(
|
||||
imageBytes,
|
||||
'attachmentPastedImage'.tr(),
|
||||
PostWriteMediaType.image,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -198,75 +170,82 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
|
||||
SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: _replyingMessage != null ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
|
||||
child: _replyingMessage != null
|
||||
? MaterialBanner(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
leading: const Icon(Symbols.reply),
|
||||
backgroundColor: Colors.transparent,
|
||||
content: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_replyingMessage?.body['text'] != null)
|
||||
MarkdownTextContent(
|
||||
content: _replyingMessage?.body['text'],
|
||||
),
|
||||
],
|
||||
child: _replyingMessage != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.reply, size: 20),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_replyingMessage?.body['text'] ?? '${_replyingMessage?.sender.nick}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text('cancel'.tr()),
|
||||
onPressed: () {
|
||||
onTap: () {
|
||||
setState(() => _replyingMessage = null);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
).padding(vertical: 8),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
)
|
||||
.height(_replyingMessage != null ? 54 + 8 : 0, animate: true)
|
||||
.height(_replyingMessage != null ? 38 : 0, animate: true)
|
||||
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
|
||||
SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Padding(
|
||||
padding: _editingMessage != null ? const EdgeInsets.only(top: 8) : EdgeInsets.zero,
|
||||
child: _editingMessage != null
|
||||
? MaterialBanner(
|
||||
padding: const EdgeInsets.only(left: 16.0),
|
||||
leading: const Icon(Symbols.edit),
|
||||
backgroundColor: Colors.transparent,
|
||||
content: SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_editingMessage?.body['text'] != null)
|
||||
MarkdownTextContent(
|
||||
content: _editingMessage?.body['text'],
|
||||
),
|
||||
],
|
||||
child: _editingMessage != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.edit, size: 20),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_editingMessage?.body['text'] ?? '${_editingMessage?.sender.nick}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text('cancel'.tr()),
|
||||
onPressed: () {
|
||||
onTap: () {
|
||||
_contentController.clear();
|
||||
setState(() => _editingMessage = null);
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
).padding(vertical: 8),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
)
|
||||
.height(_editingMessage != null ? 54 + 8 : 0, animate: true)
|
||||
.height(_editingMessage != null ? 38 : 0, animate: true)
|
||||
.animate(const Duration(milliseconds: 300), Curves.fastEaseInToSlowEaseOut),
|
||||
SizedBox(
|
||||
height: 56,
|
||||
@ -294,63 +273,12 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
PopupMenuButton(
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_camera),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraPhoto').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(false);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.videocam),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraVideo').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(true);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_library),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromAlbum').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_selectMedia();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.content_paste),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromClipboard').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_pasteMedia();
|
||||
},
|
||||
),
|
||||
],
|
||||
AddPostMediaButton(
|
||||
onAdd: (items) {
|
||||
setState(() {
|
||||
_attachments.addAll(items);
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _isBusy ? null : _sendMessage,
|
||||
|
53
lib/widgets/chat/chat_typing_indicator.dart
Normal file
53
lib/widgets/chat/chat_typing_indicator.dart
Normal file
@ -0,0 +1,53 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/chat_message_controller.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
|
||||
class ChatTypingIndicator extends StatelessWidget {
|
||||
final ChatMessageController controller;
|
||||
|
||||
const ChatTypingIndicator({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
|
||||
return StyledWidget(controller.typingMembers.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.more_horiz, weight: 600, size: 20),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'messageTyping'.plural(controller.typingMembers.length, args: [
|
||||
controller.typingMembers
|
||||
.map((ele) => (ele.nick?.isNotEmpty ?? false)
|
||||
? ele.nick!
|
||||
: ud.getAccountFromCache(ele.accountId)?.name ?? 'unknown')
|
||||
.join(', '),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.height(controller.typingMembers.isNotEmpty ? 38 : 0, animate: true)
|
||||
.animate(
|
||||
const Duration(milliseconds: 300),
|
||||
Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
}
|
47
lib/widgets/context_menu.dart
Normal file
47
lib/widgets/context_menu.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
|
||||
class ContextMenuArea extends StatelessWidget {
|
||||
final ContextMenu contextMenu;
|
||||
final Widget child;
|
||||
final ValueChanged<dynamic>? onItemSelected;
|
||||
|
||||
const ContextMenuArea({
|
||||
super.key,
|
||||
required this.contextMenu,
|
||||
required this.child,
|
||||
this.onItemSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Offset mousePosition = Offset.zero;
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
mousePosition = event.position;
|
||||
final isCollapseDrawer = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
|
||||
if (!isCollapseDrawer) {
|
||||
final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET);
|
||||
// Leave padding for side navigation
|
||||
mousePosition = isExpandDrawer
|
||||
? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
|
||||
: mousePosition.copyWith(dx: mousePosition.dx - 72 * 2);
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _showMenu(context, mousePosition),
|
||||
onSecondaryTap: () => _showMenu(context, mousePosition),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMenu(BuildContext context, Offset mousePosition) async {
|
||||
final menu = contextMenu.copyWith(position: contextMenu.position ?? mousePosition);
|
||||
final value = await showContextMenu(context, contextMenu: menu);
|
||||
onItemSelected?.call(value);
|
||||
}
|
||||
}
|
@ -60,7 +60,6 @@ class _LinkPreviewEntry extends StatelessWidget {
|
||||
final SnLinkMeta meta;
|
||||
|
||||
const _LinkPreviewEntry({
|
||||
super.key,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
|
@ -1,18 +1,16 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:markdown/markdown.dart' as markdown;
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:syntax_highlight/syntax_highlight.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import 'attachment/attachment_zoom.dart';
|
||||
@ -21,6 +19,7 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
final String content;
|
||||
final bool isSelectable;
|
||||
final bool isAutoWarp;
|
||||
final bool isEnlargeSticker;
|
||||
final TextScaler? textScaler;
|
||||
final List<SnAttachment?>? attachments;
|
||||
|
||||
@ -29,6 +28,7 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
required this.content,
|
||||
this.isSelectable = false,
|
||||
this.isAutoWarp = false,
|
||||
this.isEnlargeSticker = false,
|
||||
this.textScaler,
|
||||
this.attachments,
|
||||
});
|
||||
@ -42,32 +42,33 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
styleSheet: MarkdownStyleSheet.fromTheme(
|
||||
Theme.of(context),
|
||||
).copyWith(
|
||||
textScaler: textScaler,
|
||||
blockquote: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
width: 1.0,
|
||||
color: Theme.of(context).dividerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
textScaler: textScaler,
|
||||
blockquote: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
blockquoteDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
),
|
||||
horizontalRuleDecoration: BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
width: 1.0,
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.3,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
)),
|
||||
),
|
||||
),
|
||||
codeblockDecoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 0.3,
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.5),
|
||||
),
|
||||
code: GoogleFonts.robotoMono(height: 1),
|
||||
),
|
||||
builders: {
|
||||
'code': _MarkdownTextCodeElement(),
|
||||
},
|
||||
softLineBreak: true,
|
||||
extensionSet: markdown.ExtensionSet(
|
||||
@ -78,6 +79,7 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
<markdown.InlineSyntax>[
|
||||
if (isAutoWarp) markdown.LineBreakSyntax(),
|
||||
_UserNameCardInlineSyntax(),
|
||||
_CustomEmoteInlineSyntax(context),
|
||||
markdown.AutolinkSyntax(),
|
||||
markdown.AutolinkExtensionSyntax(),
|
||||
markdown.CodeSyntax(),
|
||||
@ -108,6 +110,38 @@ class MarkdownTextContent extends StatelessWidget {
|
||||
if (url.startsWith('solink://')) {
|
||||
final segments = url.replaceFirst('solink://', '').split('/');
|
||||
switch (segments[0]) {
|
||||
case 'stickers':
|
||||
final alias = segments[1];
|
||||
final st = context.read<SnStickerProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final double size = isEnlargeSticker ? 128 : 32;
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: FutureBuilder<SnSticker?>(
|
||||
future: st.lookupSticker(alias),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return UniversalImage(
|
||||
sn.getAttachmentUrl(snapshot.data!.attachment.rid),
|
||||
fit: BoxFit.cover,
|
||||
width: size,
|
||||
height: size,
|
||||
cacheHeight: size,
|
||||
cacheWidth: size,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
case 'attachments':
|
||||
final attachment = attachments?.firstWhere(
|
||||
(ele) => ele?.rid == segments[1],
|
||||
@ -194,45 +228,24 @@ class _UserNameCardInlineSyntax extends markdown.InlineSyntax {
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkdownTextCodeElement extends MarkdownElementBuilder {
|
||||
@override
|
||||
Widget? visitElementAfter(
|
||||
markdown.Element element,
|
||||
TextStyle? preferredStyle,
|
||||
) {
|
||||
var language = '';
|
||||
class _CustomEmoteInlineSyntax extends markdown.InlineSyntax {
|
||||
final BuildContext context;
|
||||
|
||||
if (element.attributes['class'] != null) {
|
||||
String lg = element.attributes['class'] as String;
|
||||
language = lg.substring(9).trim();
|
||||
_CustomEmoteInlineSyntax(this.context) : super(r':([-\w]+):');
|
||||
|
||||
@override
|
||||
bool onMatch(markdown.InlineParser parser, Match match) {
|
||||
final SnStickerProvider st = context.read<SnStickerProvider>();
|
||||
final alias = match[1]!.toUpperCase();
|
||||
if (st.hasNotSticker(alias)) {
|
||||
parser.advanceBy(1);
|
||||
return false;
|
||||
}
|
||||
return SizedBox(
|
||||
child: FutureBuilder(
|
||||
future: (() async {
|
||||
final docPath = '../../../';
|
||||
final highlightingPath = join(docPath, 'assets/highlighting', language);
|
||||
await Highlighter.initialize([highlightingPath]);
|
||||
return Highlighter(
|
||||
language: highlightingPath,
|
||||
theme: PlatformDispatcher.instance.platformBrightness == Brightness.light
|
||||
? await HighlighterTheme.loadLightTheme()
|
||||
: await HighlighterTheme.loadDarkTheme(),
|
||||
);
|
||||
})(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
final highlighter = snapshot.data!;
|
||||
return Text.rich(
|
||||
highlighter.highlight(element.textContent.trim()),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
);
|
||||
}
|
||||
return Text(
|
||||
element.textContent.trim(),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
);
|
||||
},
|
||||
),
|
||||
).padding(all: 8);
|
||||
|
||||
final element = markdown.Element.empty('img');
|
||||
element.attributes['src'] = 'solink://stickers/$alias';
|
||||
parser.addNode(element);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
@ -18,9 +18,9 @@ import 'package:screenshot/screenshot.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/link_preview.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/reaction.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
@ -85,7 +85,6 @@ class PostItem extends StatelessWidget {
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
Provider<SnNetworkProvider>(create: (_) => context.read()),
|
||||
Provider<SnLinkPreviewProvider>(create: (_) => context.read()),
|
||||
ChangeNotifierProvider<ConfigProvider>(create: (_) => context.read()),
|
||||
],
|
||||
child: ResponsiveBreakpoints.builder(
|
||||
@ -179,6 +178,7 @@ class PostItem extends StatelessWidget {
|
||||
children: [
|
||||
if (data.visibility > 0) _PostVisibilityHint(data: data),
|
||||
_PostTruncatedHint(data: data),
|
||||
if (data.tags.isNotEmpty) _PostTagsList(data: data),
|
||||
],
|
||||
).padding(horizontal: 12),
|
||||
const Gap(8),
|
||||
@ -186,7 +186,6 @@ class PostItem extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
|
||||
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
|
||||
_PostBottomAction(
|
||||
data: data,
|
||||
showComments: showComments,
|
||||
@ -200,6 +199,10 @@ class PostItem extends StatelessWidget {
|
||||
).center();
|
||||
}
|
||||
|
||||
final displayableAttachments = data.preload?.attachments
|
||||
?.where((ele) => ele?.mediaType != SnMediaType.image || data.type != 'article')
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@ -245,16 +248,19 @@ class PostItem extends StatelessWidget {
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
|
||||
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, top: 4, bottom: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
if ((data.preload?.attachments?.isNotEmpty ?? false) && data.type != 'article')
|
||||
if (displayableAttachments?.isNotEmpty ?? false)
|
||||
AttachmentList(
|
||||
data: data.preload!.attachments!,
|
||||
data: displayableAttachments!,
|
||||
bordered: true,
|
||||
maxHeight: 560,
|
||||
listPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
gridded: true,
|
||||
maxHeight: showFullPost ? null : 480,
|
||||
minWidth: 640,
|
||||
fit: showFullPost ? BoxFit.cover : BoxFit.contain,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
if (data.body['content'] != null)
|
||||
LinkPreviewWidget(
|
||||
@ -334,17 +340,12 @@ class PostShareImageWidget extends StatelessWidget {
|
||||
_PostQuoteContent(
|
||||
child: data.repostTo!,
|
||||
isRelativeDate: false,
|
||||
isFlatted: true,
|
||||
).padding(horizontal: 16, bottom: 8),
|
||||
if (data.type != 'article' && (data.preload?.attachments?.isNotEmpty ?? false))
|
||||
AttachmentList(
|
||||
StyledWidget(AttachmentList(
|
||||
data: data.preload!.attachments!,
|
||||
isFlatted: true,
|
||||
).padding(horizontal: 16, bottom: 8),
|
||||
if (data.body['content'] != null)
|
||||
LinkPreviewWidget(
|
||||
text: data.body['content'],
|
||||
).padding(horizontal: 4),
|
||||
gridded: true,
|
||||
)).padding(horizontal: 16, bottom: 8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -549,7 +550,6 @@ class _PostHeadline extends StatelessWidget {
|
||||
final bool isEnlarge;
|
||||
|
||||
const _PostHeadline({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.isEnlarge = false,
|
||||
});
|
||||
@ -881,6 +881,7 @@ class _PostContentBody extends StatelessWidget {
|
||||
if (data.body['content'] == null) return const SizedBox.shrink();
|
||||
return MarkdownTextContent(
|
||||
isSelectable: isSelectable,
|
||||
isEnlargeSticker: true,
|
||||
textScaler: isEnlarge ? TextScaler.linear(1.1) : null,
|
||||
content: data.body['content'],
|
||||
attachments: data.preload?.attachments,
|
||||
@ -891,12 +892,9 @@ class _PostContentBody extends StatelessWidget {
|
||||
class _PostQuoteContent extends StatelessWidget {
|
||||
final SnPost child;
|
||||
final bool isRelativeDate;
|
||||
final bool isFlatted;
|
||||
|
||||
const _PostQuoteContent({
|
||||
super.key,
|
||||
this.isRelativeDate = true,
|
||||
this.isFlatted = false,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@ -938,12 +936,15 @@ class _PostQuoteContent extends StatelessWidget {
|
||||
),
|
||||
child: AttachmentList(
|
||||
data: child.preload!.attachments!,
|
||||
isFlatted: isFlatted,
|
||||
listPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
maxHeight: 360,
|
||||
minWidth: 640,
|
||||
fit: BoxFit.contain,
|
||||
gridded: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
),
|
||||
).padding(
|
||||
top: 8,
|
||||
bottom: (child.preload?.attachments?.length ?? 0) > 1 ? 12 : 0,
|
||||
bottom: 12,
|
||||
)
|
||||
else
|
||||
const Gap(8),
|
||||
@ -962,34 +963,80 @@ class _PostQuoteContent extends StatelessWidget {
|
||||
class _PostTagsList extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostTagsList({super.key, required this.data});
|
||||
const _PostTagsList({required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: data.tags
|
||||
.map(
|
||||
(ele) => InkWell(
|
||||
child: Text(
|
||||
'#${ele.alias}',
|
||||
style: TextStyle(
|
||||
decoration: TextDecoration.underline,
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: data.categories
|
||||
.map(
|
||||
(ele) => InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Symbols.category, size: 20),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postCategory${ele.alias.capitalize()}'.trExists()
|
||||
? 'postCategory${ele.alias.capitalize()}'.tr()
|
||||
: ele.alias,
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postSearch',
|
||||
queryParameters: {
|
||||
'categories': ele.alias,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
).fontSize(13),
|
||||
onTap: () {},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
).opacity(0.8);
|
||||
)
|
||||
.toList(),
|
||||
).opacity(0.8),
|
||||
Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: data.tags
|
||||
.map(
|
||||
(ele) => InkWell(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Symbols.label, size: 20),
|
||||
const Gap(4),
|
||||
Text(ele.alias, style: GoogleFonts.robotoMono()),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postSearch',
|
||||
queryParameters: {
|
||||
'tags': ele.alias,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
).opacity(0.8),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostVisibilityHint extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostVisibilityHint({super.key, required this.data});
|
||||
const _PostVisibilityHint({required this.data});
|
||||
|
||||
static const List<IconData> kVisibilityIcons = [
|
||||
Symbols.public,
|
||||
@ -1014,7 +1061,7 @@ class _PostVisibilityHint extends StatelessWidget {
|
||||
class _PostTruncatedHint extends StatelessWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostTruncatedHint({super.key, required this.data});
|
||||
const _PostTruncatedHint({required this.data});
|
||||
|
||||
static const int kHumanReadSpeed = 238;
|
||||
|
||||
@ -1023,6 +1070,7 @@ class _PostTruncatedHint extends StatelessWidget {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (data.body['content_length'] != null)
|
||||
Row(
|
||||
@ -1035,7 +1083,7 @@ class _PostTruncatedHint extends StatelessWidget {
|
||||
).inSeconds}s',
|
||||
]),
|
||||
],
|
||||
).padding(right: 8),
|
||||
),
|
||||
if (data.body['content_length'] != null)
|
||||
Row(
|
||||
children: [
|
||||
@ -1055,7 +1103,7 @@ class _PostTruncatedHint extends StatelessWidget {
|
||||
class _PostAbuseReportDialog extends StatefulWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostAbuseReportDialog({super.key, required this.data});
|
||||
const _PostAbuseReportDialog({required this.data});
|
||||
|
||||
@override
|
||||
State<_PostAbuseReportDialog> createState() => _PostAbuseReportDialogState();
|
||||
|
@ -6,15 +6,28 @@ import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_input.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_zoom.dart';
|
||||
import 'package:surface/widgets/attachment/pending_attachment_alt.dart';
|
||||
import 'package:surface/widgets/attachment/pending_attachment_boost.dart';
|
||||
import 'package:surface/widgets/context_menu.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
import '../attachment/pending_attachment_compress.dart';
|
||||
|
||||
class PostMediaPendingList extends StatelessWidget {
|
||||
final PostWriteMedia? thumbnail;
|
||||
@ -70,6 +83,37 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setThumbnail(BuildContext context, int idx) async {
|
||||
if (idx == -1) {
|
||||
// Thumbnail only can set on video or audio. And thumbnail of the post must be an image, so it's not possible to set thumbnail on the post thumbnail.
|
||||
return;
|
||||
} else if (attachments[idx].attachment == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final thumbnail = await showDialog<SnAttachment?>(
|
||||
context: context,
|
||||
builder: (context) => AttachmentInputDialog(
|
||||
title: 'attachmentSetThumbnail'.tr(),
|
||||
analyzeNow: true,
|
||||
),
|
||||
);
|
||||
if (thumbnail == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
try {
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
final newAttach = await attach.updateOne(
|
||||
attachments[idx].attachment!,
|
||||
thumbnailId: thumbnail.id,
|
||||
);
|
||||
onUpdate!(idx, PostWriteMedia(newAttach));
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteAttachment(BuildContext context, int idx) async {
|
||||
final media = idx == -1 ? thumbnail! : attachments[idx];
|
||||
if (media.attachment == null) return;
|
||||
@ -87,9 +131,79 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) {
|
||||
Future<void> _createBoost(BuildContext context, int idx) async {
|
||||
if (attachments[idx].attachment == null) return;
|
||||
|
||||
final result = await showDialog<SnAttachmentBoost?>(
|
||||
context: context,
|
||||
builder: (context) => PendingAttachmentBoostDialog(media: attachments[idx]),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
final newAttach = attachments[idx].attachment!.copyWith(
|
||||
boosts: [...attachments[idx].attachment!.boosts, result],
|
||||
);
|
||||
final newMedia = PostWriteMedia(newAttach);
|
||||
|
||||
onUpdate!(idx, newMedia);
|
||||
}
|
||||
|
||||
Future<void> _compressVideo(BuildContext context, int idx) async {
|
||||
final result = await showDialog<PostWriteMedia?>(
|
||||
context: context,
|
||||
builder: (context) => PendingVideoCompressDialog(media: attachments[idx]),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
onUpdate!(idx, result);
|
||||
}
|
||||
|
||||
Future<void> _setAlt(BuildContext context, int idx) async {
|
||||
final result = await showDialog<SnAttachment?>(
|
||||
context: context,
|
||||
builder: (context) => PendingAttachmentAltDialog(media: attachments[idx]),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
onUpdate!(idx, PostWriteMedia(result));
|
||||
}
|
||||
|
||||
ContextMenu _createContextMenu(BuildContext context, int idx, PostWriteMedia media) {
|
||||
final canCompressVideo = !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
||||
return ContextMenu(
|
||||
entries: [
|
||||
if (media.attachment == null && media.type == SnMediaType.video && canCompressVideo)
|
||||
MenuItem(
|
||||
label: 'attachmentCompressVideo'.tr(),
|
||||
icon: Symbols.compress,
|
||||
onSelected: () {
|
||||
_compressVideo(context, idx);
|
||||
},
|
||||
),
|
||||
if (media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'attachmentSetAlt'.tr(),
|
||||
icon: Symbols.description,
|
||||
onSelected: () {
|
||||
_setAlt(context, idx);
|
||||
},
|
||||
),
|
||||
if (media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'attachmentBoost'.tr(),
|
||||
icon: Symbols.bolt,
|
||||
onSelected: () {
|
||||
_createBoost(context, idx);
|
||||
},
|
||||
),
|
||||
if (media.attachment != null && media.type == SnMediaType.video)
|
||||
MenuItem(
|
||||
label: 'attachmentSetThumbnail'.tr(),
|
||||
icon: Symbols.image,
|
||||
onSelected: () {
|
||||
_setThumbnail(context, idx);
|
||||
},
|
||||
),
|
||||
if (media.attachment == null && onUpload != null)
|
||||
MenuItem(
|
||||
label: 'attachmentUpload'.tr(),
|
||||
@ -97,7 +211,7 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
onSelected: () {
|
||||
onUpload!(idx);
|
||||
}),
|
||||
if (media.attachment != null && onPostSetThumbnail != null && idx != -1)
|
||||
if (media.attachment != null && media.type == SnMediaType.image && onPostSetThumbnail != null && idx != -1)
|
||||
MenuItem(
|
||||
label: 'attachmentSetAsPostThumbnail'.tr(),
|
||||
icon: Symbols.gallery_thumbnail,
|
||||
@ -105,7 +219,7 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
onPostSetThumbnail!(idx);
|
||||
},
|
||||
)
|
||||
else if (media.attachment != null && onPostSetThumbnail != null)
|
||||
else if (media.attachment != null && media.type == SnMediaType.image && onPostSetThumbnail != null)
|
||||
MenuItem(
|
||||
label: 'attachmentUnsetAsPostThumbnail'.tr(),
|
||||
icon: Symbols.cancel,
|
||||
@ -121,7 +235,7 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
onInsertLink!(idx);
|
||||
},
|
||||
),
|
||||
if (media.type == PostWriteMediaType.image && media.attachment != null)
|
||||
if (media.type == SnMediaType.image && media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'preview'.tr(),
|
||||
icon: Symbols.preview,
|
||||
@ -132,12 +246,20 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
if (media.type == PostWriteMediaType.image && media.attachment == null)
|
||||
if (media.type == SnMediaType.image && media.attachment == null)
|
||||
MenuItem(
|
||||
label: 'crop'.tr(),
|
||||
icon: Symbols.crop,
|
||||
onSelected: () => _cropImage(context, idx),
|
||||
),
|
||||
if (media.attachment != null)
|
||||
MenuItem(
|
||||
label: 'attachmentCopyRandomId'.tr(),
|
||||
icon: Symbols.content_copy,
|
||||
onSelected: () {
|
||||
Clipboard.setData(ClipboardData(text: media.attachment!.rid));
|
||||
},
|
||||
),
|
||||
if (media.attachment != null && onRemove != null)
|
||||
MenuItem(
|
||||
label: 'delete'.tr(),
|
||||
@ -166,50 +288,15 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
child: Row(
|
||||
children: [
|
||||
const Gap(8),
|
||||
if (thumbnail != null)
|
||||
ContextMenuRegion(
|
||||
contextMenu: _buildContextMenu(context, -1, thumbnail!),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (thumbnail!.type) {
|
||||
PostWriteMediaType.image => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: thumbnail!.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio).round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio).round(),
|
||||
)!,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}),
|
||||
),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
ContextMenuArea(
|
||||
contextMenu: _createContextMenu(context, -1, thumbnail!),
|
||||
child: _PostMediaPendingItem(media: thumbnail!),
|
||||
),
|
||||
if (thumbnail != null)
|
||||
const VerticalDivider(width: 1, thickness: 1).padding(
|
||||
@ -224,42 +311,9 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final media = attachments[idx];
|
||||
return ContextMenuRegion(
|
||||
contextMenu: _buildContextMenu(context, idx, media),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
PostWriteMediaType.image => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: media.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio).round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio).round(),
|
||||
)!,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}),
|
||||
),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
return ContextMenuArea(
|
||||
contextMenu: _createContextMenu(context, idx, media),
|
||||
child: _PostMediaPendingItem(media: media),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -269,3 +323,319 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostMediaPendingItem extends StatelessWidget {
|
||||
final PostWriteMedia media;
|
||||
|
||||
const _PostMediaPendingItem({
|
||||
required this.media,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Row(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
SnMediaType.image => LayoutBuilder(builder: (context, constraints) {
|
||||
return Image(
|
||||
image: media.getImageProvider(
|
||||
context,
|
||||
width: (constraints.maxWidth * devicePixelRatio).round(),
|
||||
height: (constraints.maxHeight * devicePixelRatio).round(),
|
||||
)!,
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
}),
|
||||
SnMediaType.video => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (media.attachment?.thumbnail != null)
|
||||
AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!.rid)),
|
||||
const Icon(Symbols.videocam, color: Colors.white, shadows: [
|
||||
Shadow(
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 8.0,
|
||||
color: Color.fromARGB(255, 0, 0, 0),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
SnMediaType.audio => Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (media.attachment?.thumbnail != null)
|
||||
AutoResizeUniversalImage(sn.getAttachmentUrl(media.attachment!.thumbnail!.rid)),
|
||||
const Icon(Symbols.audio_file, color: Colors.white, shadows: [
|
||||
Shadow(
|
||||
offset: Offset(1, 1),
|
||||
blurRadius: 8.0,
|
||||
color: Color.fromARGB(255, 0, 0, 0),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
_ => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: const Icon(Symbols.docs).center(),
|
||||
),
|
||||
},
|
||||
),
|
||||
if (media.type != SnMediaType.image) const VerticalDivider(width: 1, thickness: 1),
|
||||
if (media.type != SnMediaType.image)
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (media.attachment != null)
|
||||
Text(
|
||||
media.attachment!.alt,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
else if (media.file != null)
|
||||
Text(media.file!.name, maxLines: 1, overflow: TextOverflow.ellipsis)
|
||||
else
|
||||
Text('unknown'.tr()),
|
||||
if (media.attachment != null)
|
||||
Text(
|
||||
media.attachment!.size.formatBytes(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
maxLines: 1,
|
||||
)
|
||||
else if (media.file != null)
|
||||
FutureBuilder<int?>(
|
||||
future: media.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
return Text(
|
||||
snapshot.data!.formatBytes(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
maxLines: 1,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (media.attachment != null && media.attachment!.boosts.isNotEmpty)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.bolt, size: 16),
|
||||
const Gap(4),
|
||||
Text('attachmentGotBoosted').tr().fontSize(13),
|
||||
],
|
||||
),
|
||||
if (media.attachment != null && media.attachment!.compressedId != null)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.compress, size: 16),
|
||||
const Gap(4),
|
||||
Text('attachmentCopyCompressed').tr().fontSize(13),
|
||||
],
|
||||
),
|
||||
if (media.attachment != null)
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.cloud, size: 16),
|
||||
const Gap(4),
|
||||
Text('attachmentUploaded').tr().fontSize(13),
|
||||
],
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.cloud_off, size: 16),
|
||||
const Gap(4),
|
||||
Text('attachmentPending').tr().fontSize(13),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
).padding(horizontal: 12, vertical: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddPostMediaButton extends StatelessWidget {
|
||||
final Function(Iterable<PostWriteMedia>) onAdd;
|
||||
|
||||
const AddPostMediaButton({super.key, required this.onAdd});
|
||||
|
||||
void _takeMedia(bool isVideo) async {
|
||||
final picker = ImagePicker();
|
||||
final result = isVideo
|
||||
? await picker.pickVideo(source: ImageSource.camera)
|
||||
: await picker.pickImage(source: ImageSource.camera);
|
||||
if (result == null) return;
|
||||
onAdd([PostWriteMedia.fromFile(result)]);
|
||||
}
|
||||
|
||||
void _selectMedia() async {
|
||||
final picker = ImagePicker();
|
||||
final result = await picker.pickMultipleMedia();
|
||||
if (result.isEmpty) return;
|
||||
onAdd(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
}
|
||||
|
||||
void _pasteMedia() async {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
if (imageBytes == null) return;
|
||||
onAdd([
|
||||
PostWriteMedia.fromBytes(
|
||||
imageBytes,
|
||||
'attachmentPastedImage'.tr(),
|
||||
SnMediaType.image,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
void _linkRandomId(BuildContext context) async {
|
||||
final randomIdController = TextEditingController();
|
||||
final randomId = await showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('addAttachmentFromRandomId').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: randomIdController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldAttachmentRandomId'.tr(),
|
||||
border: const UnderlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
child: Text('dialogDismiss').tr(),
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: Text('dialogConfirm').tr(),
|
||||
onPressed: () {
|
||||
Navigator.pop(context, randomIdController.text);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
randomIdController.dispose();
|
||||
});
|
||||
if (randomId == null || randomId.isEmpty) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
final attachment = await attach.getOne(randomId);
|
||||
|
||||
onAdd([
|
||||
PostWriteMedia(attachment),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopupMenuButton(
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_camera),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraPhoto').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(false);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.videocam),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraVideo').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(true);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_library),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromAlbum').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_selectMedia();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.link),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromRandomId').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_linkRandomId(context);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.content_paste),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromClipboard').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_pasteMedia();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -83,167 +83,178 @@ class PostMetaEditor extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller.titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostTitle'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
if (controller.mode == 'articles') const Gap(4),
|
||||
if (controller.mode == 'articles')
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller.descriptionController,
|
||||
maxLines: null,
|
||||
controller: controller.titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostDescription'.tr(),
|
||||
labelText: 'fieldPostTitle'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
PostTagsField(
|
||||
initialTags: controller.tags,
|
||||
labelText: 'fieldPostTags'.tr(),
|
||||
onUpdate: (value) {
|
||||
controller.setTags(value);
|
||||
},
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: controller.aliasController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostAlias'.tr(),
|
||||
helperText: 'fieldPostAliasHint'.tr(),
|
||||
helperMaxLines: 2,
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.visibility),
|
||||
title: Text('postVisibility').tr(),
|
||||
subtitle: Text('postVisibilityDescription').tr(),
|
||||
trailing: SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<int>(
|
||||
isExpanded: true,
|
||||
items: kPostVisibilityLevel.entries
|
||||
.map(
|
||||
(entry) => DropdownMenuItem<int>(
|
||||
value: entry.key,
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
).tr(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
value: controller.visibility,
|
||||
onChanged: (int? value) {
|
||||
if (value != null) {
|
||||
controller.setVisibility(value);
|
||||
}
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
height: 40,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
if (controller.mode == 'articles') const Gap(4),
|
||||
if (controller.mode == 'articles')
|
||||
TextField(
|
||||
controller: controller.descriptionController,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostDescription'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
PostTagsField(
|
||||
initialTags: controller.tags,
|
||||
labelText: 'fieldPostTags'.tr(),
|
||||
onUpdate: (value) {
|
||||
controller.setTags(value);
|
||||
},
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
PostCategoriesField(
|
||||
initialCategories: controller.categories,
|
||||
labelText: 'fieldPostCategories'.tr(),
|
||||
onUpdate: (value) {
|
||||
controller.setCategories(value);
|
||||
},
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: controller.aliasController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostAlias'.tr(),
|
||||
helperText: 'fieldPostAliasHint'.tr(),
|
||||
helperMaxLines: 2,
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.visibility),
|
||||
title: Text('postVisibility').tr(),
|
||||
subtitle: Text('postVisibilityDescription').tr(),
|
||||
trailing: SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<int>(
|
||||
isExpanded: true,
|
||||
items: kPostVisibilityLevel.entries
|
||||
.map(
|
||||
(entry) => DropdownMenuItem<int>(
|
||||
value: entry.key,
|
||||
child: Text(
|
||||
entry.value,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
).tr(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
value: controller.visibility,
|
||||
onChanged: (int? value) {
|
||||
if (value != null) {
|
||||
controller.setVisibility(value);
|
||||
}
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
height: 40,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(height: 40),
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(height: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.visibility == 2)
|
||||
if (controller.visibility == 2)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Symbols.person),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
title: Text('postVisibleUsers').tr(),
|
||||
subtitle: Text('postSelectedUsers')
|
||||
.plural(controller.visibleUsers.length),
|
||||
onTap: () {
|
||||
_selectVisibleUser(context);
|
||||
},
|
||||
),
|
||||
if (controller.visibility == 3)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Symbols.person),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
title: Text('postInvisibleUsers').tr(),
|
||||
subtitle: Text('postSelectedUsers')
|
||||
.plural(controller.invisibleUsers.length),
|
||||
onTap: () {
|
||||
_selectInvisibleUser(context);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Symbols.person),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
title: Text('postVisibleUsers').tr(),
|
||||
subtitle: Text('postSelectedUsers')
|
||||
.plural(controller.visibleUsers.length),
|
||||
leading: const Icon(Symbols.event_available),
|
||||
title: Text('postPublishedAt').tr(),
|
||||
subtitle: Text(
|
||||
controller.publishedAt != null
|
||||
? dateFormatter.format(controller.publishedAt!)
|
||||
: 'unset'.tr(),
|
||||
),
|
||||
trailing: controller.publishedAt != null
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.cancel),
|
||||
onPressed: () {
|
||||
controller.setPublishedAt(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 18),
|
||||
onTap: () {
|
||||
_selectVisibleUser(context);
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedAt,
|
||||
).then((value) {
|
||||
controller.setPublishedAt(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
if (controller.visibility == 3)
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Symbols.person),
|
||||
trailing: Icon(Symbols.chevron_right),
|
||||
title: Text('postInvisibleUsers').tr(),
|
||||
subtitle: Text('postSelectedUsers')
|
||||
.plural(controller.invisibleUsers.length),
|
||||
leading: const Icon(Symbols.event_busy),
|
||||
title: Text('postPublishedUntil').tr(),
|
||||
subtitle: Text(
|
||||
controller.publishedUntil != null
|
||||
? dateFormatter.format(controller.publishedUntil!)
|
||||
: 'unset'.tr(),
|
||||
),
|
||||
trailing: controller.publishedUntil != null
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.cancel),
|
||||
onPressed: () {
|
||||
controller.setPublishedUntil(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 18),
|
||||
onTap: () {
|
||||
_selectInvisibleUser(context);
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedUntil,
|
||||
).then((value) {
|
||||
controller.setPublishedUntil(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.event_available),
|
||||
title: Text('postPublishedAt').tr(),
|
||||
subtitle: Text(
|
||||
controller.publishedAt != null
|
||||
? dateFormatter.format(controller.publishedAt!)
|
||||
: 'unset'.tr(),
|
||||
),
|
||||
trailing: controller.publishedAt != null
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.cancel),
|
||||
onPressed: () {
|
||||
controller.setPublishedAt(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 18),
|
||||
onTap: () {
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedAt,
|
||||
).then((value) {
|
||||
controller.setPublishedAt(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.event_busy),
|
||||
title: Text('postPublishedUntil').tr(),
|
||||
subtitle: Text(
|
||||
controller.publishedUntil != null
|
||||
? dateFormatter.format(controller.publishedUntil!)
|
||||
: 'unset'.tr(),
|
||||
),
|
||||
trailing: controller.publishedUntil != null
|
||||
? IconButton(
|
||||
icon: const Icon(Symbols.cancel),
|
||||
onPressed: () {
|
||||
controller.setPublishedUntil(null);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 18),
|
||||
onTap: () {
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedUntil,
|
||||
).then((value) {
|
||||
controller.setPublishedUntil(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
).padding(vertical: 8);
|
||||
],
|
||||
).padding(vertical: 8),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
|
||||
class PostTagsField extends StatefulWidget {
|
||||
final List<String>? initialTags;
|
||||
@ -21,9 +23,9 @@ class PostTagsField extends StatefulWidget {
|
||||
State<PostTagsField> createState() => _PostTagsFieldState();
|
||||
}
|
||||
|
||||
class _PostTagsFieldState extends State<PostTagsField> {
|
||||
static const List<String> kTagsDividers = [' ', ','];
|
||||
const List<String> kTagsDividers = [' ', ','];
|
||||
|
||||
class _PostTagsFieldState extends State<PostTagsField> {
|
||||
late final _Debounceable<List<String>?, String> _debouncedSearch;
|
||||
|
||||
final List<String> _currentTags = List.empty(growable: true);
|
||||
@ -100,8 +102,7 @@ class _PostTagsFieldState extends State<PostTagsField> {
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10.0, vertical: 4.0),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@ -155,6 +156,141 @@ class _PostTagsFieldState extends State<PostTagsField> {
|
||||
}
|
||||
}
|
||||
|
||||
class PostCategoriesField extends StatefulWidget {
|
||||
final List<String>? initialCategories;
|
||||
final String labelText;
|
||||
final Function(List<String>) onUpdate;
|
||||
|
||||
const PostCategoriesField({
|
||||
super.key,
|
||||
this.initialCategories,
|
||||
required this.labelText,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PostCategoriesField> createState() => _PostCategoriesFieldState();
|
||||
}
|
||||
|
||||
class _PostCategoriesFieldState extends State<PostCategoriesField> {
|
||||
late final _Debounceable<List<String>?, String> _debouncedSearch;
|
||||
|
||||
final List<String> _currentCategories = List.empty(growable: true);
|
||||
|
||||
String? _currentSearchProbe;
|
||||
List<String> _lastAutocompleteResult = List.empty();
|
||||
TextEditingController? _textEditingController;
|
||||
|
||||
Future<List<String>?> _searchCategories(String probe) async {
|
||||
_currentSearchProbe = probe;
|
||||
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/co/categories?take=10&probe=$_currentSearchProbe',
|
||||
);
|
||||
|
||||
if (_currentSearchProbe != probe) {
|
||||
return null;
|
||||
}
|
||||
_currentSearchProbe = null;
|
||||
|
||||
return resp.data.map((x) => x['alias']).toList().cast<String>();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_debouncedSearch = _debounce<List<String>?, String>(_searchCategories);
|
||||
if (widget.initialCategories != null) {
|
||||
_currentCategories.addAll(widget.initialCategories!);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Autocomplete<String>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) async {
|
||||
final result = await _debouncedSearch(textEditingValue.text);
|
||||
if (result == null) {
|
||||
return _lastAutocompleteResult;
|
||||
}
|
||||
_lastAutocompleteResult = result;
|
||||
return result;
|
||||
},
|
||||
onSelected: (String value) {
|
||||
if (value.isEmpty) return;
|
||||
if (!_currentCategories.contains(value)) {
|
||||
setState(() => _currentCategories.add(value));
|
||||
}
|
||||
_textEditingController?.clear();
|
||||
widget.onUpdate(_currentCategories);
|
||||
},
|
||||
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
|
||||
_textEditingController = controller;
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
label: Text(widget.labelText),
|
||||
border: const UnderlineInputBorder(),
|
||||
prefixIconConstraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.75,
|
||||
),
|
||||
prefixIcon: _currentCategories.isNotEmpty
|
||||
? SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _currentCategories.map((String category) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(20.0),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
InkWell(
|
||||
child: Text(
|
||||
'postCategory${category.capitalize()}'.trExists()
|
||||
? 'postCategory${category.capitalize()}'.tr()
|
||||
: '#$category',
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
InkWell(
|
||||
child: const Icon(
|
||||
Icons.cancel,
|
||||
size: 14.0,
|
||||
color: Color.fromARGB(255, 233, 233, 233),
|
||||
),
|
||||
onTap: () {
|
||||
setState(() => _currentCategories.remove(category));
|
||||
widget.onUpdate(_currentCategories);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onSubmitted: (_) {
|
||||
onSubmitted();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
|
||||
|
||||
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {
|
||||
|
@ -28,6 +28,7 @@ import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import video_compress
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@ -54,5 +55,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
|
@ -134,7 +134,7 @@ PODS:
|
||||
- GoogleUtilities/Privacy
|
||||
- in_app_review (2.0.0):
|
||||
- FlutterMacOS
|
||||
- livekit_client (2.3.2):
|
||||
- livekit_client (2.3.4):
|
||||
- flutter_webrtc
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
@ -170,6 +170,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- video_compress (0.3.0):
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (125.6422.06)
|
||||
@ -201,6 +203,7 @@ DEPENDENCIES:
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`)
|
||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||
|
||||
SPEC REPOS:
|
||||
@ -272,6 +275,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
video_compress:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/video_compress/macos
|
||||
wakelock_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||
|
||||
@ -299,7 +304,7 @@ SPEC CHECKSUMS:
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
|
||||
livekit_client: 9fdcb22df3de55e6d4b24bdc3b5eb1c0269d774a
|
||||
livekit_client: b7ab91e79e657d7d40da16cb2f90d517cb72d406
|
||||
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
|
||||
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
|
||||
media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5
|
||||
@ -314,6 +319,7 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f
|
||||
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
|
||||
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
||||
|
||||
|
100
pubspec.lock
100
pubspec.lock
@ -490,10 +490,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: c2376a6aae82358a9f9ccdd7d1f4006d08faa39a2767cce01031d9f593a8bd3b
|
||||
sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
version: "8.1.7"
|
||||
file_saver:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -614,6 +614,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fl_chart:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fl_chart
|
||||
sha256: c724234b05e378383e958f3e82ca84a3e1e3c06a0898462044dd8a24b1ee9864
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.70.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
@ -643,6 +651,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_colorpicker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_colorpicker
|
||||
sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
flutter_context_menu:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -692,10 +708,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "1152ab0067ca5a2ebeb862fe0a762057202cceb22b7e62692dcbabf6483891bb"
|
||||
sha256: "7062602e0dbd29141fb8eb19220b5871ca650be5197ab9c1f193a28b17537bc7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.3"
|
||||
version: "2.4.4"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -737,23 +753,23 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_udid
|
||||
sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84
|
||||
sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "4.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: "430859fb5b763d7556d06ef287cfca582e17d9a2dc36da26017f25a5c0b2523e"
|
||||
sha256: e82ffd0d0b79621c5554eed73509d7f5bd286d57fef29a573846785c65237fb1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4"
|
||||
version: "0.12.5+hotfix.2"
|
||||
freezed:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -886,10 +902,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "76d306a1c3afb33fe82e2bbacad62a61f409b5634c915fceb0d799de1a913360"
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
version: "4.1.2"
|
||||
icons_launcher:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -918,10 +934,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e
|
||||
sha256: aa6f1280b670861ac45220cc95adc59bb6ae130259d36f980ccb62220dc5e59f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+18"
|
||||
version: "0.8.12+19"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -934,10 +950,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_ios
|
||||
sha256: "4f0568120c6fcc0aaa04511cb9f9f4d29fc3d0139884b1d06be88dcec7641d6b"
|
||||
sha256: "05da758e67bc7839e886b3959848aa6b44ff123ab4b28f67891008afe8ef9100"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+1"
|
||||
version: "0.8.12+2"
|
||||
image_picker_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1070,10 +1086,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: livekit_client
|
||||
sha256: "7802b5de1cae2ee3439db730d24d31c6dcbce173c5e6db2fc5774039a290bc2d"
|
||||
sha256: a19bcf8640b45e0730b1e3e3e78be7882dad680c6ebe8ae75294fd8d4612450d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
version: "2.3.4+hotfix.2"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1126,10 +1142,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: material_symbols_icons
|
||||
sha256: "64404f47f8e0a9d20478468e5decef867a688660bad7173adcd20418d7f892c9"
|
||||
sha256: "89aac72d25dd49303f71b3b1e70f8374791846729365b25bebc2a2531e5b86cd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2801.0"
|
||||
version: "4.2801.1"
|
||||
media_kit:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1338,6 +1354,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pausable_timer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pausable_timer
|
||||
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0+3"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1622,10 +1646,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
sha256: "3c7e73920c694a436afaf65ab60ce3453d91f84208d761fbd83fc21182134d93"
|
||||
sha256: a752ce92ea7540fc35a0d19722816e04d0e72828a4200e83a98cf1a1eb524c9a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.4"
|
||||
version: "2.3.5"
|
||||
shared_preferences_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1695,6 +1719,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
slide_countdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: slide_countdown
|
||||
sha256: "363914f96389502467d4dc9c0f26e88f93df3d8e37de2d5ff05b16d981fe973d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
sliver_tools:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1763,10 +1795,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_darwin
|
||||
sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474"
|
||||
sha256: "22adfd9a2c7d634041e96d6241e6e1c8138ca6817018afc5d443fef91dcefa9c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.1"
|
||||
version: "2.4.1+1"
|
||||
sqflite_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1831,14 +1863,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0+3"
|
||||
syntax_highlight:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: syntax_highlight
|
||||
sha256: ee33b6aa82cc722bb9b40152a792181dee222353b486c0255fde666a3e3a4997
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2015,6 +2039,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.0"
|
||||
video_compress:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: video_compress
|
||||
sha256: "5b42d89f3970c956bad7a86c29682b0892c11a4ddf95ae6e29897ee28788e377"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2091,10 +2123,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: "8b338d4486ab3fbc0ba0db9f9b4f5239b6697fcee427939a40e720cbb9ee0a69"
|
||||
sha256: "154360849a56b7b67331c21f09a386562d88903f90a1099c5987afc1912e1f29"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.0"
|
||||
version: "5.10.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2137,4 +2169,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.6.0 <4.0.0"
|
||||
flutter: ">=3.24.0"
|
||||
flutter: ">=3.27.0"
|
||||
|
12
pubspec.yaml
12
pubspec.yaml
@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2.1.1+37
|
||||
version: 2.2.1+48
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
@ -53,9 +53,7 @@ dependencies:
|
||||
markdown: ^7.2.2
|
||||
flutter_markdown: ^0.7.4+1
|
||||
url_launcher: ^6.3.1
|
||||
cached_network_image: ^3.4.1
|
||||
flutter_animate: ^4.5.0
|
||||
syntax_highlight: ^0.4.0
|
||||
google_fonts: ^6.2.1
|
||||
path: ^1.9.0
|
||||
relative_time: ^5.0.0
|
||||
@ -80,7 +78,7 @@ dependencies:
|
||||
firebase_core: ^3.8.0
|
||||
firebase_messaging: ^15.1.5
|
||||
firebase_analytics: ^11.3.5
|
||||
flutter_udid: ^3.0.0
|
||||
flutter_udid: ^4.0.0
|
||||
media_kit: ^1.1.11
|
||||
media_kit_video: ^1.2.5
|
||||
media_kit_libs_video: ^1.0.5
|
||||
@ -111,6 +109,12 @@ dependencies:
|
||||
flutter_app_update: ^3.2.2
|
||||
in_app_review: ^2.0.10
|
||||
version: ^3.0.2
|
||||
flutter_colorpicker: ^1.1.0
|
||||
fl_chart: ^0.70.0
|
||||
flutter_webrtc: ^0.12.5+hotfix.1
|
||||
slide_countdown: ^2.0.2
|
||||
video_compress: ^3.1.3
|
||||
cached_network_image: ^3.4.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
221
web/index.html
221
web/index.html
@ -1,130 +1,133 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" oncontextmenu="event.preventDefault();">
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="surface">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="surface">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
<title>Solian</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
|
||||
|
||||
|
||||
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
<title>Solian</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #ffffff;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
name="viewport">
|
||||
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #ffffff;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<picture id="splash-branding">
|
||||
<source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x" media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<picture id="splash-branding">
|
||||
<source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x"
|
||||
media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x"
|
||||
media="(prefers-color-scheme: dark)">
|
||||
<img class="bottom" aria-hidden="true" src="splash/img/branding-1x.png" alt="">
|
||||
</picture>
|
||||
<picture id="splash">
|
||||
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
</picture>
|
||||
<picture id="splash">
|
||||
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x"
|
||||
media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x"
|
||||
media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
|
||||
|
||||
</body></html>
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user