Compare commits
18 Commits
2.1.1+38
...
2c7dc8c2ea
Author | SHA1 | Date | |
---|---|---|---|
|
2c7dc8c2ea | ||
|
cf0df91d8c | ||
|
91c85e8a58 | ||
|
2851780dda | ||
|
00fd58fb97 | ||
|
ee7d0ddd25 | ||
|
7656c08832 | ||
|
619c90cdd9 | ||
|
168d51c9fe | ||
|
d4b831f98e | ||
|
4d96a15c31 | ||
|
06dd3e092a | ||
|
82fe9e287a | ||
|
dc1c285de1 | ||
|
5a3313e94f | ||
|
61032c84f1 | ||
|
36a5b8fb39 | ||
|
3eda464e03 |
@@ -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
|
||||
|
30
api/Passport/Developer Notify All Users.bru
Normal file
30
api/Passport/Developer Notify All Users.bru
Normal 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
9
api/bruno.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"version": "1",
|
||||
"name": "Solar Network",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
8
api/environments/Prod.bru
Normal file
8
api/environments/Prod.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
vars {
|
||||
endpoint: https://api.sn.solsynth.dev
|
||||
third_client_id: alphabot
|
||||
}
|
||||
vars:secret [
|
||||
atk,
|
||||
third_client_tk
|
||||
]
|
@@ -281,16 +281,23 @@
|
||||
"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",
|
||||
"notification": "Notification",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "All notifications read",
|
||||
@@ -378,9 +385,26 @@
|
||||
"dailyCheckNegativeHint5Description": "Lost connection at a crucial moment",
|
||||
"dailyCheckNegativeHint6": "Going out",
|
||||
"dailyCheckNegativeHint6Description": "Forgot your umbrella and got caught in the rain",
|
||||
"happyBirthday": "Happy birthday, {}!",
|
||||
"celebrateBirthday": "Happy birthday, {}!",
|
||||
"celebrateMerryXmas": "Merry christmas, {}!",
|
||||
"celebrateNewYear": "Happy new year, {}!",
|
||||
"celebrateValentineDay": "Today is valentine's day, {}!",
|
||||
"celebrateLaborDay": "Today is labor day, {}.",
|
||||
"celebrateMotherDay": "Today is mother's day, {}.",
|
||||
"celebrateChildrenDay": "Today is children's day, {}!",
|
||||
"celebrateFatherDay": "Today is father's day, {}.",
|
||||
"celebrateHalloween": "Happy halloween, {}!",
|
||||
"celebrateThanksgiving": "Today is thanksgiving day, {}!",
|
||||
"pendingBirthday": "Birthday in {}",
|
||||
"pendingMerryXmas": "Christmas in {}",
|
||||
"pendingNewYear": "New year in {}",
|
||||
"pendingValentineDay": "Valentine's day in {}",
|
||||
"pendingLaborDay": "Labor day in {}",
|
||||
"pendingMotherDay": "Mother's day in {}",
|
||||
"pendingChildrenDay": "Children's day in {}",
|
||||
"pendingFatherDay": "Father's day in {}",
|
||||
"pendingHalloween": "Halloween in {}",
|
||||
"pendingThanksgiving": "Thanksgiving day in {}",
|
||||
"friendNew": "Add Friend",
|
||||
"friendRequests": "Friend Requests",
|
||||
"friendRequestsDescription": {
|
||||
@@ -488,5 +512,13 @@
|
||||
"postCategoryNews": "News",
|
||||
"postCategoryKnowledge": "Knowledge",
|
||||
"postCategoryLiterature": "Literature",
|
||||
"postCategoryUncategorized": "Uncategorized"
|
||||
"postCategoryFunny": "Funny",
|
||||
"postCategoryUncategorized": "Uncategorized",
|
||||
"waitingForUpload": "Waiting for upload",
|
||||
"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."
|
||||
}
|
||||
|
@@ -279,16 +279,23 @@
|
||||
"one": "{} 个附件",
|
||||
"other": "{} 个附件"
|
||||
},
|
||||
"fieldAttachmentRandomId": "访问 ID",
|
||||
"addAttachmentFromAlbum": "从相册中添加附件",
|
||||
"addAttachmentFromClipboard": "粘贴附件",
|
||||
"addAttachmentFromCameraPhoto": "拍摄照片",
|
||||
"addAttachmentFromCameraVideo": "拍摄视频",
|
||||
"addAttachmentFromRandomId": "通过访问 ID 链接",
|
||||
"attachmentPastedImage": "粘贴的图片",
|
||||
"attachmentInsertLink": "插入连接",
|
||||
"attachmentSetAsPostThumbnail": "设置为帖子缩略图",
|
||||
"attachmentUnsetAsPostThumbnail": "取消设置为帖子缩略图",
|
||||
"attachmentCompressVideo": "重新编码视频",
|
||||
"attachmentSetThumbnail": "设置缩略图",
|
||||
"attachmentCopyRandomId": "复制访问 ID",
|
||||
"attachmentUpload": "上传",
|
||||
"attachmentInputDialog": "上传附件",
|
||||
"attachmentInputUseRandomId": "使用访问 ID",
|
||||
"attachmentInputNew": "新上传附件",
|
||||
"notification": "通知",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "无未读通知",
|
||||
@@ -376,9 +383,26 @@
|
||||
"dailyCheckNegativeHint5Description": "关键时刻断网",
|
||||
"dailyCheckNegativeHint6": "出门",
|
||||
"dailyCheckNegativeHint6Description": "忘带伞遇上大雨",
|
||||
"happyBirthday": "生日快乐,{}!",
|
||||
"celebrateBirthday": "生日快乐,{}!",
|
||||
"celebrateMerryXmas": "圣诞快乐,{}!",
|
||||
"celebrateNewYear": "新年快乐,{}!",
|
||||
"celebrateValentineDay": "今天是情人节,{}!",
|
||||
"celebrateLaborDay": "今天是劳动节,{}。",
|
||||
"celebrateMotherDay": "今天是母亲节,{}。",
|
||||
"celebrateChildrenDay": "今天是儿童节,{}!",
|
||||
"celebrateFatherDay": "今天是父亲节,{}。",
|
||||
"celebrateHalloween": "快乐在圣诞节,{}!",
|
||||
"celebrateThanksgiving": "今天是感恩节,{}!",
|
||||
"pendingBirthday": "{} 过生日",
|
||||
"pendingMerryXmas": "{} 过圣诞节",
|
||||
"pendingNewYear": "{} 跨年",
|
||||
"pendingValentineDay": "{} 过情人节",
|
||||
"pendingLaborDay": "{} 过劳动节",
|
||||
"pendingMotherDay": "{} 过母亲节",
|
||||
"pendingChildrenDay": "{} 过儿童节",
|
||||
"pendingFatherDay": "{} 过父亲节",
|
||||
"pendingHalloween": "{} 过圣诞节",
|
||||
"pendingThanksgiving": "{} 过感恩节",
|
||||
"friendNew": "添加好友",
|
||||
"friendRequests": "好友请求",
|
||||
"friendRequestsDescription": {
|
||||
@@ -486,5 +510,13 @@
|
||||
"postCategoryNews": "新闻",
|
||||
"postCategoryKnowledge": "知识",
|
||||
"postCategoryLiterature": "文学",
|
||||
"postCategoryUncategorized": "未分类"
|
||||
"postCategoryFunny": "搞笑",
|
||||
"postCategoryUncategorized": "未分类",
|
||||
"waitingForUpload": "等待上传",
|
||||
"attachmentCompressQuality": "压缩质量",
|
||||
"attachmentCompressQualityHighest": "最高",
|
||||
"attachmentCompressQualityDefault": "默认",
|
||||
"attachmentCompressQualityMedium": "中等",
|
||||
"attachmentCompressQualityLow": "低",
|
||||
"attachmentCompressQualityHint": "Solar Network 并没有阻止你上传大文件、高分辨率、高码率的视频,但是为了你的网络情况观众考虑,我们建议你选择一个合适的压缩质量。"
|
||||
}
|
||||
|
@@ -279,16 +279,23 @@
|
||||
"one": "{} 個附件",
|
||||
"other": "{} 個附件"
|
||||
},
|
||||
"fieldAttachmentRandomId": "訪問 ID",
|
||||
"addAttachmentFromAlbum": "從相冊中添加附件",
|
||||
"addAttachmentFromClipboard": "粘貼附件",
|
||||
"addAttachmentFromCameraPhoto": "拍攝照片",
|
||||
"addAttachmentFromCameraVideo": "拍攝視頻",
|
||||
"addAttachmentFromRandomId": "通過訪問 ID 鏈接",
|
||||
"attachmentPastedImage": "粘貼的圖片",
|
||||
"attachmentInsertLink": "插入連接",
|
||||
"attachmentSetAsPostThumbnail": "設置為帖子縮略圖",
|
||||
"attachmentUnsetAsPostThumbnail": "取消設置為帖子縮略圖",
|
||||
"attachmentCompressVideo": "重新編碼視頻",
|
||||
"attachmentSetThumbnail": "設置縮略圖",
|
||||
"attachmentCopyRandomId": "複製訪問 ID",
|
||||
"attachmentUpload": "上傳",
|
||||
"attachmentInputDialog": "上傳附件",
|
||||
"attachmentInputUseRandomId": "使用訪問 ID",
|
||||
"attachmentInputNew": "新上傳附件",
|
||||
"notification": "通知",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "無未讀通知",
|
||||
@@ -376,9 +383,26 @@
|
||||
"dailyCheckNegativeHint5Description": "關鍵時刻斷網",
|
||||
"dailyCheckNegativeHint6": "出門",
|
||||
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
|
||||
"happyBirthday": "生日快樂,{}!",
|
||||
"celebrateBirthday": "生日快樂,{}!",
|
||||
"celebrateMerryXmas": "聖誕快樂,{}!",
|
||||
"celebrateNewYear": "新年快樂,{}!",
|
||||
"celebrateValentineDay": "今天是情人節,{}!",
|
||||
"celebrateLaborDay": "今天是勞動節,{}。",
|
||||
"celebrateMotherDay": "今天是母親節,{}。",
|
||||
"celebrateChildrenDay": "今天是兒童節,{}!",
|
||||
"celebrateFatherDay": "今天是父親節,{}。",
|
||||
"celebrateHalloween": "快樂在聖誕節,{}!",
|
||||
"celebrateThanksgiving": "今天是感恩節,{}!",
|
||||
"pendingBirthday": "{} 過生日",
|
||||
"pendingMerryXmas": "{} 過聖誕節",
|
||||
"pendingNewYear": "{} 跨年",
|
||||
"pendingValentineDay": "{} 過情人節",
|
||||
"pendingLaborDay": "{} 過勞動節",
|
||||
"pendingMotherDay": "{} 過母親節",
|
||||
"pendingChildrenDay": "{} 過兒童節",
|
||||
"pendingFatherDay": "{} 過父親節",
|
||||
"pendingHalloween": "{} 過聖誕節",
|
||||
"pendingThanksgiving": "{} 過感恩節",
|
||||
"friendNew": "添加好友",
|
||||
"friendRequests": "好友請求",
|
||||
"friendRequestsDescription": {
|
||||
@@ -486,5 +510,13 @@
|
||||
"postCategoryNews": "新聞",
|
||||
"postCategoryKnowledge": "知識",
|
||||
"postCategoryLiterature": "文學",
|
||||
"postCategoryUncategorized": "未分類"
|
||||
"postCategoryFunny": "搞笑",
|
||||
"postCategoryUncategorized": "未分類",
|
||||
"waitingForUpload": "等待上傳",
|
||||
"attachmentCompressQuality": "壓縮質量",
|
||||
"attachmentCompressQualityHighest": "最高",
|
||||
"attachmentCompressQualityDefault": "默認",
|
||||
"attachmentCompressQualityMedium": "中等",
|
||||
"attachmentCompressQualityLow": "低",
|
||||
"attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。"
|
||||
}
|
||||
|
@@ -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,45 @@
|
||||
"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": "新上傳附件",
|
||||
"notification": "通知",
|
||||
"notificationUnreadCount": {
|
||||
"zero": "無未讀通知",
|
||||
@@ -298,18 +305,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 +328,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": "每日簽到",
|
||||
@@ -376,30 +383,47 @@
|
||||
"dailyCheckNegativeHint5Description": "關鍵時刻斷網",
|
||||
"dailyCheckNegativeHint6": "出門",
|
||||
"dailyCheckNegativeHint6Description": "忘帶傘遇上大雨",
|
||||
"happyBirthday": "生日快樂,{}!",
|
||||
"celebrateBirthday": "生日快樂,{}!",
|
||||
"celebrateMerryXmas": "聖誕快樂,{}!",
|
||||
"celebrateNewYear": "新年快樂,{}!",
|
||||
"friendNew": "新增好友",
|
||||
"celebrateValentineDay": "今天是情人節,{}!",
|
||||
"celebrateLaborDay": "今天是勞動節,{}。",
|
||||
"celebrateMotherDay": "今天是母親節,{}。",
|
||||
"celebrateChildrenDay": "今天是兒童節,{}!",
|
||||
"celebrateFatherDay": "今天是父親節,{}。",
|
||||
"celebrateHalloween": "快樂在聖誕節,{}!",
|
||||
"celebrateThanksgiving": "今天是感恩節,{}!",
|
||||
"pendingBirthday": "{} 過生日",
|
||||
"pendingMerryXmas": "{} 過聖誕節",
|
||||
"pendingNewYear": "{} 跨年",
|
||||
"pendingValentineDay": "{} 過情人節",
|
||||
"pendingLaborDay": "{} 過勞動節",
|
||||
"pendingMotherDay": "{} 過母親節",
|
||||
"pendingChildrenDay": "{} 過兒童節",
|
||||
"pendingFatherDay": "{} 過父親節",
|
||||
"pendingHalloween": "{} 過聖誕節",
|
||||
"pendingThanksgiving": "{} 過感恩節",
|
||||
"friendNew": "添加好友",
|
||||
"friendRequests": "好友請求",
|
||||
"friendRequestsDescription": {
|
||||
"zero": "你沒有好友請求",
|
||||
"one": "你有 {} 個好友請求",
|
||||
"other": "你有 {} 個好友請求"
|
||||
},
|
||||
"friendBlocklist": "遮蔽列表",
|
||||
"friendBlocklist": "屏蔽列表",
|
||||
"friendBlocklistDescription": {
|
||||
"zero": "你沒有遮蔽任何人",
|
||||
"one": "你遮蔽了 {} 個使用者",
|
||||
"other": "你遮蔽了 {} 個使用者"
|
||||
"zero": "你沒有屏蔽任何人",
|
||||
"one": "你屏蔽了 {} 個用戶",
|
||||
"other": "你屏蔽了 {} 個用戶"
|
||||
},
|
||||
"friendStatusPending": "待處理",
|
||||
"friendStatusWaiting": "等待中",
|
||||
"friendStatusActive": "正活躍",
|
||||
"friendStatusBlocked": "已遮蔽",
|
||||
"friendRequestSent": "好友請求已傳送。",
|
||||
"friendStatusBlocked": "已屏蔽",
|
||||
"friendRequestSent": "好友請求已發送。",
|
||||
"fieldFriendRelatedName": "好友名 / 賬戶 ID",
|
||||
"friendBlock": "遮蔽",
|
||||
"friendUnblock": "解除遮蔽",
|
||||
"friendBlock": "屏蔽",
|
||||
"friendUnblock": "解除屏蔽",
|
||||
"friendDeleteAction": "遺忘",
|
||||
"friendDelete": "遺忘跟 {} 的關係",
|
||||
"friendDeleteDescription": "你確定要遺忘跟 {} 的關係嗎?這個操作無法撤銷。",
|
||||
@@ -415,20 +439,20 @@
|
||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"accountStatus": "狀態",
|
||||
"accountStatusOnline": "線上",
|
||||
"accountStatusOnline": "在線",
|
||||
"accountStatusOffline": "離線",
|
||||
"accountStatusLastSeen": "最後一次上線於 {}",
|
||||
"postArticle": "Solar Network 上的文章",
|
||||
"postStory": "Solar Network 上的故事",
|
||||
"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",
|
||||
@@ -436,35 +460,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": "自定義",
|
||||
@@ -486,5 +510,13 @@
|
||||
"postCategoryNews": "新聞",
|
||||
"postCategoryKnowledge": "知識",
|
||||
"postCategoryLiterature": "文學",
|
||||
"postCategoryUncategorized": "未分類"
|
||||
"postCategoryFunny": "搞笑",
|
||||
"postCategoryUncategorized": "未分類",
|
||||
"waitingForUpload": "等待上傳",
|
||||
"attachmentCompressQuality": "壓縮質量",
|
||||
"attachmentCompressQualityHighest": "最高",
|
||||
"attachmentCompressQualityDefault": "默認",
|
||||
"attachmentCompressQualityMedium": "中等",
|
||||
"attachmentCompressQualityLow": "低",
|
||||
"attachmentCompressQualityHint": "Solar Network 並沒有阻止你上傳大文件、高分辨率、高碼率的視頻,但是為了你的網絡情況觀眾考慮,我們建議你選擇一個合適的壓縮質量。"
|
||||
}
|
||||
|
@@ -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.3):
|
||||
- Flutter
|
||||
- flutter_webrtc
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
@@ -217,6 +217,8 @@ PODS:
|
||||
- SwiftyGif (5.4.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- video_compress (0.3.0):
|
||||
- Flutter
|
||||
- volume_controller (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
@@ -259,6 +261,7 @@ DEPENDENCIES:
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_compress (from `.symlinks/plugins/video_compress/ios`)
|
||||
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
- workmanager (from `.symlinks/plugins/workmanager/ios`)
|
||||
@@ -348,6 +351,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_compress:
|
||||
:path: ".symlinks/plugins/video_compress/ios"
|
||||
volume_controller:
|
||||
:path: ".symlinks/plugins/volume_controller/ios"
|
||||
wakelock_plus:
|
||||
@@ -386,7 +391,7 @@ SPEC CHECKSUMS:
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
|
||||
Kingfisher: f2af9028b16baf9dc6c07c570072bc41cbf009ef
|
||||
livekit_client: 6108dad8b77db3142bafd4c630f471d0a54335cd
|
||||
livekit_client: 02cf2cc4357a655af12ccee70ff5596ae4e6feef
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
@@ -405,6 +410,7 @@ SPEC CHECKSUMS:
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
|
||||
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
|
||||
wakelock_plus: 373cfe59b235a6dd5837d0fb88791d2f13a90d56
|
||||
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
||||
|
@@ -80,7 +80,11 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
let metadataCopy = metadata as? [String: String] ?? [:]
|
||||
let avatarUrl = getAttachmentUrl(for: avatarIdentifier)
|
||||
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, completionHandler: { result in
|
||||
|
||||
let targetSize = 640
|
||||
let scaleProcessor = ResizingImageProcessor(referenceSize: CGSize(width: targetSize, height: targetSize), mode: .aspectFit)
|
||||
|
||||
KingfisherManager.shared.retrieveImage(with: URL(string: avatarUrl)!, options: [.processor(scaleProcessor)], completionHandler: { result in
|
||||
var image: Data?
|
||||
switch result {
|
||||
case .success(let value):
|
||||
|
@@ -15,16 +15,9 @@ 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,
|
||||
}
|
||||
|
||||
class PostWriteMedia {
|
||||
late String name;
|
||||
late PostWriteMediaType type;
|
||||
late SnMediaType type;
|
||||
final SnAttachment? attachment;
|
||||
final XFile? file;
|
||||
final Uint8List? raw;
|
||||
@@ -36,16 +29,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 +50,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +237,7 @@ 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(
|
||||
@@ -301,7 +294,7 @@ 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(
|
||||
|
@@ -30,6 +30,7 @@ import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/relationship.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/special_day.dart';
|
||||
import 'package:surface/providers/theme.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
@@ -148,6 +149,9 @@ class SolianApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
||||
|
||||
// Additional helper layer
|
||||
Provider(create: (ctx) => SpecialDayProvider(ctx)),
|
||||
],
|
||||
child: _AppDelegate(),
|
||||
),
|
||||
@@ -265,6 +269,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> {
|
||||
// The Network initialization will also save initialize the Config, so it not need to be initialized again
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.initializeUserAgent();
|
||||
await sn.setConfigWithNative();
|
||||
if (!mounted) return;
|
||||
final ua = context.read<UserProvider>();
|
||||
await ua.initialize();
|
||||
|
@@ -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;
|
||||
@@ -117,7 +118,7 @@ class SnAttachmentProvider {
|
||||
return SnAttachment.fromJson(resp.data);
|
||||
}
|
||||
|
||||
Future<(SnAttachment, int)> chunkedUploadInitialize(
|
||||
Future<(SnAttachmentFragment, int)> chunkedUploadInitialize(
|
||||
int size,
|
||||
String filename,
|
||||
String pool,
|
||||
@@ -134,7 +135,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,17 +144,17 @@ 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,
|
||||
}) async {
|
||||
final resp = await _sn.client.post(
|
||||
'/cgi/uc/attachments/multipart/$rid/$cid',
|
||||
'/cgi/uc/fragments/$rid/$cid',
|
||||
data: data,
|
||||
options: Options(headers: {'Content-Type': 'application/octet-stream'}),
|
||||
onSendProgress: (count, total) {
|
||||
@@ -163,21 +164,27 @@ 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,
|
||||
}) async {
|
||||
final Map<String, dynamic> chunks = place.fileChunks ?? {};
|
||||
final Map<String, dynamic> chunks = place.fileChunks;
|
||||
var currentTask = 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 +194,25 @@ 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,
|
||||
onProgress: (progress) {
|
||||
final overallProgress = (currentTask + progress) / chunks.length;
|
||||
onProgress?.call(overallProgress);
|
||||
},
|
||||
);
|
||||
|
||||
currentTask++;
|
||||
final overallProgress = currentTask / chunks.length;
|
||||
onProgress?.call(overallProgress);
|
||||
|
||||
currentTask++;
|
||||
if (result is SnAttachmentFragment) {
|
||||
place = result;
|
||||
} else {
|
||||
out = result as SnAttachment;
|
||||
}
|
||||
}());
|
||||
}
|
||||
|
||||
@@ -213,6 +229,24 @@ class SnAttachmentProvider {
|
||||
}
|
||||
}
|
||||
|
||||
return place;
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<SnAttachment> updateOne(
|
||||
int id, {
|
||||
String? alt,
|
||||
int? thumbnailId,
|
||||
int? compressedId,
|
||||
Map<String, dynamic>? metadata,
|
||||
bool? isIndexable,
|
||||
}) async {
|
||||
final resp = await _sn.client.put('/cgi/uc/attachments/$id', data: {
|
||||
'alt': alt,
|
||||
'thumbnail': thumbnailId,
|
||||
'compressed': compressedId,
|
||||
'metadata': metadata,
|
||||
'is_indexable': isIndexable,
|
||||
});
|
||||
return SnAttachment.fromJson(resp.data);
|
||||
}
|
||||
}
|
||||
|
@@ -68,9 +68,8 @@ class SnNetworkProvider {
|
||||
_config.initialize().then((_) {
|
||||
_prefs = _config.prefs;
|
||||
client.options.baseUrl = _config.serverUrl;
|
||||
if (!context.mounted) return;
|
||||
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
static Future<Dio> createOffContextClient() async {
|
||||
@@ -109,6 +108,10 @@ class SnNetworkProvider {
|
||||
return client;
|
||||
}
|
||||
|
||||
Future<void> setConfigWithNative() async {
|
||||
_home.saveWidgetData("nex_server_url", client.options.baseUrl);
|
||||
}
|
||||
|
||||
static Future<String> _getUserAgent() async {
|
||||
final String platformInfo;
|
||||
if (kIsWeb) {
|
||||
|
136
lib/providers/special_day.dart
Normal file
136
lib/providers/special_day.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
|
||||
// Stored as key: month, day
|
||||
const Map<String, (int, int)> kSpecialDays = {
|
||||
// Birthday is dynamically generated according to the user's profile
|
||||
'NewYear': (1, 1),
|
||||
'ValentineDay': (2, 14),
|
||||
'LaborDay': (5, 1),
|
||||
'MotherDay': (5, 11),
|
||||
'ChildrenDay': (6, 1),
|
||||
'FatherDay': (8, 8),
|
||||
'Halloween': (10, 31),
|
||||
'Thanksgiving': (11, 28),
|
||||
'MerryXmas': (12, 25),
|
||||
};
|
||||
|
||||
const Map<String, String> kSpecialDaysSymbol = {
|
||||
'Birthday': '🎂',
|
||||
'NewYear': '🎉',
|
||||
'MerryXmas': '🎄',
|
||||
'ValentineDay': '💑',
|
||||
'LaborDay': '🏋️',
|
||||
'MotherDay': '👩',
|
||||
'ChildrenDay': '👶',
|
||||
'FatherDay': '👨',
|
||||
'Halloween': '🎃',
|
||||
'Thanksgiving': '🎅',
|
||||
};
|
||||
|
||||
class SpecialDayProvider {
|
||||
late final UserProvider _user;
|
||||
|
||||
SpecialDayProvider(BuildContext context) {
|
||||
_user = context.read<UserProvider>();
|
||||
}
|
||||
|
||||
List<String> getSpecialDays() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final birthday = _user.user?.profile?.birthday?.toLocal();
|
||||
final isBirthday = birthday != null && birthday.day == now.day && birthday.month == now.month;
|
||||
|
||||
return [
|
||||
if (isBirthday) 'Birthday',
|
||||
...kSpecialDays.keys.where(
|
||||
(key) => kSpecialDays[key]!.$1 == now.month && kSpecialDays[key]!.$2 == now.day,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
(String, DateTime)? getLastSpecialDay() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final birthday = _user.user?.profile?.birthday?.toLocal();
|
||||
|
||||
final Map<String, (int, int)> specialDays = {
|
||||
if (birthday != null) 'Birthday': (birthday.month, birthday.day),
|
||||
...kSpecialDays,
|
||||
};
|
||||
|
||||
DateTime? lastDate;
|
||||
String? lastEvent;
|
||||
|
||||
for (final entry in specialDays.entries) {
|
||||
final eventName = entry.key;
|
||||
final (month, day) = entry.value;
|
||||
|
||||
var specialDayThisYear = DateTime(now.year, month, day);
|
||||
var specialDayLastYear = DateTime(now.year - 1, month, day);
|
||||
|
||||
if (specialDayThisYear.isBefore(now)) {
|
||||
if (lastDate == null || specialDayThisYear.isAfter(lastDate)) {
|
||||
lastDate = specialDayThisYear;
|
||||
lastEvent = eventName;
|
||||
}
|
||||
} else if (specialDayLastYear.isBefore(now)) {
|
||||
if (lastDate == null || specialDayLastYear.isAfter(lastDate)) {
|
||||
lastDate = specialDayLastYear;
|
||||
lastEvent = eventName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastEvent != null && lastDate != null) {
|
||||
return (lastEvent, lastDate);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
(String, DateTime)? getNextSpecialDay() {
|
||||
final now = DateTime.now().toLocal();
|
||||
final birthday = _user.user?.profile?.birthday?.toLocal();
|
||||
|
||||
// Stored as key: month, day
|
||||
final Map<String, (int, int)> specialDays = {
|
||||
if (birthday != null) 'Birthday': (birthday.month, birthday.day),
|
||||
...kSpecialDays,
|
||||
};
|
||||
|
||||
DateTime? closestDate;
|
||||
String? closestEvent;
|
||||
|
||||
for (final entry in specialDays.entries) {
|
||||
final eventName = entry.key;
|
||||
final (month, day) = entry.value;
|
||||
|
||||
// Calculate the special day's DateTime in the current year
|
||||
var specialDay = DateTime(now.year, month, day);
|
||||
|
||||
// If the special day has already passed this year, consider it for the next year
|
||||
if (specialDay.isBefore(now)) {
|
||||
specialDay = DateTime(now.year + 1, month, day);
|
||||
}
|
||||
|
||||
// Check if this special day is closer than the previously found one
|
||||
if (closestDate == null || specialDay.isBefore(closestDate)) {
|
||||
closestDate = specialDay;
|
||||
closestEvent = eventName;
|
||||
}
|
||||
}
|
||||
|
||||
if (closestEvent != null && closestDate != null) {
|
||||
return (closestEvent, closestDate);
|
||||
}
|
||||
|
||||
// No special day found
|
||||
return null;
|
||||
}
|
||||
|
||||
double getSpecialDayProgress(DateTime last, DateTime next) {
|
||||
final totalDuration = next.add(-const Duration(days: 1)).difference(last).inSeconds.toDouble();
|
||||
final elapsedDuration = DateTime.now().difference(last).inSeconds.toDouble();
|
||||
return (elapsedDuration / totalDuration).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
@@ -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,10 +28,10 @@ class UserProvider extends ChangeNotifier {
|
||||
final value = _config.prefs.getString(kAtkStoreKey);
|
||||
isAuthorized = value != null;
|
||||
notifyListeners();
|
||||
refreshUser().then((value) {
|
||||
refreshUser().then((value) async {
|
||||
if (value != null) {
|
||||
log('Logged in as @${value.name}');
|
||||
_home.saveWidgetData('user', value.toJson());
|
||||
log('Atk: ${await atk}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -13,7 +13,7 @@ class HomeWidgetProvider {
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
|
||||
if (!kIsWeb && Platform.isIOS) {
|
||||
if (Platform.isIOS) {
|
||||
await HomeWidget.setAppGroupId("group.solsynth.solian");
|
||||
}
|
||||
}
|
||||
|
@@ -22,15 +22,14 @@ import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
class AccountPublisherEditScreen extends StatefulWidget {
|
||||
final String name;
|
||||
|
||||
const AccountPublisherEditScreen({super.key, required this.name});
|
||||
|
||||
@override
|
||||
State<AccountPublisherEditScreen> createState() =>
|
||||
_AccountPublisherEditScreenState();
|
||||
State<AccountPublisherEditScreen> createState() => _AccountPublisherEditScreenState();
|
||||
}
|
||||
|
||||
class _AccountPublisherEditScreenState
|
||||
extends State<AccountPublisherEditScreen> {
|
||||
class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen> {
|
||||
bool _isBusy = false;
|
||||
|
||||
SnPublisher? _publisher;
|
||||
@@ -54,7 +53,7 @@ class _AccountPublisherEditScreenState
|
||||
_publisher = SnPublisher.fromJson(resp.data);
|
||||
_syncWidget();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@@ -75,9 +74,9 @@ class _AccountPublisherEditScreenState
|
||||
'name': _nameController.text,
|
||||
'description': _descriptionController.text,
|
||||
});
|
||||
Navigator.pop(context, true);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if(mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@@ -108,11 +107,9 @@ class _AccountPublisherEditScreenState
|
||||
if (image == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final ImageProvider imageProvider =
|
||||
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios = place == 'banner'
|
||||
? [CropAspectRatio(width: 16, height: 7)]
|
||||
: [CropAspectRatio(width: 1, height: 1)];
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
@@ -134,10 +131,7 @@ class _AccountPublisherEditScreenState
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final rawBytes =
|
||||
(await result.uiImage.toByteData(format: ImageByteFormat.png))!
|
||||
.buffer
|
||||
.asUint8List();
|
||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
|
||||
try {
|
||||
final attachment = await attach.directUploadOne(
|
||||
@@ -199,9 +193,7 @@ class _AccountPublisherEditScreenState
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: _banner != null
|
||||
? AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(_banner!),
|
||||
@@ -240,8 +232,7 @@ class _AccountPublisherEditScreenState
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@@ -249,8 +240,7 @@ class _AccountPublisherEditScreenState
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldNickname'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
@@ -260,8 +250,7 @@ class _AccountPublisherEditScreenState
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldDescription'.tr(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
Row(
|
||||
|
@@ -201,7 +201,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
|
||||
}
|
||||
|
||||
class _PublisherNewOrganization extends StatefulWidget {
|
||||
const _PublisherNewOrganization({super.key});
|
||||
const _PublisherNewOrganization();
|
||||
|
||||
@override
|
||||
State<_PublisherNewOrganization> createState() =>
|
||||
|
@@ -105,6 +105,7 @@ class _LoginCheckScreen extends StatefulWidget {
|
||||
final SnAuthFactor? factor;
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function onNext;
|
||||
|
||||
const _LoginCheckScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
@@ -204,9 +205,7 @@ class _LoginCheckScreenState extends State<_LoginCheckScreen> {
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
autofillHints: [
|
||||
(_factorLabelMap[widget.factor!.type]?.$3 ?? true)
|
||||
? AutofillHints.password
|
||||
: AutofillHints.oneTimeCode
|
||||
(_factorLabelMap[widget.factor!.type]?.$3 ?? true) ? AutofillHints.password : AutofillHints.oneTimeCode
|
||||
],
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
@@ -243,6 +242,7 @@ class _LoginPickerScreen extends StatefulWidget {
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(SnAuthFactor) onPickFactor;
|
||||
final Function onNext;
|
||||
|
||||
const _LoginPickerScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
@@ -260,8 +260,7 @@ class _LoginPickerScreenState extends State<_LoginPickerScreen> {
|
||||
bool _isBusy = false;
|
||||
int? _factorPicked;
|
||||
|
||||
Color get _unFocusColor =>
|
||||
Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
Color get _unFocusColor => Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||
|
||||
void _performGetFactorCode() async {
|
||||
if (_factorPicked == null) return;
|
||||
@@ -373,6 +372,7 @@ class _LoginLookupScreen extends StatefulWidget {
|
||||
final Function(SnAuthTicket?) onTicket;
|
||||
final Function(List<SnAuthFactor>?) onFactor;
|
||||
final Function onNext;
|
||||
|
||||
const _LoginLookupScreen({
|
||||
super.key,
|
||||
required this.ticket,
|
||||
@@ -401,14 +401,13 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final lookupResp =
|
||||
await sn.client.get('/cgi/id/users/lookup?probe=$username');
|
||||
final lookupResp = await sn.client.get('/cgi/id/users/lookup?probe=$username');
|
||||
await sn.client.post('/cgi/id/users/me/password-reset', data: {
|
||||
'user_id': lookupResp.data['id'],
|
||||
});
|
||||
context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
if (mounted) context.showModalDialog('done'.tr(), 'signinResetPasswordSent'.tr());
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@@ -431,8 +430,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
widget.onTicket(result.ticket);
|
||||
|
||||
// Pull factors
|
||||
final factorResp =
|
||||
await sn.client.get('/cgi/id/auth/factors', queryParameters: {
|
||||
final factorResp = await sn.client.get('/cgi/id/auth/factors', queryParameters: {
|
||||
'ticketId': result.ticket!.id.toString(),
|
||||
});
|
||||
widget.onFactor(
|
||||
@@ -443,7 +441,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
|
||||
widget.onNext();
|
||||
} catch (err) {
|
||||
context.showErrorDialog(err);
|
||||
if(mounted) context.showErrorDialog(err);
|
||||
return;
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
@@ -526,10 +524,7 @@ class _LoginLookupScreenState extends State<_LoginLookupScreen> {
|
||||
'termAcceptNextWithAgree'.tr(),
|
||||
textAlign: TextAlign.end,
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withAlpha((255 * 0.75).round()),
|
||||
color: Theme.of(context).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
||||
),
|
||||
),
|
||||
Material(
|
||||
|
@@ -443,7 +443,7 @@ class _ChannelProfileDetailDialogState
|
||||
|
||||
class _ChannelMemberListWidget extends StatefulWidget {
|
||||
final SnChannel channel;
|
||||
const _ChannelMemberListWidget({super.key, required this.channel});
|
||||
const _ChannelMemberListWidget({required this.channel});
|
||||
|
||||
@override
|
||||
State<_ChannelMemberListWidget> createState() =>
|
||||
@@ -580,7 +580,7 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
|
||||
class _NewChannelMemberWidget extends StatefulWidget {
|
||||
final SnChannel channel;
|
||||
const _NewChannelMemberWidget({super.key, required this.channel});
|
||||
const _NewChannelMemberWidget({required this.channel});
|
||||
|
||||
@override
|
||||
State<_NewChannelMemberWidget> createState() =>
|
||||
|
@@ -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);
|
||||
|
@@ -22,8 +22,9 @@ const Map<String, IconData> kCategoryIcons = {
|
||||
'sports': Symbols.sports_soccer,
|
||||
'music': Symbols.music_note,
|
||||
'news': Symbols.newspaper,
|
||||
'knowledge': Symbols.book,
|
||||
'knowledge': Symbols.library_books,
|
||||
'literature': Symbols.book,
|
||||
'funny': Symbols.attractions,
|
||||
};
|
||||
|
||||
class ExploreScreen extends StatefulWidget {
|
||||
@@ -184,26 +185,27 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
preferredSize: const Size.fromHeight(50),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _categories.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final ele = _categories[idx];
|
||||
return StyledWidget(ChoiceChip(
|
||||
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
|
||||
label: Text(
|
||||
'postCategory${ele.alias.capitalize()}'.trExists()
|
||||
? 'postCategory${ele.alias.capitalize()}'.tr()
|
||||
: ele.name,
|
||||
),
|
||||
selected: _selectedCategory == ele.alias,
|
||||
onSelected: (value) {
|
||||
_selectedCategory = value ? ele.alias : null;
|
||||
_refreshPosts();
|
||||
},
|
||||
)).padding(horizontal: 4);
|
||||
},
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _categories.map((ele) {
|
||||
return StyledWidget(ChoiceChip(
|
||||
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
|
||||
label: Text(
|
||||
'postCategory${ele.alias.capitalize()}'.trExists()
|
||||
? 'postCategory${ele.alias.capitalize()}'.tr()
|
||||
: ele.name,
|
||||
),
|
||||
selected: _selectedCategory == ele.alias,
|
||||
onSelected: (value) {
|
||||
_selectedCategory = value ? ele.alias : null;
|
||||
_refreshPosts();
|
||||
},
|
||||
)).padding(horizontal: 4);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@@ -289,7 +289,7 @@ class _FriendScreenState extends State<FriendScreen> {
|
||||
}
|
||||
|
||||
class _NewFriendWidget extends StatefulWidget {
|
||||
const _NewFriendWidget({super.key});
|
||||
const _NewFriendWidget();
|
||||
|
||||
@override
|
||||
State<_NewFriendWidget> createState() => _NewFriendWidgetState();
|
||||
@@ -365,7 +365,7 @@ class _NewFriendWidgetState extends State<_NewFriendWidget> {
|
||||
|
||||
class _FriendshipListWidget extends StatefulWidget {
|
||||
final List<SnRelationship> relations;
|
||||
const _FriendshipListWidget({super.key, required this.relations});
|
||||
const _FriendshipListWidget({required this.relations});
|
||||
|
||||
@override
|
||||
State<_FriendshipListWidget> createState() => _FriendshipListWidgetState();
|
||||
|
@@ -11,11 +11,14 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:slide_countdown/slide_countdown.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/special_day.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/widget.dart';
|
||||
import 'package:surface/types/check_in.dart';
|
||||
@@ -79,8 +82,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
child: Column(
|
||||
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||||
children: [
|
||||
_HomeDashSpecialDayWidget().padding(bottom: 8, horizontal: 8),
|
||||
_HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
|
||||
_HomeDashSpecialDayWidget().padding(horizontal: 8),
|
||||
StaggeredGrid.extent(
|
||||
maxCrossAxisExtent: 280,
|
||||
mainAxisSpacing: 8,
|
||||
@@ -107,7 +110,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
class _HomeDashUpdateWidget extends StatelessWidget {
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const _HomeDashUpdateWidget({super.key, this.padding});
|
||||
const _HomeDashUpdateWidget({this.padding});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -151,46 +154,76 @@ class _HomeDashUpdateWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _HomeDashSpecialDayWidget extends StatelessWidget {
|
||||
const _HomeDashSpecialDayWidget({super.key});
|
||||
const _HomeDashSpecialDayWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ua = context.watch<UserProvider>();
|
||||
final today = DateTime.now();
|
||||
final birthday = ua.user?.profile?.birthday?.toLocal();
|
||||
final isBirthday = birthday != null && birthday.day == today.day && birthday.month == today.month;
|
||||
final dayz = context.watch<SpecialDayProvider>();
|
||||
|
||||
return Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (isBirthday)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Text('🎂').fontSize(24),
|
||||
title: Text('happyBirthday').tr(args: [ua.user?.nick ?? 'user']),
|
||||
),
|
||||
).padding(bottom: 8),
|
||||
if (today.month == 12 && today.day == 25)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Text('🎄').fontSize(24),
|
||||
title: Text('celebrateMerryXmas').tr(args: [ua.user?.nick ?? 'user']),
|
||||
),
|
||||
final days = dayz.getSpecialDays();
|
||||
|
||||
if (days.isNotEmpty) {
|
||||
return Column(
|
||||
spacing: 8,
|
||||
children: days.map((ele) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[ele] ?? '🎉').fontSize(24),
|
||||
title: Text('celebrate$ele').tr(args: [ua.user?.nick ?? 'user']),
|
||||
subtitle: Text(
|
||||
DateFormat('y/M/d').format(DateTime.now().copyWith(
|
||||
month: kSpecialDays[ele]!.$1,
|
||||
day: kSpecialDays[ele]!.$2,
|
||||
)),
|
||||
),
|
||||
),
|
||||
).padding(bottom: 8);
|
||||
}).toList());
|
||||
}
|
||||
|
||||
final nextOne = dayz.getNextSpecialDay();
|
||||
final lastOne = dayz.getLastSpecialDay();
|
||||
|
||||
if (nextOne != null && lastOne != null) {
|
||||
var (name, date) = nextOne;
|
||||
date = date.add(Duration(days: 1));
|
||||
final progress = dayz.getSpecialDayProgress(lastOne.$2, date);
|
||||
final diff = nextOne.$2.difference(DateTime.now());
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
|
||||
title: Text('pending$name').tr(args: [RelativeTime(context).format(date)]),
|
||||
subtitle: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SlideCountdown(
|
||||
duration: diff,
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
separatorStyle: GoogleFonts.robotoMono(fontSize: 13),
|
||||
separatorType: SeparatorType.symbol,
|
||||
decoration: BoxDecoration(),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
const Gap(12),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (today.month == 1 && today.day == 1)
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: Text('🎉').fontSize(24),
|
||||
title: Text('celebrateNewYear').tr(args: [ua.user?.nick ?? 'user']),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
),
|
||||
).padding(bottom: 8);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeDashCheckInWidget extends StatefulWidget {
|
||||
const _HomeDashCheckInWidget({super.key});
|
||||
const _HomeDashCheckInWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashCheckInWidget> createState() => _HomeDashCheckInWidgetState();
|
||||
@@ -408,7 +441,7 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
}
|
||||
|
||||
class _HomeDashNotificationWidget extends StatefulWidget {
|
||||
const _HomeDashNotificationWidget({super.key});
|
||||
const _HomeDashNotificationWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
|
||||
@@ -479,7 +512,7 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
|
||||
}
|
||||
|
||||
class _HomeDashRecommendationPostWidget extends StatefulWidget {
|
||||
const _HomeDashRecommendationPostWidget({super.key});
|
||||
const _HomeDashRecommendationPostWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
|
||||
@@ -493,9 +526,7 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final home = context.read<HomeWidgetProvider>();
|
||||
_posts = await pt.listRecommendations();
|
||||
home.saveWidgetData('post_featured', _posts!.first.toJson());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
|
@@ -1,16 +1,11 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
@@ -96,38 +91,6 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final _imagePicker = ImagePicker();
|
||||
|
||||
void _takeMedia(bool isVideo) async {
|
||||
final result = isVideo
|
||||
? await _imagePicker.pickVideo(source: ImageSource.camera)
|
||||
: await _imagePicker.pickImage(source: ImageSource.camera);
|
||||
if (result == null) return;
|
||||
_writeController.addAttachments([
|
||||
PostWriteMedia.fromFile(result),
|
||||
]);
|
||||
}
|
||||
|
||||
void _selectMedia() async {
|
||||
final result = await _imagePicker.pickMultipleMedia();
|
||||
if (result.isEmpty) return;
|
||||
_writeController.addAttachments(
|
||||
result.map((e) => PostWriteMedia.fromFile(e)),
|
||||
);
|
||||
}
|
||||
|
||||
void _pasteMedia() async {
|
||||
final imageBytes = await Pasteboard.image;
|
||||
if (imageBytes == null) return;
|
||||
_writeController.addAttachments([
|
||||
PostWriteMedia.fromBytes(
|
||||
imageBytes,
|
||||
'attachmentPastedImage'.tr(),
|
||||
PostWriteMediaType.image,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_writeController.dispose();
|
||||
@@ -292,18 +255,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (_writeController.replyingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.reply).padding(left: 4),
|
||||
title: Text('postReplyingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
|
||||
),
|
||||
ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.reply).padding(left: 4),
|
||||
title: Text('postReplyingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.replyingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.replyingPost!)],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
@@ -312,22 +270,17 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (_writeController.repostingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.forward).padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
|
||||
children: <Widget>[
|
||||
PostItem(
|
||||
data: _writeController.repostingPost!,
|
||||
)
|
||||
],
|
||||
),
|
||||
ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.forward).padding(left: 4),
|
||||
title: Text('postRepostingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.repostingPost!.publisher.name}']),
|
||||
children: <Widget>[
|
||||
PostItem(
|
||||
data: _writeController.repostingPost!,
|
||||
)
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
@@ -336,18 +289,13 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
if (_writeController.editingPost != null)
|
||||
Column(
|
||||
children: [
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit_note).padding(left: 4),
|
||||
title: Text('postEditingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.editingPost!)],
|
||||
),
|
||||
ExpansionTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit_note).padding(left: 4),
|
||||
title: Text('postEditingNotice')
|
||||
.fontSize(15)
|
||||
.tr(args: ['@${_writeController.editingPost!.publisher.name}']),
|
||||
children: <Widget>[PostItem(data: _writeController.editingPost!)],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
@@ -435,63 +383,12 @@ class _PostEditorScreenState extends State<PostEditorScreen> {
|
||||
scrollDirection: Axis.vertical,
|
||||
child: Row(
|
||||
children: [
|
||||
PopupMenuButton(
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_camera),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraPhoto').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(false);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.videocam),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraVideo').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(true);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_library),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromAlbum').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_selectMedia();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.content_paste),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromClipboard').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_pasteMedia();
|
||||
},
|
||||
),
|
||||
],
|
||||
AddPostMediaButton(
|
||||
onAdd: (items) {
|
||||
setState(() {
|
||||
_writeController.addAttachments(items);
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@@ -99,11 +99,16 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
).then((_) {
|
||||
_posts.clear();
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refreshPosts() {
|
||||
_postCount = null;
|
||||
_posts.clear();
|
||||
return _fetchPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const labelShadows = <Shadow>[
|
||||
@@ -144,8 +149,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
_posts.clear();
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
@@ -176,10 +180,8 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
|
||||
_searchTerm = value;
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
setState(() => _posts.clear());
|
||||
|
||||
_searchTerm = value;
|
||||
_fetchPosts();
|
||||
_refreshPosts();
|
||||
},
|
||||
),
|
||||
if (_lastTook != null)
|
||||
|
@@ -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,
|
||||
|
@@ -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();
|
||||
|
@@ -33,7 +33,6 @@ Future<ThemeData> createAppTheme(
|
||||
brightness: brightness,
|
||||
);
|
||||
|
||||
final hasBackground = prefs.getBool(kAppBackgroundStoreKey) ?? false;
|
||||
final hasAppBarBlurry = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
|
||||
|
||||
return ThemeData(
|
||||
|
@@ -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,68 @@ 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 SnAttachment? ref,
|
||||
required int? refId,
|
||||
required SnAttachmentPool? pool,
|
||||
required int poolId,
|
||||
required int accountId,
|
||||
int? thumbnailId,
|
||||
SnAttachment? thumbnail,
|
||||
int? compressedId,
|
||||
SnAttachment? compressed,
|
||||
@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 +107,5 @@ 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);
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,9 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'],
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
rid: json['rid'] as String,
|
||||
uuid: json['uuid'] as String,
|
||||
size: (json['size'] as num).toInt(),
|
||||
@@ -21,19 +23,31 @@ _$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'],
|
||||
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(),
|
||||
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>),
|
||||
usermeta: json['usermeta'] as Map<String, dynamic>? ?? const {},
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
|
||||
);
|
||||
|
||||
@@ -42,7 +56,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 +66,70 @@ 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,
|
||||
'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(),
|
||||
'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(
|
||||
|
@@ -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;
|
||||
|
114
lib/widgets/attachment/attachment_input.dart
Normal file
114
lib/widgets/attachment/attachment_input.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
|
||||
const AttachmentInputDialog({super.key, required this.title});
|
||||
|
||||
@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,
|
||||
);
|
||||
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 OutlineInputBorder(),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -18,6 +18,7 @@ import 'package:uuid/uuid.dart';
|
||||
class AttachmentItem extends StatelessWidget {
|
||||
final SnAttachment? data;
|
||||
final String? heroTag;
|
||||
|
||||
const AttachmentItem({
|
||||
super.key,
|
||||
required this.data,
|
||||
@@ -60,9 +61,14 @@ class AttachmentItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (data!.isMature) {
|
||||
return _AttachmentItemSensitiveBlur(
|
||||
child: _buildContent(context),
|
||||
if (data!.contentRating > 0) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return _AttachmentItemSensitiveBlur(
|
||||
isCompact: constraints.maxHeight < 360,
|
||||
child: _buildContent(context),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,15 +78,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 +110,21 @@ class _AttachmentItemSensitiveBlurState
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
const Gap(8),
|
||||
Text('sensitiveContent', textAlign: TextAlign.center)
|
||||
.tr()
|
||||
.fontSize(20)
|
||||
.textColor(Colors.white)
|
||||
.bold(),
|
||||
Text(
|
||||
'sensitiveContentDescription',
|
||||
textAlign: TextAlign.center,
|
||||
)
|
||||
.tr()
|
||||
.fontSize(14)
|
||||
.textColor(Colors.white.withOpacity(0.8)),
|
||||
const Gap(16),
|
||||
InkWell(
|
||||
child: Text('sensitiveContentReveal')
|
||||
if (!widget.isCompact) const Gap(8),
|
||||
if (!widget.isCompact)
|
||||
Text('sensitiveContent', textAlign: TextAlign.center)
|
||||
.tr()
|
||||
.textColor(Colors.white),
|
||||
.fontSize(20)
|
||||
.textColor(Colors.white)
|
||||
.bold(),
|
||||
if (!widget.isCompact)
|
||||
Text(
|
||||
'sensitiveContentDescription',
|
||||
textAlign: TextAlign.center,
|
||||
).tr().fontSize(14).textColor(Colors.white.withOpacity(0.8)),
|
||||
if (!widget.isCompact) const Gap(16),
|
||||
InkWell(
|
||||
child: Text('sensitiveContentReveal').tr().textColor(Colors.white),
|
||||
onTap: () {
|
||||
setState(() => _doesShow = !_doesShow);
|
||||
},
|
||||
@@ -131,9 +134,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,19 +164,17 @@ 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;
|
||||
|
||||
Player? _videoPlayer;
|
||||
@@ -207,7 +206,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 +215,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 +265,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,
|
||||
@@ -317,19 +313,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 +372,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 +457,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 +493,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 +534,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();
|
||||
},
|
||||
|
@@ -58,7 +58,7 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
|
||||
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,
|
||||
@@ -114,6 +114,7 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
if (widget.data.firstOrNull?.mediaType != SnMediaType.image) return;
|
||||
context.pushTransparentRoute(
|
||||
AttachmentZoomView(
|
||||
data: widget.data.where((ele) => ele != null).cast(),
|
||||
@@ -136,7 +137,7 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
children: widget.data
|
||||
.mapIndexed(
|
||||
(idx, ele) => AspectRatio(
|
||||
aspectRatio: (ele?.metadata['ratio'] ?? 1).toDouble(),
|
||||
aspectRatio: (ele?.data['ratio'] ?? 1).toDouble(),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
@@ -161,7 +162,7 @@ class _AttachmentListState extends State<AttachmentList> {
|
||||
}
|
||||
|
||||
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 +174,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,
|
||||
),
|
||||
|
163
lib/widgets/attachment/pending_attachment_compress.dart
Normal file
163
lib/widgets/attachment/pending_attachment_compress.dart
Normal file
@@ -0,0 +1,163 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:video_compress/video_compress.dart';
|
||||
|
||||
class PendingVideoCompressDialog extends StatefulWidget {
|
||||
final PostWriteMedia media;
|
||||
|
||||
const PendingVideoCompressDialog({super.key, required this.media});
|
||||
|
||||
@override
|
||||
State<PendingVideoCompressDialog> createState() => _PendingVideoCompressDialogState();
|
||||
}
|
||||
|
||||
class _PendingVideoCompressDialogState extends State<PendingVideoCompressDialog> {
|
||||
VideoQuality _quality = VideoQuality.DefaultQuality;
|
||||
|
||||
bool _isBusy = false;
|
||||
double? _progress;
|
||||
MediaInfo? _mediaInfo;
|
||||
|
||||
Subscription? _progressSubscription;
|
||||
|
||||
Future<void> _startCompress() async {
|
||||
_mediaInfo = await VideoCompress.compressVideo(
|
||||
widget.media.file!.path,
|
||||
quality: _quality,
|
||||
deleteOrigin: false,
|
||||
frameRate: switch (_quality) {
|
||||
VideoQuality.HighestQuality => 60,
|
||||
VideoQuality.DefaultQuality => 60,
|
||||
_ => 30,
|
||||
},
|
||||
);
|
||||
if (_mediaInfo == null) return;
|
||||
setState(() => _isBusy = true);
|
||||
if (!mounted || _mediaInfo == null) return;
|
||||
Navigator.pop(context, PostWriteMedia.fromFile(XFile(_mediaInfo!.path!)));
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_progressSubscription = VideoCompress.compressProgress$.subscribe((event) {
|
||||
log('[Compress] Progress: $event');
|
||||
setState(() {
|
||||
_progress = event / 100;
|
||||
_isBusy = event < 100;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_progressSubscription?.unsubscribe();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('attachmentCompressVideo').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
FutureBuilder(
|
||||
future: widget.media.file?.length(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
return Text(
|
||||
snapshot.data!.formatBytes(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
);
|
||||
},
|
||||
),
|
||||
Text('attachmentCompressQuality').tr(),
|
||||
const Gap(8),
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityHighest').tr(),
|
||||
value: VideoQuality.HighestQuality,
|
||||
groupValue: _quality,
|
||||
selected: _quality == VideoQuality.HighestQuality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityDefault').tr(),
|
||||
value: VideoQuality.DefaultQuality,
|
||||
groupValue: _quality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityMedium').tr(),
|
||||
value: VideoQuality.MediumQuality,
|
||||
groupValue: _quality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
RadioListTile(
|
||||
title: Text('attachmentCompressQualityLow').tr(),
|
||||
value: VideoQuality.LowQuality,
|
||||
groupValue: _quality,
|
||||
onChanged: (val) {
|
||||
if (val != null) {
|
||||
setState(() => _quality = val);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('attachmentCompressQualityHint', style: Theme.of(context).textTheme.bodySmall!).tr(),
|
||||
if (_isBusy)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0, end: _progress ?? 0),
|
||||
duration: Duration(milliseconds: 100),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
value: value,
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
).padding(top: 16),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : _startCompress,
|
||||
child: Text('dialogConfirm').tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@@ -10,6 +10,7 @@ import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_list.dart';
|
||||
import 'package:surface/widgets/context_menu.dart';
|
||||
import 'package:surface/widgets/link_preview.dart';
|
||||
import 'package:surface/widgets/markdown_content.dart';
|
||||
import 'package:swipe_to/swipe_to.dart';
|
||||
@@ -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}'])),
|
||||
@@ -173,7 +174,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 +214,7 @@ class _ChatMessageText extends StatelessWidget {
|
||||
class _ChatMessageSystemNotify extends StatelessWidget {
|
||||
final SnChatMessage data;
|
||||
|
||||
const _ChatMessageSystemNotify({super.key, required this.data});
|
||||
const _ChatMessageSystemNotify({required this.data});
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
String negativeSign = duration.isNegative ? '-' : '';
|
||||
|
@@ -1,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,7 +76,7 @@ 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(
|
||||
@@ -123,40 +119,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,63 +256,12 @@ class ChatMessageInputState extends State<ChatMessageInput> {
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
PopupMenuButton(
|
||||
icon: Icon(
|
||||
Symbols.add_photo_alternate,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_camera),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraPhoto').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(false);
|
||||
},
|
||||
),
|
||||
if (!kIsWeb && !Platform.isLinux && !Platform.isMacOS && !Platform.isWindows)
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.videocam),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromCameraVideo').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_takeMedia(true);
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.photo_library),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromAlbum').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_selectMedia();
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.content_paste),
|
||||
const Gap(16),
|
||||
Text('addAttachmentFromClipboard').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_pasteMedia();
|
||||
},
|
||||
),
|
||||
],
|
||||
AddPostMediaButton(
|
||||
onAdd: (items) {
|
||||
setState(() {
|
||||
_attachments.addAll(items);
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _isBusy ? null : _sendMessage,
|
||||
|
47
lib/widgets/context_menu.dart
Normal file
47
lib/widgets/context_menu.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import 'package:flutter_context_menu/flutter_context_menu.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
|
||||
class ContextMenuArea extends StatelessWidget {
|
||||
final ContextMenu contextMenu;
|
||||
final Widget child;
|
||||
final ValueChanged<dynamic>? onItemSelected;
|
||||
|
||||
const ContextMenuArea({
|
||||
super.key,
|
||||
required this.contextMenu,
|
||||
required this.child,
|
||||
this.onItemSelected,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Offset mousePosition = Offset.zero;
|
||||
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
mousePosition = event.position;
|
||||
final isCollapseDrawer = ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE);
|
||||
if (!isCollapseDrawer) {
|
||||
final isExpandDrawer = ResponsiveBreakpoints.of(context).largerThan(TABLET);
|
||||
// Leave padding for side navigation
|
||||
mousePosition = isExpandDrawer
|
||||
? mousePosition.copyWith(dx: mousePosition.dx - 304 * 2)
|
||||
: mousePosition.copyWith(dx: mousePosition.dx - 72 * 2);
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onLongPress: () => _showMenu(context, mousePosition),
|
||||
onSecondaryTap: () => _showMenu(context, mousePosition),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showMenu(BuildContext context, Offset mousePosition) async {
|
||||
final menu = contextMenu.copyWith(position: contextMenu.position ?? mousePosition);
|
||||
final value = await showContextMenu(context, contextMenu: menu);
|
||||
onItemSelected?.call(value);
|
||||
}
|
||||
}
|
@@ -60,7 +60,6 @@ class _LinkPreviewEntry extends StatelessWidget {
|
||||
final SnLinkMeta meta;
|
||||
|
||||
const _LinkPreviewEntry({
|
||||
super.key,
|
||||
required this.meta,
|
||||
});
|
||||
|
||||
|
@@ -549,7 +549,6 @@ class _PostHeadline extends StatelessWidget {
|
||||
final bool isEnlarge;
|
||||
|
||||
const _PostHeadline({
|
||||
super.key,
|
||||
required this.data,
|
||||
this.isEnlarge = false,
|
||||
});
|
||||
@@ -894,7 +893,6 @@ class _PostQuoteContent extends StatelessWidget {
|
||||
final bool isFlatted;
|
||||
|
||||
const _PostQuoteContent({
|
||||
super.key,
|
||||
this.isRelativeDate = true,
|
||||
this.isFlatted = false,
|
||||
required this.child,
|
||||
@@ -962,7 +960,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 +1033,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 +1058,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 +1100,7 @@ class _PostTruncatedHint extends StatelessWidget {
|
||||
class _PostAbuseReportDialog extends StatefulWidget {
|
||||
final SnPost data;
|
||||
|
||||
const _PostAbuseReportDialog({super.key, required this.data});
|
||||
const _PostAbuseReportDialog({required this.data});
|
||||
|
||||
@override
|
||||
State<_PostAbuseReportDialog> createState() => _PostAbuseReportDialogState();
|
||||
|
@@ -6,15 +6,25 @@ 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: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/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 +80,32 @@ 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(),
|
||||
),
|
||||
);
|
||||
if (thumbnail == null) return;
|
||||
if (!context.mounted) return;
|
||||
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
final newAttach = await attach.updateOne(
|
||||
attachments[idx].attachment!.id,
|
||||
thumbnailId: thumbnail.id,
|
||||
);
|
||||
|
||||
onUpdate!(idx, PostWriteMedia(newAttach));
|
||||
}
|
||||
|
||||
Future<void> _deleteAttachment(BuildContext context, int idx) async {
|
||||
final media = idx == -1 ? thumbnail! : attachments[idx];
|
||||
if (media.attachment == null) return;
|
||||
@@ -87,9 +123,36 @@ class PostMediaPendingList extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
ContextMenu _buildContextMenu(BuildContext context, int idx, PostWriteMedia media) {
|
||||
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 && 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 +160,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 +168,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 +184,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 +195,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 +237,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 +260,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 +272,246 @@ 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),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: switch (media.type) {
|
||||
SnMediaType.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,
|
||||
);
|
||||
}),
|
||||
),
|
||||
SnMediaType.video => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: 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 => Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: 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(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -282,20 +282,6 @@ class _PostCategoriesFieldState extends State<PostCategoriesField> {
|
||||
: null,
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onChanged: (value) {
|
||||
for (final divider in kTagsDividers) {
|
||||
if (value.endsWith(divider)) {
|
||||
final tagValue = value.substring(0, value.length - 1);
|
||||
if (tagValue.isEmpty) return;
|
||||
if (!_currentCategories.contains(tagValue)) {
|
||||
setState(() => _currentCategories.add(tagValue));
|
||||
}
|
||||
controller.clear();
|
||||
widget.onUpdate(_currentCategories);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
onSubmitted: (_) {
|
||||
onSubmitted();
|
||||
},
|
||||
|
@@ -28,6 +28,7 @@ import share_plus
|
||||
import shared_preferences_foundation
|
||||
import sqflite_darwin
|
||||
import url_launcher_macos
|
||||
import video_compress
|
||||
import wakelock_plus
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@@ -54,5 +55,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
VideoCompressPlugin.register(with: registry.registrar(forPlugin: "VideoCompressPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
}
|
||||
|
@@ -134,7 +134,7 @@ PODS:
|
||||
- GoogleUtilities/Privacy
|
||||
- in_app_review (2.0.0):
|
||||
- FlutterMacOS
|
||||
- livekit_client (2.3.2):
|
||||
- livekit_client (2.3.3):
|
||||
- flutter_webrtc
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
@@ -170,6 +170,8 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- video_compress (0.3.0):
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- WebRTC-SDK (125.6422.06)
|
||||
@@ -201,6 +203,7 @@ DEPENDENCIES:
|
||||
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- video_compress (from `Flutter/ephemeral/.symlinks/plugins/video_compress/macos`)
|
||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||
|
||||
SPEC REPOS:
|
||||
@@ -272,6 +275,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
video_compress:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/video_compress/macos
|
||||
wakelock_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||
|
||||
@@ -299,7 +304,7 @@ SPEC CHECKSUMS:
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
in_app_review: a6a031b9acd03c7d103e341aa334adf2c493fb93
|
||||
livekit_client: 9fdcb22df3de55e6d4b24bdc3b5eb1c0269d774a
|
||||
livekit_client: 8b1b90a6f2445d127a018ce93cc8cf6d8ab62982
|
||||
media_kit_libs_macos_video: b3e2bbec2eef97c285f2b1baa7963c67c753fb82
|
||||
media_kit_native_event_loop: 81fd5b45192b72f8b5b69eaf5b540f45777eb8d5
|
||||
media_kit_video: c75b07f14d59706c775778e4dd47dd027de8d1e5
|
||||
@@ -314,6 +319,7 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||
video_compress: c896234f100791b5fef7f049afa38f6d2ef7b42f
|
||||
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
|
||||
WebRTC-SDK: 79942c006ea64f6fb48d7da8a4786dfc820bc1db
|
||||
|
||||
|
46
pubspec.lock
46
pubspec.lock
@@ -490,10 +490,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: file_picker
|
||||
sha256: c2376a6aae82358a9f9ccdd7d1f4006d08faa39a2767cce01031d9f593a8bd3b
|
||||
sha256: c904b4ab56d53385563c7c39d8e9fa9af086f91495dfc48717ad84a42c3cf204
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.6"
|
||||
version: "8.1.7"
|
||||
file_saver:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -753,23 +753,23 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_udid
|
||||
sha256: be464dc5b1fb7ee894f6a32d65c086ca5e177fdcf9375ac08d77495b98150f84
|
||||
sha256: "166bee5989a58c66b8b62000ea65edccc7c8167bbafdbb08022638db330dd030"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "4.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webrtc:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_webrtc
|
||||
sha256: "430859fb5b763d7556d06ef287cfca582e17d9a2dc36da26017f25a5c0b2523e"
|
||||
sha256: "3efe9828f19a07d29a51a726759ad0c70a840d231548a1c7d0332075a94db1df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.4"
|
||||
version: "0.12.5+hotfix.1"
|
||||
freezed:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -934,10 +934,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image_picker_android
|
||||
sha256: fa8141602fde3f7e2f81dbf043613eb44dfa325fa0bcf93c0f142c9f7a2c193e
|
||||
sha256: aa6f1280b670861ac45220cc95adc59bb6ae130259d36f980ccb62220dc5e59f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.12+18"
|
||||
version: "0.8.12+19"
|
||||
image_picker_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1086,10 +1086,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: livekit_client
|
||||
sha256: "7802b5de1cae2ee3439db730d24d31c6dcbce173c5e6db2fc5774039a290bc2d"
|
||||
sha256: a3ff529fe6745ee40cdedcd021d81c4a6ad946dd495e782596f2856eeeabc739
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
version: "2.3.3"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1354,6 +1354,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pausable_timer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pausable_timer
|
||||
sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0+3"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1711,6 +1719,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
slide_countdown:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: slide_countdown
|
||||
sha256: "363914f96389502467d4dc9c0f26e88f93df3d8e37de2d5ff05b16d981fe973d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
sliver_tools:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -2031,6 +2047,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:
|
||||
|
@@ -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+38
|
||||
version: 2.2.1+40
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.4
|
||||
@@ -80,7 +80,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 +113,9 @@ 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
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
221
web/index.html
221
web/index.html
@@ -1,130 +1,133 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" oncontextmenu="event.preventDefault();">
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="surface">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="surface">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png">
|
||||
|
||||
<title>Solian</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
|
||||
|
||||
|
||||
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
<title>Solian</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #ffffff;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
name="viewport">
|
||||
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background-color: #ffffff;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
.center {
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, -50%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
.contain {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
.stretch {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
.cover {
|
||||
display:block;
|
||||
width:100%; height:100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
-ms-transform: translate(-50%, 0);
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.bottomLeft {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.bottomRight {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script id="splash-screen-script">
|
||||
function removeSplashFromWeb() {
|
||||
document.getElementById("splash")?.remove();
|
||||
document.getElementById("splash-branding")?.remove();
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<picture id="splash-branding">
|
||||
<source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x" media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<picture id="splash-branding">
|
||||
<source srcset="splash/img/branding-1x.png 1x, splash/img/branding-2x.png 2x, splash/img/branding-3x.png 3x, splash/img/branding-4x.png 4x"
|
||||
media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/branding-dark-1x.png 1x, splash/img/branding-dark-2x.png 2x, splash/img/branding-dark-3x.png 3x, splash/img/branding-dark-4x.png 4x"
|
||||
media="(prefers-color-scheme: dark)">
|
||||
<img class="bottom" aria-hidden="true" src="splash/img/branding-1x.png" alt="">
|
||||
</picture>
|
||||
<picture id="splash">
|
||||
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x" media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
</picture>
|
||||
<picture id="splash">
|
||||
<source srcset="splash/img/light-1x.png 1x, splash/img/light-2x.png 2x, splash/img/light-3x.png 3x, splash/img/light-4x.png 4x"
|
||||
media="(prefers-color-scheme: light)">
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x"
|
||||
media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
|
||||
|
||||
</body></html>
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user