Compare commits

...

25 Commits

Author SHA1 Message Date
96fd64d85d 🚀 Launch 2.2.1+42 2024-12-29 22:43:58 +08:00
e236b7f98b 💄 Optimize attachment list width in post 2024-12-29 22:34:17 +08:00
5c7929e618 Post editor on device draft 2024-12-29 22:27:07 +08:00
7ba5260246 Improve image loading 2024-12-29 15:30:31 +08:00
a6d4947a23 🐛 Fix attachment list NaN height 2024-12-29 14:03:19 +08:00
7fbd4e9647 🚀 Launch 2.2.1+41 2024-12-29 12:15:22 +08:00
95d926b29f Bug fixes 2024-12-29 12:09:04 +08:00
f6cf6d0440 💄 Experimental new attachment layout 2024-12-29 12:02:26 +08:00
e503c3f02f Use analyze now for images 2024-12-29 11:09:54 +08:00
d4fbdd397e Create boost 2024-12-29 02:13:31 +08:00
03943a7138 🗑️ Remove link expand from post share 2024-12-28 20:35:43 +08:00
44f2c5fe0e Toggle original or compressed one via video control 2024-12-28 19:59:04 +08:00
bb66d5b684 Able to upload low quality video copy 2024-12-28 19:23:49 +08:00
1fca36293d 🐛 Fix attachment set thumbnail 2024-12-28 18:16:59 +08:00
2c7dc8c2ea 🐛 Fix attachment uploading progress 2024-12-28 17:37:58 +08:00
cf0df91d8c 👽 Fix attachment uploading 2024-12-28 17:19:20 +08:00
91c85e8a58 Compress video 2024-12-26 23:57:43 +08:00
2851780dda 💄 Better displaying of thumbnail on pending list 2024-12-26 23:21:33 +08:00
00fd58fb97 Better countdown on special day widget 2024-12-26 23:08:16 +08:00
ee7d0ddd25 🎨 Fix most of linting notes 2024-12-26 23:01:00 +08:00
7656c08832 ♻️ Refactored attachment loading system 2024-12-26 22:19:01 +08:00
619c90cdd9 Setting attachment thumbnail 2024-12-26 00:02:25 +08:00
168d51c9fe 📝 Add api docs 2024-12-25 00:48:25 +08:00
d4b831f98e Copy, linking attachment RID 2024-12-25 00:48:19 +08:00
4d96a15c31 🐛 Fix context menu mis placed on device which showing the side navigation 2024-12-24 23:07:47 +08:00
47 changed files with 3622 additions and 1184 deletions

View File

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

View File

@ -0,0 +1,30 @@
meta {
name: Activate Boost
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/uc/boosts/1/activate
body: none
auth: bearer
}
auth:bearer {
token: {{atk}}
}
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
}
}

View File

@ -0,0 +1,30 @@
meta {
name: Developer Notify All Users
type: http
seq: 1
}
post {
url: {{endpoint}}/cgi/id/dev/notify/all
body: json
auth: bearer
}
auth:bearer {
token: {{atk}}
}
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
View File

@ -0,0 +1,9 @@
{
"version": "1",
"name": "Solar Network",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@ -0,0 +1,8 @@
vars {
endpoint: https://api.sn.solsynth.dev
third_client_id: alphabot
}
vars:secret [
atk,
third_client_tk
]

View File

@ -281,16 +281,43 @@
"one": "{} attachment",
"other": "{} attachments"
},
"fieldAttachmentRandomId": "Random ID",
"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",
"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",
@ -431,6 +458,7 @@
"accountJoinedAt": "Joined at {}",
"accountBirthday": "Born on {}",
"accountBadge": "Badge",
"accountCheckInNoRecords": "No check-in records",
"badgeCompanyStaff": "Solsynth Staff",
"badgeSiteMigration": "Solar Network Native",
"accountStatus": "Status",
@ -439,6 +467,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",

View File

@ -279,16 +279,43 @@
"one": "{} 个附件",
"other": "{} 个附件"
},
"fieldAttachmentRandomId": "访问 ID",
"addAttachmentFromAlbum": "从相册中添加附件",
"addAttachmentFromClipboard": "粘贴附件",
"addAttachmentFromCameraPhoto": "拍摄照片",
"addAttachmentFromCameraVideo": "拍摄视频",
"addAttachmentFromRandomId": "通过访问 ID 链接",
"attachmentPastedImage": "粘贴的图片",
"attachmentInsertLink": "插入连接",
"attachmentSetAsPostThumbnail": "设置为帖子缩略图",
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
"attachmentCompressVideo": "重新编码视频",
"attachmentSetThumbnail": "设置缩略图",
"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": "无未读通知",
@ -429,6 +456,7 @@
"accountJoinedAt": "加入于 {}",
"accountBirthday": "出生于 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暂无运势记录",
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
"badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "状态",
@ -437,6 +465,7 @@
"accountStatusLastSeen": "最后一次上线于 {}",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"postLocalDraftRestored": "从本地恢复草稿",
"articleWrittenAt": "发表于 {}",
"articleEditedAt": "编辑于 {}",
"attachmentSaved": "已保存到相册",

View File

@ -279,16 +279,43 @@
"one": "{} 個附件",
"other": "{} 個附件"
},
"fieldAttachmentRandomId": "訪問 ID",
"addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝視頻",
"addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentPastedImage": "粘貼的圖片",
"attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
"attachmentCompressVideo": "重新編碼視頻",
"attachmentSetThumbnail": "設置縮略圖",
"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": "無未讀通知",
@ -429,6 +456,7 @@
"accountJoinedAt": "加入於 {}",
"accountBirthday": "出生於 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暫無運勢記錄",
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
"badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "狀態",
@ -437,6 +465,7 @@
"accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事",
"postLocalDraftRestored": "從本地恢復草稿",
"articleWrittenAt": "發表於 {}",
"articleEditedAt": "編輯於 {}",
"attachmentSaved": "已保存到相冊",

View File

@ -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,10 +115,10 @@
"publisherAffiliatedBy": "隸屬於 {}",
"publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所屬領域",
"fieldPublisherBelongToRealmUnset": "未設定釋出者所屬領域",
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
"writePostTypeStory": "發動態",
"writePostTypeArticle": "寫文章",
"fieldPostPublisher": "帖子釋出者",
"fieldPostPublisher": "帖子發佈者",
"fieldPostContent": "發生什麼事了?!",
"fieldPostTitle": "標題",
"fieldPostDescription": "描述",
@ -126,26 +126,26 @@
"fieldPostCategories": "分類",
"fieldPostAlias": "別名",
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "釋出",
"postPublishedAt": "釋出於",
"postPublishedUntil": "取消釋出於",
"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": "帖子的反應",
@ -164,7 +164,7 @@
"one": "{} 點社會信用點變更",
"other": "{} 點社會信用點變更"
},
"postReactCompleted": "反應已被新增。",
"postReactCompleted": "反應已被添加。",
"postReactUncompleted": "反應已被移除。",
"postComments": {
"zero": "評論",
@ -178,76 +178,76 @@
},
"settingsAppearance": "外觀",
"settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageDescription": "設應用全域性生效的的背景圖片。",
"settingsBackgroundImageDescription": "設應用全生效的的背景圖片。",
"settingsBackgroundImageClear": "清除現存背景圖",
"settingsBackgroundImageClearDescription": "將應用背景圖重置為空白。",
"settingsThemeMaterial3": "使用 Material You 設計正規化",
"settingsThemeMaterial3Description": "將應用主題設為 Material 3 設計正規化的主題。",
"settingsThemeMaterial3": "使用 Material You 設計範式",
"settingsThemeMaterial3Description": "將應用主題設為 Material 3 設計範式的主題。",
"settingsAppBarTransparent": "透明頂欄",
"settingsAppBarTransparentDescription": "為頂欄啟用透明效果。",
"settingsColorScheme": "主題色",
"settingsColorSchemeDescription": "設應用主題色。",
"settingsColorSchemeDescription": "設應用主題色。",
"settingsColorSeed": "預設色彩主題",
"settingsColorSeedDescription": "選擇一個預設色彩主題。",
"settingsNetwork": "網",
"settingsNetworkServer": "HyperNet 服器",
"settingsNetworkServerDescription": "設 HyperNet 服器地址,選擇我們提供的,或者自己搭建。",
"settingsNetworkServerReset": "重設為官方服器",
"settingsNetworkServerResetDescription": "重設為 Solar Network 的服器地址。",
"settingsNetworkServerPreset": "預設的 HyperNet 服器",
"settingsNetworkServerPresetDescription": "你可以在旁邊的列表中選擇我們提供的預設 HyperNet 服器地址。",
"settingsNetworkServerSaved": "服器地址已存。",
"settingsPerformance": "能",
"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": "領域別名",
@ -257,38 +257,65 @@
"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": "貼上附件",
"fieldAttachmentRandomId": "訪問 ID",
"addAttachmentFromAlbum": "從相冊中添加附件",
"addAttachmentFromClipboard": "粘貼附件",
"addAttachmentFromCameraPhoto": "拍攝照片",
"addAttachmentFromCameraVideo": "拍攝影片",
"attachmentPastedImage": "貼上的圖片",
"attachmentInsertLink": "插入連線",
"attachmentSetAsPostThumbnail": "設定為帖子縮圖",
"attachmentUnsetAsPostThumbnail": "取消設定為帖子縮圖",
"attachmentSetThumbnail": "設定縮圖",
"addAttachmentFromCameraVideo": "拍攝視頻",
"addAttachmentFromRandomId": "通過訪問 ID 鏈接",
"attachmentPastedImage": "粘貼的圖片",
"attachmentInsertLink": "插入連接",
"attachmentSetAsPostThumbnail": "設置為帖子縮圖",
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
"attachmentCompressVideo": "重新編碼視頻",
"attachmentSetThumbnail": "設置縮略圖",
"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": "無未讀通知",
@ -298,18 +325,18 @@
"notificationUnread": "未讀",
"notificationRead": "已讀",
"notificationMarkAllRead": "已讀所有通知",
"notificationMarkAllReadDescription": "您確定要將所有通知設為已讀嗎?該操作不可撤銷。",
"notificationMarkAllReadDescription": "您確定要將所有通知設為已讀嗎?該操作不可撤銷。",
"notificationMarkAllReadPrompt": {
"zero": "已將 0 個通知標記為已讀。",
"one": "已將 {} 個通知標記為已讀。",
"other": "已將 {} 個通知標記為已讀。"
},
"notificationMarkOneReadPrompt": "已將通知 {} 標記為已讀。",
"search": "搜",
"search": "搜",
"postSearchResult": {
"zero": "沒有搜到結果",
"one": "搜到 {} 個結果",
"other": "搜到 {} 個結果"
"zero": "沒有搜到結果",
"one": "搜到 {} 個結果",
"other": "搜到 {} 個結果"
},
"postSearchTook": "耗時 {}",
"postDelete": "刪除帖子 {}",
@ -321,26 +348,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": "每日簽到",
@ -396,27 +423,27 @@
"pendingFatherDay": "{} 過父親節",
"pendingHalloween": "{} 過聖誕節",
"pendingThanksgiving": "{} 過感恩節",
"friendNew": "新增好友",
"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": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。",
@ -429,23 +456,25 @@
"accountJoinedAt": "加入於 {}",
"accountBirthday": "出生於 {}",
"accountBadge": "徽章",
"accountCheckInNoRecords": "暫無運勢記錄",
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
"badgeSiteMigration": "Solar Network 原住民",
"accountStatus": "狀態",
"accountStatusOnline": "線",
"accountStatusOnline": "線",
"accountStatusOffline": "離線",
"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",
@ -453,35 +482,35 @@
"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": "自定義",

View File

@ -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)
@ -211,12 +211,11 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- 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):
@ -257,8 +256,8 @@ DEPENDENCIES:
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- 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`)
@ -344,10 +343,10 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
: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:
@ -386,7 +385,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
@ -402,9 +401,9 @@ SPEC CHECKSUMS:
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db

View File

@ -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;
}
}
@ -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;
@ -236,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(
@ -244,22 +254,139 @@ 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(),
'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'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;
@ -301,20 +428,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();
@ -368,6 +506,7 @@ class PostWriteController extends ChangeNotifier {
method: editingPost != null ? 'PUT' : 'POST',
),
);
reset();
} catch (err) {
if (!context.mounted) return;
context.showErrorDialog(err);
@ -416,56 +555,67 @@ 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();
}
@ -483,6 +633,8 @@ class PostWriteController extends ChangeNotifier {
replyingPost = null;
repostingPost = null;
mode = kTitleMap.keys.first;
temporaryRestored = false;
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
notifyListeners();
}

View File

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

View File

@ -129,7 +129,7 @@ class SpecialDayProvider {
}
double getSpecialDayProgress(DateTime last, DateTime next) {
final totalDuration = next.difference(last).inSeconds.toDouble();
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);
}

View File

@ -5,7 +5,6 @@ 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 {
@ -13,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>();
}
@ -31,9 +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}');
log('Atk: ${await atk}');
}
});
}

View File

@ -517,6 +517,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
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,

View File

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

View File

@ -201,7 +201,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
}
class _PublisherNewOrganization extends StatefulWidget {
const _PublisherNewOrganization({super.key});
const _PublisherNewOrganization();
@override
State<_PublisherNewOrganization> createState() =>

View File

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

View File

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

View File

@ -97,7 +97,6 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
}
} catch (err) {
if (!mounted) return;
print((err as DioException).response?.data);
context.showErrorDialog(err);
} finally {
setState(() => _isCalling = false);

View File

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

View File

@ -12,6 +12,7 @@ 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';
@ -109,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) {
@ -153,7 +154,7 @@ class _HomeDashUpdateWidget extends StatelessWidget {
}
class _HomeDashSpecialDayWidget extends StatelessWidget {
const _HomeDashSpecialDayWidget({super.key});
const _HomeDashSpecialDayWidget();
@override
Widget build(BuildContext context) {
@ -188,7 +189,7 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
var (name, date) = nextOne;
date = date.add(Duration(days: 1));
final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
final diff = nextOne.$2.add(-const Duration(days: 1)).difference(lastOne.$2);
final diff = nextOne.$2.difference(DateTime.now());
return Card(
child: ListTile(
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
@ -196,8 +197,15 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
subtitle: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text('${diff.inDays}d · ${(progress * 100).toStringAsFixed(2)}%'),
const Gap(8),
SlideCountdown(
duration: diff,
style: GoogleFonts.robotoMono(fontSize: 13),
separatorStyle: GoogleFonts.robotoMono(fontSize: 13),
separatorType: SeparatorType.symbol,
decoration: BoxDecoration(),
padding: EdgeInsets.zero,
),
const Gap(12),
Expanded(
child: LinearProgressIndicator(
value: progress,
@ -215,7 +223,7 @@ class _HomeDashSpecialDayWidget extends StatelessWidget {
}
class _HomeDashCheckInWidget extends StatefulWidget {
const _HomeDashCheckInWidget({super.key});
const _HomeDashCheckInWidget();
@override
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
@ -433,7 +441,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
}
class _HomeDashNotificationWidget extends StatefulWidget {
const _HomeDashNotificationWidget({super.key});
const _HomeDashNotificationWidget();
@override
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
@ -504,7 +512,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
}
class _HomeDashRecommendationPostWidget extends StatefulWidget {
const _HomeDashRecommendationPostWidget({super.key});
const _HomeDashRecommendationPostWidget();
@override
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();

View File

@ -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,11 +255,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.replyingPost != null)
Column(
children: [
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.reply).padding(left: 4),
title: Text('postReplyingNotice')
@ -304,7 +263,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
),
),
const Divider(height: 1),
],
),
@ -312,11 +270,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.repostingPost != null)
Column(
children: [
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.forward).padding(left: 4),
title: Text('postRepostingNotice')
@ -328,7 +282,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
)
],
),
),
const Divider(height: 1),
],
),
@ -336,11 +289,7 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
if (_writeController.editingPost != null)
Column(
children: [
Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
ExpansionTile(
minTileHeight: 48,
leading: const Icon(Symbols.edit_note).padding(left: 4),
title: Text('postEditingNotice')
@ -348,7 +297,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
children: <Widget>[PostItem(data: _writeController.editingPost!)],
),
),
const Divider(height: 1),
],
),
@ -416,6 +364,35 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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: _writeController.temporaryRestored
? 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,64 +412,13 @@ 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);
AddPostMediaButton(
onAdd: (items) {
setState(() {
_writeController.addAttachments(items);
});
},
),
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();
},
),
],
),
],
),
),

View File

@ -580,7 +580,6 @@ class _PublisherPostList extends StatelessWidget {
final void Function() onDeleted;
const _PublisherPostList({
super.key,
required this.isBusy,
required this.postCount,
required this.posts,

View File

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

View File

@ -33,7 +33,6 @@ Future<ThemeData> createAppTheme(
brightness: brightness,
);
final hasBackground = prefs.getBool(kAppBackgroundStoreKey) ?? false;
final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
return ThemeData(

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

View File

@ -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) {
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),
if (!widget.isCompact) const Gap(8),
if (!widget.isCompact)
Text('sensitiveContent', textAlign: TextAlign.center)
.tr()
.fontSize(20)
.textColor(Colors.white)
.bold(),
if (!widget.isCompact)
Text(
'sensitiveContentDescription',
textAlign: TextAlign.center,
)
.tr()
.fontSize(14)
.textColor(Colors.white.withOpacity(0.8)),
const Gap(16),
).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)),
if (!widget.isCompact) const Gap(16),
InkWell(
child: Text('sensitiveContentReveal')
.tr()
.textColor(Colors.white),
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,45 @@ class _AttachmentItemContentVideoState
);
}
return Video(
return MaterialDesktopVideoControlsTheme(
normal: MaterialDesktopVideoControlsThemeData(
buttonBarButtonSize: 24,
buttonBarButtonColor: Colors.white,
topButtonBarMargin: EdgeInsets.symmetric(horizontal: 12, vertical: 2),
topButtonBar: [
const Spacer(),
MaterialDesktopCustomButton(
iconSize: 24,
onPressed: _toggleOriginal,
icon: Builder(builder: (context) {
return _showOriginal ? const Icon(Symbols.high_quality, size: 24) : const Icon(Symbols.sd, size: 24);
}),
),
],
),
fullscreen: const MaterialDesktopVideoControlsThemeData(),
child: MaterialVideoControlsTheme(
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 +366,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 +425,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 +510,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 +546,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 +587,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();
},

View File

@ -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 gridded;
final bool noGrow;
final bool isFlatted;
final BoxFit fit;
final double? maxHeight;
final EdgeInsets? listPadding;
final double? minWidth;
final EdgeInsets? padding;
const AttachmentList({
super.key,
required this.data,
this.bordered = false,
this.gridded = false,
this.noGrow = false,
this.isFlatted = false,
this.fit = BoxFit.cover,
this.maxHeight,
this.listPadding,
this.minWidth,
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,27 +68,15 @@ class _AttachmentListState extends State<AttachmentList> {
.toDouble();
return Container(
constraints: ResponsiveBreakpoints.of(context).largerThan(MOBILE)
? constraints.copyWith(
maxWidth: math.min(
constraints.maxWidth,
kAttachmentMaxWidth,
),
)
: null,
padding: widget.padding ?? EdgeInsets.zero,
constraints: constraints,
child: GestureDetector(
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),
border: Border.fromBorderSide(borderSide),
borderRadius: AttachmentList.kDefaultRadius,
),
child: ClipRRect(
@ -95,25 +84,13 @@ class _AttachmentListState extends State<AttachmentList> {
child: AttachmentItem(
data: widget.data[0],
heroTag: heroTags[0],
fit: widget.fit,
),
),
),
);
}
return Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(top: borderSide, bottom: borderSide),
),
child: AttachmentItem(
data: widget.data[0],
heroTag: heroTags.first,
),
);
},
),
onTap: () {
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return;
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
@ -125,18 +102,12 @@ class _AttachmentListState extends State<AttachmentList> {
);
},
),
),
);
}
if (widget.isFlatted) {
return Wrap(
spacing: 4,
runSpacing: 4,
children: widget.data
.mapIndexed(
(idx, ele) => AspectRatio(
aspectRatio: (ele?.metadata['ratio'] ?? 1).toDouble(),
if (widget.gridded) {
return Padding(
padding: widget.padding ?? EdgeInsets.zero,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
@ -148,20 +119,44 @@ class _AttachmentListState extends State<AttachmentList> {
),
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,
),
),
onTap: () {
if (widget.data[idx]!.mediaType != SnMediaType.image) return;
context.pushTransparentRoute(
AttachmentZoomView(
data: widget.data.where((ele) => ele != null).cast(),
initialIndex: idx,
heroTags: heroTags,
),
backgroundColor: Colors.black.withOpacity(0.7),
rootNavigator: true,
);
},
),
)
.toList(),
),
),
),
);
}
return AspectRatio(
aspectRatio: (widget.data.firstOrNull?.metadata['ratio'] ?? 1).toDouble(),
aspectRatio: (widget.data.firstOrNull?.data['ratio'] ?? 1).toDouble(),
child: Container(
constraints: BoxConstraints(maxHeight: constraints.maxHeight),
child: ScrollConfiguration(
@ -173,12 +168,14 @@ class _AttachmentListState extends State<AttachmentList> {
return Container(
constraints: constraints,
child: AspectRatio(
aspectRatio: (widget.data[idx]?.metadata['ratio'] ?? 1).toDouble(),
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).cast(),
data:
widget.data.where((ele) => ele != null && ele.mediaType == SnMediaType.image).cast(),
initialIndex: idx,
heroTags: heroTags,
),
@ -220,7 +217,7 @@ class _AttachmentListState extends State<AttachmentList> {
);
},
separatorBuilder: (context, index) => const Gap(8),
padding: widget.listPadding,
padding: widget.padding,
physics: const BouncingScrollPhysics(),
scrollDirection: Axis.horizontal,
),

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

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

View File

@ -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';
@ -53,7 +54,7 @@ class ChatMessage extends StatelessWidget {
swipeSensitivity: 20,
onLeftSwipe: onReply != null ? (_) => onReply!(data) : null,
onRightSwipe: onEdit != null ? (_) => onEdit!(data) : null,
child: ContextMenuRegion(
child: ContextMenuArea(
contextMenu: ContextMenu(
entries: [
MenuHeader(text: "eventResourceTag".tr(args: ['#${data.id}'])),
@ -158,9 +159,10 @@ class ChatMessage extends StatelessWidget {
AttachmentList(
data: data.preload!.attachments!,
bordered: true,
gridded: true,
noGrow: true,
maxHeight: 520,
listPadding: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.only(top: 8),
),
if (!hasMerged && !isCompact) const Gap(12) else if (!isCompact) const Gap(6),
],
@ -173,7 +175,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 +215,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 ? '-' : '';

View File

@ -1,18 +1,14 @@
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';
@ -80,13 +76,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 +120,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() {
@ -294,64 +257,13 @@ 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);
AddPostMediaButton(
onAdd: (items) {
setState(() {
_attachments.addAll(items);
});
},
),
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();
},
),
],
),
IconButton(
onPressed: _isBusy ? null : _sendMessage,
icon: Icon(

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

View File

@ -60,7 +60,6 @@ class _LinkPreviewEntry extends StatelessWidget {
final SnLinkMeta meta;
const _LinkPreviewEntry({
super.key,
required this.meta,
});

View File

@ -18,7 +18,6 @@ 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/post.dart';
@ -85,7 +84,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(
@ -253,8 +251,11 @@ class PostItem extends StatelessWidget {
AttachmentList(
data: data.preload!.attachments!,
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 +335,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 +545,6 @@ class _PostHeadline extends StatelessWidget {
final bool isEnlarge;
const _PostHeadline({
super.key,
required this.data,
this.isEnlarge = false,
});
@ -891,12 +886,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 +930,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,7 +957,7 @@ 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) {
@ -1035,7 +1030,7 @@ class _PostTagsList extends StatelessWidget {
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,
@ -1060,7 +1055,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;
@ -1102,7 +1097,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();

View File

@ -6,15 +6,27 @@ 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_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 +82,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 +130,61 @@ 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);
}
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: '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 +192,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 +200,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 +216,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 +227,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 +269,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 +292,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 +304,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();
},
),
],
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:extended_image/extended_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
@ -7,7 +7,6 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:flutter_animate/flutter_animate.dart';
// Keep this import to make the web image render work
import 'package:cached_network_image_platform_interface/cached_network_image_platform_interface.dart';
import 'package:surface/providers/config.dart';
class UniversalImage extends StatelessWidget {
@ -34,45 +33,27 @@ class UniversalImage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final double? resizeHeight = cacheHeight != null ? (cacheHeight! * devicePixelRatio) : null;
final double? resizeWidth = cacheWidth != null ? (cacheWidth! * devicePixelRatio) : null;
final quality = filterQuality ?? context.read<ConfigProvider>().imageQuality;
return Image(
filterQuality: filterQuality ?? context.read<ConfigProvider>().imageQuality,
image: kIsWeb
? UniversalImage.provider(url)
: ResizeImage(
UniversalImage.provider(url),
width: resizeWidth?.round(),
height: resizeHeight?.round(),
policy: ResizeImagePolicy.fit,
),
return ExtendedImage.network(
url,
width: width,
height: height,
fit: fit,
loadingBuilder: noProgressIndicator
? null
: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: TweenAnimationBuilder(
tween: Tween(
begin: 0,
end: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes!
: 0,
),
duration: const Duration(milliseconds: 300),
builder: (context, value, _) => CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null ? value.toDouble() : null,
),
),
);
cache: true,
compressionRatio: kIsWeb ? 1 : switch(quality) {
FilterQuality.high => 1,
FilterQuality.medium => 0.75,
FilterQuality.low => 0.5,
FilterQuality.none => 0.25,
},
errorBuilder: noErrorWidget
? null
: (context, error, stackTrace) {
filterQuality: quality,
enableLoadState: true,
retries: 3,
loadStateChanged: (ExtendedImageState state) {
if (state.extendedImageLoadState == LoadState.completed) {
return state.completedWidget;
} else if (state.extendedImageLoadState == LoadState.failed) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Container(
@ -84,13 +65,21 @@ class UniversalImage extends StatelessWidget {
.animate(onPlay: (e) => e.repeat(reverse: true))
.fade(duration: 500.ms),
Text(
error.toString(),
state.lastException.toString(),
textAlign: TextAlign.center,
),
],
).center(),
),
);
}
return Center(
child: CircularProgressIndicator(
value: state.loadingProgress != null
? state.loadingProgress!.cumulativeBytesLoaded / state.loadingProgress!.expectedTotalBytes!
: null,
),
);
},
);
}
@ -99,9 +88,10 @@ class UniversalImage extends StatelessWidget {
// This place used to use network image or cached network image depending on the platform.
// But now the cached network image is working on every platform.
// So we just use it now.
return CachedNetworkImageProvider(
return ExtendedNetworkImageProvider(
url,
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
cache: true,
retries: 3,
);
}
}

View File

@ -26,8 +26,8 @@ import path_provider_foundation
import screen_brightness_macos
import share_plus
import shared_preferences_foundation
import sqflite_darwin
import url_launcher_macos
import video_compress
import wakelock_plus
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
@ -52,7 +52,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
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"))
}

View File

@ -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)
@ -165,11 +165,10 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- 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)
@ -199,8 +198,8 @@ DEPENDENCIES:
- screen_brightness_macos (from `Flutter/ephemeral/.symlinks/plugins/screen_brightness_macos/macos`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- 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:
@ -268,10 +267,10 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
: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 +298,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
@ -312,8 +311,8 @@ SPEC CHECKSUMS:
screen_brightness_macos: 2d6d3af2165592d9a55ffcd95b7550970e41ebda
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db

View File

@ -182,30 +182,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.9.3"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: "7c1183e361e5c8b0a0f21a28401eecdbde252441106a9816400dd4c2b2424916"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: "35814b016e37fbdc91f7ae18c8caf49ba5c88501813f73ce8a07027a395e2829"
url: "https://pub.dev"
source: hosted
version: "4.1.1"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: "980842f4e8e2535b8dbd3d5ca0b1f0ba66bf61d14cc3a17a9b4788a3685ba062"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
cassowary:
dependency: transitive
description:
@ -454,6 +430,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.7"
extended_image:
dependency: "direct main"
description:
name: extended_image
sha256: "93890a88d89ce017789f6c031c32ad8d2c685f1a5c25c169550746d973ca5e44"
url: "https://pub.dev"
source: hosted
version: "9.0.9"
extended_image_library:
dependency: transitive
description:
name: extended_image_library
sha256: "9a94ec9314aa206cfa35f16145c3cd6e2c924badcc670eaaca8a3a8063a68cd7"
url: "https://pub.dev"
source: hosted
version: "4.0.5"
fading_edge_scrollview:
dependency: transitive
description:
@ -490,10 +482,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:
@ -643,14 +635,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.2"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "400b6592f16a4409a7f2bb929a9a7e38c72cceb8ffb99ee57bbf2cb2cecf8386"
url: "https://pub.dev"
source: hosted
version: "3.4.1"
flutter_colorpicker:
dependency: "direct main"
description:
@ -753,23 +737,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: "3efe9828f19a07d29a51a726759ad0c70a840d231548a1c7d0332075a94db1df"
url: "https://pub.dev"
source: hosted
version: "0.12.4"
version: "0.12.5+hotfix.1"
freezed:
dependency: "direct dev"
description:
@ -890,6 +874,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
http_client_helper:
dependency: transitive
description:
name: http_client_helper
sha256: "8a9127650734da86b5c73760de2b404494c968a3fd55602045ffec789dac3cb1"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
http_multi_server:
dependency: transitive
description:
@ -934,10 +926,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:
@ -1086,10 +1078,10 @@ packages:
dependency: "direct main"
description:
name: livekit_client
sha256: "7802b5de1cae2ee3439db730d24d31c6dcbce173c5e6db2fc5774039a290bc2d"
sha256: "7cdeb3eaeec7fb70a4cf88d9caabccbef9e3bd5f0b23c086320bc5c9acb2770b"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "2.3.4"
logging:
dependency: transitive
description:
@ -1250,14 +1242,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "34faa6639a78c7e3cbe79be6f9f96535867e879748ade7d17c9b1ae7536293bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
@ -1354,6 +1338,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:
@ -1538,14 +1530,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
safe_local_storage:
dependency: transitive
description:
@ -1711,6 +1695,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:
@ -1751,46 +1743,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: "2d7299468485dca85efeeadf5d38986909c5eb0cd71fd3db2c2f000e6c9454bb"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "78f489aab276260cdd26676d2169446c7ecd3484bbd5fead4ca14f3ed4dd9ee3"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "761b9740ecbd4d3e66b8916d784e581861fd3c3553eda85e167bc49fdb68f709"
url: "https://pub.dev"
source: hosted
version: "2.5.4+6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "96a698e2bc82bd770a4d6aab00b42396a7c63d9e33513a56945cbccb594c2474"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
@ -2031,6 +1983,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:

View File

@ -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+39
version: 2.2.1+42
environment:
sdk: ^3.5.4
@ -53,7 +53,6 @@ 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
@ -80,7 +79,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
@ -113,6 +112,10 @@ dependencies:
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
extended_image: ^9.0.9
dev_dependencies:
flutter_test:

View File

@ -1,4 +1,6 @@
<!DOCTYPE html><html><head>
<!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.
@ -31,9 +33,8 @@
<link rel="manifest" href="manifest.json">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
name="viewport">
<style id="splash-screen-style">
@ -109,22 +110,24 @@
</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)">
</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>
</picture>
<script src="flutter_bootstrap.js" async=""></script>
<script src="flutter_bootstrap.js" async=""></script>
</body></html>
</body>
</html>