Compare commits
45 Commits
3.2.0+134
...
53d51b8a0e
Author | SHA1 | Date | |
---|---|---|---|
53d51b8a0e
|
|||
337ae39e08
|
|||
8fe3a664a6
|
|||
3bfc0b8181
|
|||
ac2951479b | |||
2bfd13d843 | |||
28db6f9f01 | |||
a4f7b8415d
|
|||
2255d3d591
|
|||
97792ae734
|
|||
a5d13250cc
|
|||
de9e235d0c
|
|||
56fb5451cd
|
|||
870de961f5
|
|||
22bf6d1c33
|
|||
5b62f89531
|
|||
b1326d8f04
|
|||
fffca4a78c
|
|||
42bd7f97cb
|
|||
6377856ae0 | |||
0f1c52b9e3 | |||
6ed6f60fbc | |||
e65a414065 | |||
214d5c4a53 | |||
fe33931304 | |||
113309257e | |||
b95a8b2ed2 | |||
|
e922971a5e | ||
9d5b71bead | |||
890efa2efb | |||
674097e425 | |||
3379dcb7f3
|
|||
eb5a849e1f | |||
4981a23e8e
|
|||
c64d4bacb6
|
|||
838d18013b
|
|||
3f7902e463
|
|||
54560ad5d8
|
|||
0c729db639
|
|||
1fbaac8d88
|
|||
b9dc724f0b
|
|||
a2cc55696f
|
|||
e79f857feb
|
|||
affba29c04
|
|||
756746b144
|
@@ -14,13 +14,13 @@ The backend of the Solar Network is written in Go and is a microservices app. Th
|
||||
|
||||
## Commit Messages
|
||||
|
||||
We're using the gitmoji to clarify the reason and changes of the commit. To learn more about gitmoji, visit https://gitmoji.dev
|
||||
We're using the gitmoji to clarify the reason and changes of the commit. To learn more about gitmoji, visit <https://gitmoji.dev>
|
||||
|
||||
All the commit message should follow `:[gitmoji]: <commit message>` syntax
|
||||
|
||||
## Translations & Localization
|
||||
|
||||
We're not accepting translation and localization improvements, or fixes on the GitHub or Solsynth Git Repository. If you want to contribute to those, please head to our Crowdin project: https://crowdin.com/project/solian
|
||||
We're not accepting translation and localization improvements, or fixes on the GitHub or Solsynth Git Repository. If you want to contribute to those, please head to our Crowdin project: <https://crowdin.com/project/solian>
|
||||
|
||||
## New Features
|
||||
|
||||
@@ -28,9 +28,14 @@ To contribute new features, please create an issue or mention the feature you wa
|
||||
|
||||
## Bug Reports / Ask for help
|
||||
|
||||
Read the error message, check for the update (including pre-releases), and wiki before creating an issue. At the same time, be respectful and don't argue with our developers and contributors in the development chat or GitHub issue. Otherwise your issue may got deleted and your Solar Network Account may got a strike.
|
||||
Read the error message, check for the update (including pre-releases), and wiki before creating an issue. At the same time, be respectful and don't argue with our developers and contributors in the development chat or GitHub issue. Otherwise your issue may got deleted and your Solar Network Account may got a strike.
|
||||
|
||||
## Styles of Code
|
||||
|
||||
Before you create a Pull Request, make sure your code has pass the `flutter analyze` check, if there is any notes, fix as much as possible, if there is no way to fix, do ignore.
|
||||
|
||||
When the code contains comments, use English. We do not any other language of comments existing in the codebase. It might confuse future contributors, cause the code hard to understand and maintaiance.
|
||||
|
||||
-----------
|
||||
|
||||
We appreciate every single commit you contributed. Let's work together and create a better Solar Network!
|
||||
|
||||
|
@@ -51,6 +51,12 @@
|
||||
<data android:scheme="http" android:host="solian.app" />
|
||||
<data android:scheme="https" />
|
||||
</intent-filter>
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="solian" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Share Intent Filters -->
|
||||
<intent-filter>
|
||||
|
File diff suppressed because it is too large
Load Diff
1079
assets/i18n/es-ES.json
Normal file
1079
assets/i18n/es-ES.json
Normal file
File diff suppressed because it is too large
Load Diff
1079
assets/i18n/ja-JP.json
Normal file
1079
assets/i18n/ja-JP.json
Normal file
File diff suppressed because it is too large
Load Diff
1079
assets/i18n/ko-KR.json
Normal file
1079
assets/i18n/ko-KR.json
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1079
assets/i18n/zh-OG.json
Normal file
1079
assets/i18n/zh-OG.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -116,16 +116,12 @@
|
||||
},
|
||||
"postHasAttachments": {
|
||||
"one": "{} 個附件",
|
||||
"other": "{}個附件"
|
||||
"other": "{} 個附件"
|
||||
},
|
||||
"edited": "已編輯",
|
||||
"addVideo": "添加視頻",
|
||||
"addPhoto": "添加照片",
|
||||
"addFile": "添加文件",
|
||||
"uploadFile": "上傳文件",
|
||||
"settingsDefaultPool": "選擇文件池",
|
||||
"settingsDefaultPoolHelper": "爲文件上傳選擇一個默認池",
|
||||
|
||||
"createDirectMessage": "創建新私人消息",
|
||||
"gotoDirectMessage": "前往私信",
|
||||
"react": "反應",
|
||||
@@ -307,8 +303,7 @@
|
||||
"notifications": "通知",
|
||||
"posts": "帖子",
|
||||
"settingsBackgroundImage": "背景圖片",
|
||||
"settingsBackgroundImageEnable": "顯示背景圖片",
|
||||
"settingsBackgroundImageClear": "清除背景圖片",
|
||||
"settingsBackgroundImageClear": "清除背景圖片",
|
||||
"settingsBackgroundGenerateColor": "從背景圖像生成主題色",
|
||||
"messageNone": "沒有內容可顯示",
|
||||
"unreadMessages": {
|
||||
@@ -319,8 +314,6 @@
|
||||
"settingsRealmCompactView": "緊湊領域視圖",
|
||||
"settingsMixedFeed": "混合動態",
|
||||
"settingsAutoTranslate": "自動翻譯",
|
||||
"settingsDataSavingMode": "低數據模式",
|
||||
"dataSavingHint": "低數據模式",
|
||||
"settingsHideBottomNav": "隱藏底部導航",
|
||||
"settingsSoundEffects": "音效",
|
||||
"settingsAprilFoolFeatures": "愚人節功能",
|
||||
@@ -675,7 +668,6 @@
|
||||
"publisherFeatureDevelopDescription": "為你的開發者解鎖包括應用套件,API 及更多開發功能。",
|
||||
"publisherFeatureDevelopHint": "目前該功能還在開發中,你需要邀請才可解鎖。",
|
||||
"learnMore": "瞭解更多",
|
||||
"discoverWebArticles": "來自站外的文章",
|
||||
"webArticlesStand": "文章亭",
|
||||
"about": "關於",
|
||||
"somethingWentWrong": "發生了一些錯誤",
|
||||
@@ -698,8 +690,6 @@
|
||||
"sharePostPhoto": "通過圖片分享帖子",
|
||||
"wouldYouLikeToNavigateToChat": "你想要前往聊天頁面嗎?",
|
||||
"abuseReports": "舉報",
|
||||
"discoverRealms": "發現領域",
|
||||
"discoverPublishers": "發現發佈者",
|
||||
"membershipCancel": "取消會員訂閱",
|
||||
"membershipCancelConfirm": "你確定要取消會員訂閱嗎?",
|
||||
"membershipCancelHint": "你確定要取消會員訂閱嗎?你將不會再次被扣費。你的會員資格將在當前計費週期結束前保持有效。並且你將無法重新訂閱,直到當前訂閱結束。",
|
||||
@@ -819,6 +809,159 @@
|
||||
"one": "+{} 個文件被摺疊",
|
||||
"other": "+{} 個文件被摺疊"
|
||||
},
|
||||
"pollQuestions": "問題",
|
||||
"pollAnswerSubmitted": "投票答案已提交。",
|
||||
"modifyAnswers": "修改答案",
|
||||
"back": "返回",
|
||||
"submit": "提交",
|
||||
"pollOptionDefaultLabel": "選項1",
|
||||
"pollUpdated": "投票已更新。",
|
||||
"pollCreated": "投票已創建。",
|
||||
"pollCreate": "創建投票",
|
||||
"pollEdit": "編輯投票",
|
||||
"pollPreviewJsonDebug": "調試預覽",
|
||||
"pollTitleRequired": "標題不可為空",
|
||||
"pollEndDateOptional": "結束日期和時間 (可選)",
|
||||
"notSet": "未設定",
|
||||
"pick": "選擇",
|
||||
"clear": "清除",
|
||||
"questions": "問題",
|
||||
"pollAddQuestion": "添加問題",
|
||||
"pollQuestionTypeSingleChoice": "單選框",
|
||||
"pollQuestionTypeMultipleChoice": "多選框",
|
||||
"pollQuestionTypeFreeText": "自由文本",
|
||||
"pollQuestionTypeYesNo": "是 / 不是",
|
||||
"pollQuestionTypeRating": "評分",
|
||||
"pollNoQuestionsYet": "尚未有問題",
|
||||
"pollNoQuestionsHint": "使用「添加問題」開始建立您的投票。",
|
||||
"pollDebugPreview": "調試預覽",
|
||||
"pollUntitledQuestion": "無標題問題",
|
||||
"moveUp": "往上移動",
|
||||
"moveDown": "往下移動",
|
||||
"required": "必需的",
|
||||
"pollQuestionTitle": "問題標題",
|
||||
"pollQuestionTitleRequired": "問題標題是必需的",
|
||||
"pollQuestionDescriptionOptional": "問題描述(選填)",
|
||||
"options": "選項",
|
||||
"pollAddOption": "添加選項",
|
||||
"pollOptionLabel": "選項標籤",
|
||||
"pollLongTextAnswerPreview": "長文本答案 (預覽)",
|
||||
"pollShortTextAnswerPreview": "短文本答案 (預覽)",
|
||||
"award": "讚賞",
|
||||
"awardPost": "讚賞帖子",
|
||||
"awardMessage": "消息",
|
||||
"awardMessageHint": "輸入您的讚賞消息...",
|
||||
"awardAttitude": "態度",
|
||||
"awardAttitudePositive": "積極",
|
||||
"awardAttitudeNegative": "消极",
|
||||
"awardAmount": "金額",
|
||||
"awardAmountHint": "輸入金額……",
|
||||
"awardAmountRequired": "「金額」為必填字段",
|
||||
"awardAmountInvalid": "請輸入有效金額",
|
||||
"awardMessageTooLong": "消息太長(最多4096個字符)",
|
||||
"awardSuccess": "獎勵已成功發送!",
|
||||
"awardSubmit": "讚賞",
|
||||
"awardPostPreview": "帖子預覽",
|
||||
"awardNoContent": "暫無內容",
|
||||
"awardByPublisher": "由 {} 發表",
|
||||
"awardBenefits": "讚賞福利",
|
||||
"awardBenefitsDescription": "為該帖子授予獎勵可以提升其價值和曝光度。價值更高的帖子更有可能在社區中被推薦和突出顯示。",
|
||||
"checkInResultLevel5": "生日快樂 🥳",
|
||||
"region": "區域",
|
||||
"accountRegionHint": "這個區域將用於內容傳遞和本地化。",
|
||||
"settingsCustomFontsHelper": "使用逗號分隔。",
|
||||
"settingsBackgroundImageEnable": "顯示背景圖片",
|
||||
"settingsDataSavingMode": "低數據模式",
|
||||
"dataSavingHint": "低數據模式",
|
||||
"postTypePost": "帖子",
|
||||
"searchDrafts": "搜尋草稿……",
|
||||
"noSearchResults": "無搜尋結果",
|
||||
"contactMethodMakePublic": "設為公開",
|
||||
"contactMethodMakePrivate": "設定為僅自己可見",
|
||||
"contactMethodPublic": "公開",
|
||||
"contactMethodPrivate": "私密",
|
||||
"discoverRealms": "發現領域",
|
||||
"discoverPublishers": "發現發佈者",
|
||||
"discoverShuffledPost": "隨機帖子",
|
||||
"projects": "項目",
|
||||
"noProjects": "未找到項目。",
|
||||
"deleteProject": "刪除項目",
|
||||
"deleteProjectHint": "確定要刪除此項目嗎?此操作無法撤銷。",
|
||||
"createProject": "新建專案",
|
||||
"editProject": "編輯項目",
|
||||
"projectDetails": "專案描述",
|
||||
"createBot": "創建機器人",
|
||||
"bots": "機器人",
|
||||
"noBots": "還沒有機器人。",
|
||||
"deleteBotHint": "您確定要刪除這個機器人嗎?此操作無法撤銷。",
|
||||
"deleteBot": "刪除機器人",
|
||||
"discoverWebArticles": "來自站外的文章",
|
||||
"messageJumpNotLoaded": "引用的訊息未加載,無法跳轉到該訊息。",
|
||||
"postUnlinkRealm": "未連結到領域",
|
||||
"postSlug": "別名",
|
||||
"postSlugHint": "這個別名可以用於在網頁通過 URL 瀏覽到你的帖子,它應該在同一發布者中是唯一。",
|
||||
"attachmentOnDevice": "離線",
|
||||
"attachmentOnCloud": "在線",
|
||||
"attachments": "附件",
|
||||
"publisherCollabInvitation": "協作邀請",
|
||||
"publisherCollabInvitationCount": {
|
||||
"zero": "無邀請",
|
||||
"one": "{} 個可用邀請",
|
||||
"other": "{} 個可用邀請"
|
||||
},
|
||||
"failedToLoadUserInfo": "無法加載用戶資訊",
|
||||
"failedToLoadUserInfoNetwork": "看起來是網絡問題,您可以點擊下面的按鈕再試一次。",
|
||||
"failedToLoadUserInfoUnauthorized": "看起來您的會話已經登出或不再可用,如果您想的話,您仍然可以嘗試再次獲取用戶資訊。",
|
||||
"okay": "好的",
|
||||
"postDetail": "帖子詳情",
|
||||
"postCount": {
|
||||
"zero": "沒有帖子",
|
||||
"one": "{} 帖子",
|
||||
"other": "{} 帖子"
|
||||
},
|
||||
"mimeType": "類型",
|
||||
"fileSize": "文件大小",
|
||||
"fileHash": "文件哈希",
|
||||
"exifData": "EXIF 數據",
|
||||
"postShuffle": "隨機帖子",
|
||||
"leveling": "等級",
|
||||
"levelingHistory": "經驗記錄",
|
||||
"stellarProgram": "恆星計畫",
|
||||
"socialCredits": "社會信用點",
|
||||
"credits": "信用",
|
||||
"creditsStatus": "積分狀態",
|
||||
"socialCreditsDescription": "社會信用是 Solar Network 評價用戶的一種方式。它基於用戶的行為和互動來計算。以 100 分為基準,分數越高表示用戶在社區中的信譽越好。分數會隨著時間的推移而變化,反映用戶的最新行為。信用等級高的用戶可以享受到更多的福利,反之的用戶部分功能可能受到限制。",
|
||||
"socialCreditsLevelPoor": "糟糕",
|
||||
"socialCreditsLevelNormal": "正常",
|
||||
"socialCreditsLevelGood": "良好",
|
||||
"socialCreditsLevelExcellent": "優秀",
|
||||
"orderByPopularity": "按熱度排序",
|
||||
"orderByReleaseDate": "按發佈日期排序",
|
||||
"editBot": "編輯機器人",
|
||||
"botAutomatedBy": "由 {} 自動化",
|
||||
"botDetails": "機器人描述",
|
||||
"overview": "概述",
|
||||
"keys": "密鑰",
|
||||
"botNotFound": "機器人未找到。",
|
||||
"newBotKey": "新建密鑰",
|
||||
"newBotKeyHint": "輸入新密鑰的名稱,密鑰只會顯示一次。",
|
||||
"revokeBotKey": "撤銷密鑰",
|
||||
"revokeBotKeyHint": "你確定要撤銷這個密鑰?這個操作無法撤回,所有使用該密鑰的應用程式會停止工作。",
|
||||
"noBotKeys": "機器人未找到。",
|
||||
"revoke": "撤銷",
|
||||
"keyName": "密鑰名稱",
|
||||
"newKeyGenerated": "新密鑰已生成",
|
||||
"copyKeyHint": "請安全地保存該密鑰,你不會再次看到它。",
|
||||
"rotateKey": "旋轉密鑰",
|
||||
"rotateBotKey": "旋轉密鑰",
|
||||
"rotateBotKeyHint": "你確認要旋轉這個密鑰?久的密鑰會立即失效,該操作無法撤銷。",
|
||||
"webFeedArticleCount": {
|
||||
"zero": "無文章",
|
||||
"one": "{} 文章",
|
||||
"other": "{} 文章"
|
||||
},
|
||||
"webFeedSubscribed": "你已經訂閱了這個來源",
|
||||
"webFeedUnsubscribed": "你已經取消訂閱這個來源",
|
||||
"appDetails": "應用程式詳情",
|
||||
"secrets": "密鑰",
|
||||
"appNotFound": "找不到應用程式。",
|
||||
@@ -830,5 +973,107 @@
|
||||
"newSecretGenerated": "已產生新密鑰",
|
||||
"copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。",
|
||||
"expiresIn": "過期時間(秒)",
|
||||
"isOidc": "OIDC 相容"
|
||||
}
|
||||
"isOidc": "OIDC 相容",
|
||||
"pinPost": "置頂帖子",
|
||||
"unpinPost": "取消置頂",
|
||||
"pinnedPost": "已置顶",
|
||||
"publisherPage": "發布者頁面",
|
||||
"realmPage": "領域頁面",
|
||||
"replyPage": "回覆頁面",
|
||||
"pinPostPublisherHint": "將這篇文章置顶到您的發佈者頁面",
|
||||
"pinPostRealmHint": "將這篇文章置顶到領域頁面",
|
||||
"pinPostRealmDisabledHint": "這個帖子不屬於任何領域",
|
||||
"pinPostReplyHint": "將這篇文章置顶到回覆頁面",
|
||||
"pinPostReplyDisabledHint": "這篇帖子不是回覆",
|
||||
"pin": "置顶",
|
||||
"unpinPostHint": "你確定要取消置顶這篇帖子嗎?",
|
||||
"all": "所有",
|
||||
"statusPresent": "至今",
|
||||
"accountAutomated": "機器人",
|
||||
"chatBreakClearButton": "清除",
|
||||
"chatBreak5m": "5 分鐘",
|
||||
"chatBreak10m": "10 分鐘",
|
||||
"chatBreak15m": "15 分鐘",
|
||||
"chatBreak30m": "30 分鐘",
|
||||
"chatBreakCustomMinutes": "自訂(分鐘)",
|
||||
"errorGeneric": "錯誤:{}",
|
||||
"searchMessages": "搜尋消息",
|
||||
"messagesCount": "{} 消息",
|
||||
"dotSeparator": ".",
|
||||
"roleValidationHint": "成員角色必須設置在0到100之間",
|
||||
"searchMessagesHint": "搜尋消息…",
|
||||
"searchLinks": "連結",
|
||||
"searchAttachments": "附件",
|
||||
"noMessagesFound": "未找到消息",
|
||||
"openInBrowser": "在瀏覽器打開",
|
||||
"highlightPost": "精選帖子",
|
||||
"filters": "過濾器",
|
||||
"apply": "應用",
|
||||
"pubName": "題目名稱",
|
||||
"realm": "領域",
|
||||
"shuffle": "隨機",
|
||||
"pinned": "已置顶",
|
||||
"noResultsFound": "未找到結果",
|
||||
"toggleFilters": "切換篩檢器",
|
||||
"notableDayNext": "距離 {} 還有",
|
||||
"expandPoll": "展開投票",
|
||||
"collapsePoll": "摺叠投票",
|
||||
"embedView": "嵌入視圖",
|
||||
"embedUri": "嵌入URL",
|
||||
"aspectRatio": "縱橫比",
|
||||
"renderer": "渲染器",
|
||||
"addEmbed": "添加嵌入",
|
||||
"editEmbed": "編輯嵌入",
|
||||
"deleteEmbed": "刪除嵌入",
|
||||
"deleteEmbedConfirm": "您確定要刪除這個嵌入嗎?",
|
||||
"currentEmbed": "當前嵌入",
|
||||
"noEmbed": "尚未嵌入",
|
||||
"save": "保存",
|
||||
"webView": "網頁視圖",
|
||||
"settingsDefaultPool": "預設檔案池",
|
||||
"settingsDefaultPoolHelper": "選擇文件上傳的默認儲存池",
|
||||
"uploadFile": "上傳檔案",
|
||||
"authDeviceChallenges": "設備活動",
|
||||
"authDeviceHint": "向左滑動以編輯標籤,向右滑動以登出設備。",
|
||||
"settingsMessageDisplayStyle": "訊息顯示樣式",
|
||||
"auto": "自動",
|
||||
"manual": "手動",
|
||||
"iframeCode": "Iframe 代碼",
|
||||
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
|
||||
"parseIframe": "解析 Iframe",
|
||||
"messageActions": "消息選項",
|
||||
"viewEmbedLoadHint": "點擊以載入",
|
||||
"levelingStage1": "新手",
|
||||
"levelingStage2": "學徒",
|
||||
"levelingStage3": "學徒工",
|
||||
"levelingStage4": "熟練",
|
||||
"levelingStage5": "專家",
|
||||
"levelingStage6": "大師",
|
||||
"levelingStage7": "宗師",
|
||||
"levelingStage8": "傳說",
|
||||
"levelingStage9": "神話",
|
||||
"levelingStage10": "不朽",
|
||||
"levelingStage11": "神聖",
|
||||
"levelingStage12": "超凡",
|
||||
"uploadAttachment": "上傳附件",
|
||||
"attachmentPreview": "附件預覽",
|
||||
"selectPool": "選擇檔案池",
|
||||
"choosePool": "選擇一個檔案池",
|
||||
"errorLoadingPools": "加載池時出錯",
|
||||
"quotaCostInfo": "這次上傳將消耗 {} 配額點",
|
||||
"uploadConstraints": "上傳限制",
|
||||
"fileSizeExceeded": "檔案大小超過了 {} 的最大限制",
|
||||
"fileTypeNotAccepted": "該文件類型不被此池接受",
|
||||
"files": "附件",
|
||||
"confirmDeleteFile": "你確定要刪除這個文件嗎?",
|
||||
"deleteFile": "刪除文件",
|
||||
"failedToDeleteFile": "刪除文件失敗",
|
||||
"drive": "雲盤",
|
||||
"allPools": "全部的池",
|
||||
"includeRecycled": "包含已回收文件",
|
||||
"confirmDeleteRecycledFiles": "您確定要刪除所有回收的檔案嗎?",
|
||||
"deleteRecycledFiles": "刪除已回收檔案",
|
||||
"recycledFilesDeleted": "已回收檔案刪除成功",
|
||||
"failedToDeleteRecycledFiles": "已回收檔案刪除失敗",
|
||||
"upload": "上傳"
|
||||
}
|
@@ -1,5 +1,7 @@
|
||||
PODS:
|
||||
- Alamofire (5.10.2)
|
||||
- app_links (6.4.1):
|
||||
- Flutter
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- croppy (0.0.1):
|
||||
@@ -293,6 +295,8 @@ PODS:
|
||||
- super_native_extensions (0.0.1):
|
||||
- Flutter
|
||||
- SwiftyGif (5.4.5)
|
||||
- syncfusion_flutter_pdfviewer (0.0.1):
|
||||
- Flutter
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- volume_controller (0.0.1):
|
||||
@@ -303,6 +307,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- Alamofire
|
||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
@@ -344,6 +349,7 @@ DEPENDENCIES:
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
|
||||
- syncfusion_flutter_pdfviewer (from `.symlinks/plugins/syncfusion_flutter_pdfviewer/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
@@ -379,6 +385,8 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
app_links:
|
||||
:path: ".symlinks/plugins/app_links/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
croppy:
|
||||
@@ -459,6 +467,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
||||
super_native_extensions:
|
||||
:path: ".symlinks/plugins/super_native_extensions/ios"
|
||||
syncfusion_flutter_pdfviewer:
|
||||
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
volume_controller:
|
||||
@@ -468,6 +478,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
@@ -533,6 +544,7 @@ SPEC CHECKSUMS:
|
||||
sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1
|
||||
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
|
||||
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
|
||||
|
@@ -36,6 +36,14 @@
|
||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>solian</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
|
128
lib/main.dart
128
lib/main.dart
@@ -1,6 +1,4 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:croppy/croppy.dart';
|
||||
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
@@ -8,16 +6,15 @@ import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker_android/image_picker_android.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/firebase_options.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/theme.dart';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.dart';
|
||||
@@ -29,18 +26,21 @@ import 'package:relative_time/relative_time.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
import 'package:talker_riverpod_logger/talker_riverpod_logger.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
log('Handling a background message: ${message.messageId}');
|
||||
talker.info('Handling a background message: ${message.messageId}');
|
||||
}
|
||||
|
||||
void main() async {
|
||||
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
log(
|
||||
talker.info(
|
||||
"[SplashScreen] Keeping the flash screen to loading other resources...",
|
||||
);
|
||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
@@ -73,48 +73,59 @@ void main() async {
|
||||
}
|
||||
}
|
||||
|
||||
log("[SplashScreen] Firebase is ready!");
|
||||
talker.info("[SplashScreen] Firebase is ready!");
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
}
|
||||
|
||||
try {
|
||||
log("[SplashScreen] Loading timezone database...");
|
||||
talker.info("[SplashScreen] Loading timezone database...");
|
||||
await initializeTzdb();
|
||||
log("[SplashScreen] Time zone database was loaded!");
|
||||
talker.info("[SplashScreen] Time zone database was loaded!");
|
||||
} catch (err) {
|
||||
log("[SplashScreen] Failed to load timezone database... $err");
|
||||
talker.error("[SplashScreen] Failed to load timezone database... $err");
|
||||
}
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
|
||||
doWhenWindowReady(() {
|
||||
const defaultSize = Size(360, 640);
|
||||
await windowManager.ensureInitialized();
|
||||
|
||||
// Get saved window size from preferences
|
||||
final savedSizeString = prefs.getString(kAppWindowSize);
|
||||
Size initialSize = defaultSize;
|
||||
const defaultSize = Size(360, 640);
|
||||
|
||||
if (savedSizeString != null) {
|
||||
try {
|
||||
final parts = savedSizeString.split(',');
|
||||
if (parts.length == 2) {
|
||||
final width = double.parse(parts[0]);
|
||||
final height = double.parse(parts[1]);
|
||||
initialSize = Size(width, height);
|
||||
}
|
||||
} catch (e) {
|
||||
log("[SplashScreen] Failed to parse saved window size: $e");
|
||||
initialSize = defaultSize;
|
||||
// Get saved window size from preferences
|
||||
final savedSizeString = prefs.getString(kAppWindowSize);
|
||||
Size initialSize = defaultSize;
|
||||
|
||||
if (savedSizeString != null) {
|
||||
try {
|
||||
final parts = savedSizeString.split(',');
|
||||
if (parts.length == 2) {
|
||||
final width = double.parse(parts[0]);
|
||||
final height = double.parse(parts[1]);
|
||||
initialSize = Size(width, height);
|
||||
}
|
||||
} catch (e) {
|
||||
talker.error("[SplashScreen] Failed to parse saved window size: $e");
|
||||
initialSize = defaultSize;
|
||||
}
|
||||
}
|
||||
|
||||
appWindow.minSize = defaultSize;
|
||||
appWindow.size = initialSize;
|
||||
appWindow.alignment = Alignment.center;
|
||||
appWindow.show();
|
||||
log(
|
||||
WindowOptions windowOptions = WindowOptions(
|
||||
size: initialSize,
|
||||
center: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
skipTaskbar: false,
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
windowButtonVisibility: true,
|
||||
);
|
||||
windowManager.waitUntilReadyToShow(windowOptions, () async {
|
||||
await windowManager.setMinimumSize(defaultSize);
|
||||
await windowManager.show();
|
||||
await windowManager.focus();
|
||||
final opacity = prefs.getDouble(kAppWindowOpacity) ?? 1.0;
|
||||
await windowManager.setOpacity(opacity);
|
||||
talker.info(
|
||||
"[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}",
|
||||
);
|
||||
});
|
||||
@@ -126,16 +137,27 @@ void main() async {
|
||||
if (imagePickerImplementation is ImagePickerAndroid) {
|
||||
imagePickerImplementation.useAndroidPhotoPicker = true;
|
||||
}
|
||||
log("[SplashScreen] Android image picker is ready!");
|
||||
talker.info("[SplashScreen] Android image picker is ready!");
|
||||
}
|
||||
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
FlutterNativeSplash.remove();
|
||||
log("[SplashScreen] Now hiding the splash screen...");
|
||||
talker.info("[SplashScreen] Now hiding the splash screen...");
|
||||
}
|
||||
|
||||
runApp(
|
||||
ProviderScope(
|
||||
observers: [
|
||||
TalkerRiverpodObserver(
|
||||
talker: talker,
|
||||
settings: TalkerRiverpodLoggerSettings(
|
||||
printProviderAdded: false,
|
||||
printProviderDisposed: false,
|
||||
printProviderUpdated: false,
|
||||
printStateFullData: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
@@ -144,6 +166,10 @@ void main() async {
|
||||
Locale('en', 'US'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh', 'TW'),
|
||||
Locale('zh', 'OG'),
|
||||
Locale('ja', 'JP'),
|
||||
Locale('ko', 'KR'),
|
||||
Locale('es', 'ES'),
|
||||
],
|
||||
path: 'assets/i18n',
|
||||
fallbackLocale: Locale('en', 'US'),
|
||||
@@ -165,6 +191,21 @@ class IslandApp extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = ref.watch(themeProvider);
|
||||
final settings = ref.watch(appSettingsNotifierProvider);
|
||||
|
||||
// Convert string theme mode to ThemeMode enum
|
||||
ThemeMode getThemeMode() {
|
||||
final themeMode = settings.themeMode ?? 'system';
|
||||
switch (themeMode) {
|
||||
case 'light':
|
||||
return ThemeMode.light;
|
||||
case 'dark':
|
||||
return ThemeMode.dark;
|
||||
case 'system':
|
||||
default:
|
||||
return ThemeMode.system;
|
||||
}
|
||||
}
|
||||
|
||||
void handleMessage(RemoteMessage notification) {
|
||||
if (notification.data['meta']?['action_uri'] != null) {
|
||||
@@ -201,7 +242,9 @@ class IslandApp extends HookConsumerWidget {
|
||||
final onMessageSubscription = FirebaseMessaging.onMessage.listen((
|
||||
message,
|
||||
) {
|
||||
log('Foreground message received: ${message.messageId}');
|
||||
talker.info(
|
||||
'[Notification] foreground message received: ${message.messageId}',
|
||||
);
|
||||
handleMessage(message);
|
||||
});
|
||||
|
||||
@@ -215,7 +258,7 @@ class IslandApp extends HookConsumerWidget {
|
||||
// Load userinfo
|
||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||
ref.listen(websocketStateProvider, (_, state) {
|
||||
log('[WebSocket] $state');
|
||||
talker.info('[WebSocket] $state');
|
||||
});
|
||||
Future(() {
|
||||
userNotifier.fetchUser().then((_) {
|
||||
@@ -235,9 +278,10 @@ class IslandApp extends HookConsumerWidget {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
color: Colors.transparent,
|
||||
theme: theme?.light,
|
||||
darkTheme: theme?.dark,
|
||||
themeMode: ThemeMode.system,
|
||||
themeMode: getThemeMode(),
|
||||
routerConfig: router,
|
||||
supportedLocales: context.supportedLocales,
|
||||
scrollBehavior: AppScrollBehavior(),
|
||||
@@ -252,9 +296,15 @@ class IslandApp extends HookConsumerWidget {
|
||||
key: globalOverlay,
|
||||
initialEntries: [
|
||||
OverlayEntry(
|
||||
builder:
|
||||
(_) =>
|
||||
WindowScaffold(child: child ?? const SizedBox.shrink()),
|
||||
builder: (_) {
|
||||
return TalkerWrapper(
|
||||
talker: talker,
|
||||
options: const TalkerWrapperOptions(enableErrorAlerts: true),
|
||||
child: WindowScaffold(
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
@@ -14,6 +14,7 @@ sealed class UniversalFile with _$UniversalFile {
|
||||
required dynamic data,
|
||||
required UniversalFileType type,
|
||||
@Default(false) bool isLink,
|
||||
String? displayName,
|
||||
}) = _UniversalFile;
|
||||
|
||||
factory UniversalFile.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -31,6 +32,7 @@ sealed class UniversalFile with _$UniversalFile {
|
||||
'video' => UniversalFileType.video,
|
||||
_ => UniversalFileType.file,
|
||||
},
|
||||
displayName: attachment.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$UniversalFile {
|
||||
|
||||
dynamic get data; UniversalFileType get type; bool get isLink;
|
||||
dynamic get data; UniversalFileType get type; bool get isLink; String? get displayName;
|
||||
/// Create a copy of UniversalFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)&&(identical(other.displayName, displayName) || other.displayName == displayName));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink);
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink,displayName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UniversalFile(data: $data, type: $type, isLink: $isLink)';
|
||||
return 'UniversalFile(data: $data, type: $type, isLink: $isLink, displayName: $displayName)';
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $UniversalFileCopyWith<$Res> {
|
||||
factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
dynamic data, UniversalFileType type, bool isLink
|
||||
dynamic data, UniversalFileType type, bool isLink, String? displayName
|
||||
});
|
||||
|
||||
|
||||
@@ -65,12 +65,13 @@ class _$UniversalFileCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of UniversalFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,Object? displayName = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
as bool,displayName: freezed == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -152,10 +153,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink, String? displayName)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UniversalFile() when $default != null:
|
||||
return $default(_that.data,_that.type,_that.isLink);case _:
|
||||
return $default(_that.data,_that.type,_that.isLink,_that.displayName);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -173,10 +174,10 @@ return $default(_that.data,_that.type,_that.isLink);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( dynamic data, UniversalFileType type, bool isLink, String? displayName) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UniversalFile():
|
||||
return $default(_that.data,_that.type,_that.isLink);}
|
||||
return $default(_that.data,_that.type,_that.isLink,_that.displayName);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
@@ -190,10 +191,10 @@ return $default(_that.data,_that.type,_that.isLink);}
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data, UniversalFileType type, bool isLink)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( dynamic data, UniversalFileType type, bool isLink, String? displayName)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _UniversalFile() when $default != null:
|
||||
return $default(_that.data,_that.type,_that.isLink);case _:
|
||||
return $default(_that.data,_that.type,_that.isLink,_that.displayName);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -205,12 +206,13 @@ return $default(_that.data,_that.type,_that.isLink);case _:
|
||||
@JsonSerializable()
|
||||
|
||||
class _UniversalFile extends UniversalFile {
|
||||
const _UniversalFile({required this.data, required this.type, this.isLink = false}): super._();
|
||||
const _UniversalFile({required this.data, required this.type, this.isLink = false, this.displayName}): super._();
|
||||
factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json);
|
||||
|
||||
@override final dynamic data;
|
||||
@override final UniversalFileType type;
|
||||
@override@JsonKey() final bool isLink;
|
||||
@override final String? displayName;
|
||||
|
||||
/// Create a copy of UniversalFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -225,16 +227,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _UniversalFile&&const DeepCollectionEquality().equals(other.data, data)&&(identical(other.type, type) || other.type == type)&&(identical(other.isLink, isLink) || other.isLink == isLink)&&(identical(other.displayName, displayName) || other.displayName == displayName));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink);
|
||||
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(data),type,isLink,displayName);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'UniversalFile(data: $data, type: $type, isLink: $isLink)';
|
||||
return 'UniversalFile(data: $data, type: $type, isLink: $isLink, displayName: $displayName)';
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +247,7 @@ abstract mixin class _$UniversalFileCopyWith<$Res> implements $UniversalFileCopy
|
||||
factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
dynamic data, UniversalFileType type, bool isLink
|
||||
dynamic data, UniversalFileType type, bool isLink, String? displayName
|
||||
});
|
||||
|
||||
|
||||
@@ -262,12 +264,13 @@ class __$UniversalFileCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of UniversalFile
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? data = freezed,Object? type = null,Object? isLink = null,Object? displayName = freezed,}) {
|
||||
return _then(_UniversalFile(
|
||||
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable
|
||||
as dynamic,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||
as UniversalFileType,isLink: null == isLink ? _self.isLink : isLink // ignore: cast_nullable_to_non_nullable
|
||||
as bool,
|
||||
as bool,displayName: freezed == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
@@ -11,6 +11,7 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) =>
|
||||
data: json['data'],
|
||||
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
|
||||
isLink: json['is_link'] as bool? ?? false,
|
||||
displayName: json['display_name'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
|
||||
@@ -18,6 +19,7 @@ Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
|
||||
'data': instance.data,
|
||||
'type': _$UniversalFileTypeEnumMap[instance.type]!,
|
||||
'is_link': instance.isLink,
|
||||
'display_name': instance.displayName,
|
||||
};
|
||||
|
||||
const _$UniversalFileTypeEnumMap = {
|
||||
|
@@ -1,11 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/account/status.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
import 'package:shelf/shelf_io.dart' as shelf_io;
|
||||
@@ -69,13 +69,13 @@ class ActivityRpcServer {
|
||||
|
||||
// Start WebSocket server
|
||||
while (port <= portRange[1]) {
|
||||
developer.log('Trying port $port', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] Trying port $port');
|
||||
try {
|
||||
_httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
|
||||
developer.log('Listening on $port', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] Listening on $port');
|
||||
|
||||
shelf_io.serveRequests(_httpServer!, (Request request) async {
|
||||
developer.log('New request', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] New request');
|
||||
if (request.headers['upgrade']?.toLowerCase() == 'websocket') {
|
||||
final handler = webSocketHandler((WebSocketChannel channel, _) {
|
||||
_wsSockets.add(channel);
|
||||
@@ -83,19 +83,16 @@ class ActivityRpcServer {
|
||||
});
|
||||
return handler(request);
|
||||
}
|
||||
developer.log(
|
||||
'New request disposed due to not websocket',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('New request disposed due to not websocket');
|
||||
return Response.notFound('Not a WebSocket request');
|
||||
});
|
||||
wsSuccess = true;
|
||||
break;
|
||||
} catch (e) {
|
||||
if (e is SocketException && e.osError?.errorCode == 98) {
|
||||
developer.log('$port in use!', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] $port in use!');
|
||||
} else {
|
||||
developer.log('HTTP error: $e', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] HTTP error: $e');
|
||||
}
|
||||
port++;
|
||||
await Future.delayed(Duration(milliseconds: 100)); // Add delay
|
||||
@@ -121,13 +118,10 @@ class ActivityRpcServer {
|
||||
|
||||
await _ipcServer!.start();
|
||||
} catch (e) {
|
||||
developer.log('IPC server error: $e', name: kRpcIpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] IPC server error: $e');
|
||||
}
|
||||
} else {
|
||||
developer.log(
|
||||
'IPC server disabled on macOS or web in production mode',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('IPC server disabled on macOS or web in production mode');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +132,7 @@ class ActivityRpcServer {
|
||||
try {
|
||||
await socket.sink.close();
|
||||
} catch (e) {
|
||||
developer.log('Error closing WebSocket: $e', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] Error closing WebSocket: $e');
|
||||
}
|
||||
}
|
||||
_wsSockets.clear();
|
||||
@@ -147,7 +141,7 @@ class ActivityRpcServer {
|
||||
// Stop IPC server
|
||||
await _ipcServer?.stop();
|
||||
|
||||
developer.log('Servers stopped', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] Servers stopped');
|
||||
}
|
||||
|
||||
// Handle new WebSocket connection
|
||||
@@ -159,10 +153,7 @@ class ActivityRpcServer {
|
||||
final clientId = params['client_id'] ?? '';
|
||||
final origin = request.headers['origin'] ?? '';
|
||||
|
||||
developer.log(
|
||||
'New WS connection! origin: $origin, params: $params',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('New WS connection! origin: $origin, params: $params');
|
||||
|
||||
if (origin.isNotEmpty &&
|
||||
![
|
||||
@@ -170,22 +161,19 @@ class ActivityRpcServer {
|
||||
'https://ptb.discord.com',
|
||||
'https://canary.discord.com',
|
||||
].contains(origin)) {
|
||||
developer.log('Disallowed origin: $origin', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] Disallowed origin: $origin');
|
||||
socket.sink.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (encoding != 'json') {
|
||||
developer.log(
|
||||
'Unsupported encoding requested: $encoding',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('Unsupported encoding requested: $encoding');
|
||||
socket.sink.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ver != 1) {
|
||||
developer.log('Unsupported version requested: $ver', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] Unsupported version requested: $ver');
|
||||
socket.sink.close();
|
||||
return;
|
||||
}
|
||||
@@ -195,10 +183,10 @@ class ActivityRpcServer {
|
||||
socket.stream.listen(
|
||||
(data) => _onWsMessage(socketWithMeta, data),
|
||||
onError: (e) {
|
||||
developer.log('WS socket error: $e', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] WS socket error: $e');
|
||||
},
|
||||
onDone: () {
|
||||
developer.log('WS socket closed', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] WS socket closed');
|
||||
handlers['close']?.call(socketWithMeta);
|
||||
_wsSockets.remove(socket);
|
||||
},
|
||||
@@ -210,25 +198,19 @@ class ActivityRpcServer {
|
||||
// Handle incoming WebSocket message
|
||||
Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async {
|
||||
if (data is! String) {
|
||||
developer.log(
|
||||
'Invalid WebSocket message: not a string',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('Invalid WebSocket message: not a string');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final jsonData = await compute(jsonDecode, data);
|
||||
if (jsonData is! Map<String, dynamic>) {
|
||||
developer.log(
|
||||
'Invalid WebSocket message: not a JSON object',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('Invalid WebSocket message: not a JSON object');
|
||||
return;
|
||||
}
|
||||
developer.log('WS message: $jsonData', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] WS message: $jsonData');
|
||||
handlers['message']?.call(socket, jsonData);
|
||||
} catch (e) {
|
||||
developer.log('WS message parse error: $e', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] WS message parse error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,12 +218,12 @@ class ActivityRpcServer {
|
||||
void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) {
|
||||
switch (packet.type) {
|
||||
case IpcTypes.ping:
|
||||
developer.log('IPC ping received', name: kRpcIpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] IPC ping received');
|
||||
socket.sendPong(packet.data);
|
||||
break;
|
||||
|
||||
case IpcTypes.pong:
|
||||
developer.log('IPC pong received', name: kRpcIpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] IPC pong received');
|
||||
break;
|
||||
|
||||
case IpcTypes.handshake:
|
||||
@@ -256,7 +238,7 @@ class ActivityRpcServer {
|
||||
if (!socket.handshook) {
|
||||
throw Exception('Need to handshake first');
|
||||
}
|
||||
developer.log('IPC frame: ${packet.data}', name: kRpcIpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] IPC frame: ${packet.data}');
|
||||
handlers['message']?.call(socket, packet.data);
|
||||
break;
|
||||
|
||||
@@ -271,22 +253,19 @@ class ActivityRpcServer {
|
||||
|
||||
// Handle IPC handshake
|
||||
void _onIpcHandshake(IpcSocketWrapper socket, Map<String, dynamic> params) {
|
||||
developer.log('IPC handshake: $params', name: kRpcIpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] IPC handshake: $params');
|
||||
|
||||
final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1;
|
||||
final clientId = params['client_id']?.toString() ?? '';
|
||||
|
||||
if (ver != 1) {
|
||||
developer.log(
|
||||
'IPC unsupported version requested: $ver',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('IPC unsupported version requested: $ver');
|
||||
socket.closeWithCode(IpcErrorCodes.invalidVersion);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientId.isEmpty) {
|
||||
developer.log('IPC client ID required', name: kRpcIpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] IPC client ID required');
|
||||
socket.closeWithCode(IpcErrorCodes.invalidClientId);
|
||||
return;
|
||||
}
|
||||
@@ -306,7 +285,7 @@ class _WsSocketWrapper {
|
||||
_WsSocketWrapper(this.channel, this.clientId, this.encoding);
|
||||
|
||||
void send(Map<String, dynamic> msg) {
|
||||
developer.log('WS sending: $msg', name: kRpcLogPrefix);
|
||||
talker.log('[$kRpcLogPrefix] WS sending: $msg');
|
||||
channel.sink.add(jsonEncode(msg));
|
||||
}
|
||||
}
|
||||
@@ -398,12 +377,7 @@ final rpcServerStateProvider =
|
||||
final appId = socket.clientId;
|
||||
final meta = data['args']['activity'];
|
||||
try {
|
||||
await setRemoteActivityStatus(
|
||||
ref,
|
||||
label,
|
||||
appId,
|
||||
meta,
|
||||
);
|
||||
await setRemoteActivityStatus(ref, label, appId, meta);
|
||||
final now = DateTime.now();
|
||||
final status = SnAccountStatus(
|
||||
id: 'local_$appId',
|
||||
@@ -422,10 +396,7 @@ final rpcServerStateProvider =
|
||||
);
|
||||
ref.read(currentAccountStatusProvider.notifier).setStatus(status);
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Failed to set remote activity status: $e',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('Failed to set remote activity status: $e');
|
||||
}
|
||||
socket.send({
|
||||
'cmd': 'SET_ACTIVITY',
|
||||
@@ -442,10 +413,7 @@ final rpcServerStateProvider =
|
||||
await unsetRemoteActivityStatus(ref, appId);
|
||||
ref.read(currentAccountStatusProvider.notifier).clearStatus();
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Failed to unset remote activity status: $e',
|
||||
name: kRpcLogPrefix,
|
||||
);
|
||||
talker.log('Failed to unset remote activity status: $e');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:dart_ipc/dart_ipc.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
const String kRpcIpcLogPrefix = 'arRPC.ipc';
|
||||
@@ -128,23 +128,18 @@ class MultiPlatformIpcServer extends IpcServer {
|
||||
@override
|
||||
Future<void> start() async {
|
||||
try {
|
||||
final ipcPath = Platform.isWindows
|
||||
? r'\\.\pipe\discord-ipc-0'
|
||||
: await _findAvailableUnixIpcPath();
|
||||
final ipcPath =
|
||||
Platform.isWindows
|
||||
? r'\\.\pipe\discord-ipc-0'
|
||||
: await _findAvailableUnixIpcPath();
|
||||
|
||||
final serverSocket = await bind(ipcPath);
|
||||
developer.log(
|
||||
'IPC listening at $ipcPath',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('IPC listening at $ipcPath');
|
||||
|
||||
_serverSubscription = serverSocket.listen((socket) {
|
||||
final socketWrapper = MultiPlatformIpcSocketWrapper(socket);
|
||||
addSocket(socketWrapper);
|
||||
developer.log(
|
||||
'New IPC connection!',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('New IPC connection!');
|
||||
_handleIpcData(socketWrapper);
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -158,7 +153,7 @@ class MultiPlatformIpcServer extends IpcServer {
|
||||
try {
|
||||
socket.close();
|
||||
} catch (e) {
|
||||
developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix);
|
||||
talker.log('Error closing IPC socket: $e');
|
||||
}
|
||||
}
|
||||
sockets.clear();
|
||||
@@ -168,31 +163,30 @@ class MultiPlatformIpcServer extends IpcServer {
|
||||
// Handle incoming IPC data
|
||||
void _handleIpcData(MultiPlatformIpcSocketWrapper socket) {
|
||||
final startTime = DateTime.now();
|
||||
socket.socket.listen((data) {
|
||||
final readStart = DateTime.now();
|
||||
socket.addData(data);
|
||||
final readDuration = DateTime.now().difference(readStart).inMicroseconds;
|
||||
developer.log(
|
||||
'Read data took $readDuration microseconds',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
socket.socket.listen(
|
||||
(data) {
|
||||
final readStart = DateTime.now();
|
||||
socket.addData(data);
|
||||
final readDuration =
|
||||
DateTime.now().difference(readStart).inMicroseconds;
|
||||
talker.log('Read data took $readDuration microseconds');
|
||||
|
||||
final packets = socket.readPackets();
|
||||
for (final packet in packets) {
|
||||
handlePacket?.call(socket, packet, {});
|
||||
}
|
||||
}, onDone: () {
|
||||
developer.log('IPC connection closed', name: kRpcIpcLogPrefix);
|
||||
socket.close();
|
||||
}, onError: (e) {
|
||||
developer.log('IPC data error: $e', name: kRpcIpcLogPrefix);
|
||||
socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString());
|
||||
});
|
||||
final totalDuration = DateTime.now().difference(startTime).inMicroseconds;
|
||||
developer.log(
|
||||
'_handleIpcData took $totalDuration microseconds',
|
||||
name: kRpcIpcLogPrefix,
|
||||
final packets = socket.readPackets();
|
||||
for (final packet in packets) {
|
||||
handlePacket?.call(socket, packet, {});
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
talker.log('IPC connection closed');
|
||||
socket.close();
|
||||
},
|
||||
onError: (e) {
|
||||
talker.log('IPC data error: $e');
|
||||
socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString());
|
||||
},
|
||||
);
|
||||
final totalDuration = DateTime.now().difference(startTime).inMicroseconds;
|
||||
talker.log('_handleIpcData took $totalDuration microseconds');
|
||||
}
|
||||
|
||||
Future<String> _getMacOsSystemTmpDir() async {
|
||||
@@ -212,10 +206,7 @@ class MultiPlatformIpcServer extends IpcServer {
|
||||
baseDirs.add(macTempDir);
|
||||
}
|
||||
} catch (e) {
|
||||
developer.log(
|
||||
'Failed to get macOS system temp dir: $e',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('Failed to get macOS system temp dir: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,17 +232,11 @@ class MultiPlatformIpcServer extends IpcServer {
|
||||
try {
|
||||
await File(socketPath).delete();
|
||||
} catch (_) {}
|
||||
developer.log(
|
||||
'IPC socket will be created at: $socketPath',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('IPC socket will be created at: $socketPath');
|
||||
return socketPath;
|
||||
} catch (e) {
|
||||
if (i == 0) {
|
||||
developer.log(
|
||||
'IPC path $socketPath not available: $e',
|
||||
name: kRpcIpcLogPrefix,
|
||||
);
|
||||
talker.log('IPC path $socketPath not available: $e');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -271,7 +256,7 @@ class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper {
|
||||
|
||||
@override
|
||||
void send(Map<String, dynamic> msg) {
|
||||
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix);
|
||||
talker.log('IPC sending: $msg');
|
||||
final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg);
|
||||
socket.add(packet);
|
||||
}
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||
@@ -10,6 +9,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
part 'call.g.dart';
|
||||
part 'call.freezed.dart';
|
||||
@@ -212,7 +212,7 @@ class CallNotifier extends _$CallNotifier {
|
||||
|
||||
Future<void> joinRoom(String roomId) async {
|
||||
if (_roomId == roomId && _room != null) {
|
||||
log('[Call] Call skipped. Already has data');
|
||||
talker.info('[Call] Call skipped. Already has data');
|
||||
return;
|
||||
} else if (_room != null) {
|
||||
if (!_room!.isDisposed &&
|
@@ -6,7 +6,7 @@ part of 'call.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$callNotifierHash() => r'eb9bd41b97e9b5e9d54007c8327edb6567458846';
|
||||
String _$callNotifierHash() => r'd374402e51d331cf40724e51fd86bce3c5504776';
|
||||
|
||||
/// See also [CallNotifier].
|
||||
@ProviderFor(CallNotifier)
|
50
lib/pods/chat/chat_online_count.dart
Normal file
50
lib/pods/chat/chat_online_count.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
import 'dart:async';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
|
||||
part 'chat_online_count.g.dart';
|
||||
|
||||
@riverpod
|
||||
class ChatOnlineCountNotifier extends _$ChatOnlineCountNotifier {
|
||||
@override
|
||||
Future<int> build(String chatroomId) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
final ws = ref.watch(websocketProvider);
|
||||
|
||||
// Fetch initial online count
|
||||
final response = await apiClient.get(
|
||||
'/sphere/chat/$chatroomId/members/online',
|
||||
);
|
||||
final initialCount = response.data as int;
|
||||
|
||||
// Listen for websocket status updates
|
||||
final subscription = ws.dataStream.listen((WebSocketPacket packet) {
|
||||
if (packet.type == 'accounts.status.update') {
|
||||
final data = packet.data;
|
||||
if (data != null && data['chat_room_id'] == chatroomId) {
|
||||
final status = SnAccountStatus.fromJson(data['status']);
|
||||
var delta = status.isOnline ? 1 : -1;
|
||||
if (status.clearedAt != null &&
|
||||
status.clearedAt!.isBefore(DateTime.now())) {
|
||||
if (status.isInvisible) delta = 1;
|
||||
}
|
||||
// Update count based on online status
|
||||
state.whenData((currentCount) {
|
||||
final newCount = currentCount + delta;
|
||||
state = AsyncData(
|
||||
newCount.clamp(0, double.infinity).toInt(),
|
||||
); // Ensure non-negative
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
subscription.cancel();
|
||||
});
|
||||
|
||||
return initialCount;
|
||||
}
|
||||
}
|
168
lib/pods/chat/chat_online_count.g.dart
Normal file
168
lib/pods/chat/chat_online_count.g.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_online_count.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatOnlineCountNotifierHash() =>
|
||||
r'19af8fd0e9f62c65e12a68215406776085235fa3';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$ChatOnlineCountNotifier
|
||||
extends BuildlessAutoDisposeAsyncNotifier<int> {
|
||||
late final String chatroomId;
|
||||
|
||||
FutureOr<int> build(String chatroomId);
|
||||
}
|
||||
|
||||
/// See also [ChatOnlineCountNotifier].
|
||||
@ProviderFor(ChatOnlineCountNotifier)
|
||||
const chatOnlineCountNotifierProvider = ChatOnlineCountNotifierFamily();
|
||||
|
||||
/// See also [ChatOnlineCountNotifier].
|
||||
class ChatOnlineCountNotifierFamily extends Family<AsyncValue<int>> {
|
||||
/// See also [ChatOnlineCountNotifier].
|
||||
const ChatOnlineCountNotifierFamily();
|
||||
|
||||
/// See also [ChatOnlineCountNotifier].
|
||||
ChatOnlineCountNotifierProvider call(String chatroomId) {
|
||||
return ChatOnlineCountNotifierProvider(chatroomId);
|
||||
}
|
||||
|
||||
@override
|
||||
ChatOnlineCountNotifierProvider getProviderOverride(
|
||||
covariant ChatOnlineCountNotifierProvider provider,
|
||||
) {
|
||||
return call(provider.chatroomId);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'chatOnlineCountNotifierProvider';
|
||||
}
|
||||
|
||||
/// See also [ChatOnlineCountNotifier].
|
||||
class ChatOnlineCountNotifierProvider
|
||||
extends AutoDisposeAsyncNotifierProviderImpl<ChatOnlineCountNotifier, int> {
|
||||
/// See also [ChatOnlineCountNotifier].
|
||||
ChatOnlineCountNotifierProvider(String chatroomId)
|
||||
: this._internal(
|
||||
() => ChatOnlineCountNotifier()..chatroomId = chatroomId,
|
||||
from: chatOnlineCountNotifierProvider,
|
||||
name: r'chatOnlineCountNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatOnlineCountNotifierHash,
|
||||
dependencies: ChatOnlineCountNotifierFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
ChatOnlineCountNotifierFamily._allTransitiveDependencies,
|
||||
chatroomId: chatroomId,
|
||||
);
|
||||
|
||||
ChatOnlineCountNotifierProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.chatroomId,
|
||||
}) : super.internal();
|
||||
|
||||
final String chatroomId;
|
||||
|
||||
@override
|
||||
FutureOr<int> runNotifierBuild(covariant ChatOnlineCountNotifier notifier) {
|
||||
return notifier.build(chatroomId);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(ChatOnlineCountNotifier Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: ChatOnlineCountNotifierProvider._internal(
|
||||
() => create()..chatroomId = chatroomId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
chatroomId: chatroomId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeAsyncNotifierProviderElement<ChatOnlineCountNotifier, int>
|
||||
createElement() {
|
||||
return _ChatOnlineCountNotifierProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ChatOnlineCountNotifierProvider &&
|
||||
other.chatroomId == chatroomId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, chatroomId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin ChatOnlineCountNotifierRef on AutoDisposeAsyncNotifierProviderRef<int> {
|
||||
/// The parameter `chatroomId` of this provider.
|
||||
String get chatroomId;
|
||||
}
|
||||
|
||||
class _ChatOnlineCountNotifierProviderElement
|
||||
extends
|
||||
AutoDisposeAsyncNotifierProviderElement<ChatOnlineCountNotifier, int>
|
||||
with ChatOnlineCountNotifierRef {
|
||||
_ChatOnlineCountNotifierProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get chatroomId =>
|
||||
(origin as ChatOnlineCountNotifierProvider).chatroomId;
|
||||
}
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
5
lib/pods/chat/chat_rooms.dart
Normal file
5
lib/pods/chat/chat_rooms.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
|
||||
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
|
||||
|
||||
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
|
229
lib/pods/chat/chat_subscribe.dart
Normal file
229
lib/pods/chat/chat_subscribe.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import "dart:async";
|
||||
import "dart:convert";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_riverpod/flutter_riverpod.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/pods/lifecycle.dart";
|
||||
import "package:island/pods/chat/messages_notifier.dart";
|
||||
import "package:island/pods/websocket.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/widgets/chat/call_button.dart";
|
||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
|
||||
part 'chat_subscribe.g.dart';
|
||||
|
||||
final currentSubscribedChatIdProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
@riverpod
|
||||
class ChatSubscribeNotifier extends _$ChatSubscribeNotifier {
|
||||
late final String _roomId;
|
||||
late final SnChatRoom _chatRoom;
|
||||
late final SnChatMember _chatIdentity;
|
||||
late final MessagesNotifier _messagesNotifier;
|
||||
|
||||
final List<SnChatMember> _typingStatuses = [];
|
||||
Timer? _typingCleanupTimer;
|
||||
Timer? _typingCooldownTimer;
|
||||
Timer? _periodicSubscribeTimer;
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
@override
|
||||
List<SnChatMember> build(String roomId) {
|
||||
_roomId = roomId;
|
||||
final ws = ref.watch(websocketProvider);
|
||||
final chatRoomAsync = ref.watch(chatroomProvider(roomId));
|
||||
final chatIdentityAsync = ref.watch(chatroomIdentityProvider(roomId));
|
||||
_messagesNotifier = ref.watch(messagesNotifierProvider(roomId).notifier);
|
||||
|
||||
if (chatRoomAsync.isLoading || chatIdentityAsync.isLoading) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (chatRoomAsync.value == null || chatIdentityAsync.value == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
_chatRoom = chatRoomAsync.value!;
|
||||
_chatIdentity = chatIdentityAsync.value!;
|
||||
|
||||
// Subscribe to messages
|
||||
final wsState = ref.read(websocketStateProvider.notifier);
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.subscribe',
|
||||
data: {'chat_room_id': roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future.microtask(
|
||||
() => ref.read(currentSubscribedChatIdProvider.notifier).state = roomId,
|
||||
);
|
||||
|
||||
// Send initial read receipt
|
||||
sendReadReceipt();
|
||||
|
||||
// Set up WebSocket listener
|
||||
_wsSubscription = ws.dataStream.listen(onMessage);
|
||||
|
||||
// Set up typing status cleanup timer
|
||||
_typingCleanupTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (_typingStatuses.isNotEmpty) {
|
||||
// Remove typing statuses older than 5 seconds
|
||||
final now = DateTime.now();
|
||||
_typingStatuses.removeWhere((member) {
|
||||
final lastTyped =
|
||||
member.lastTyped ??
|
||||
DateTime.now().subtract(const Duration(milliseconds: 1350));
|
||||
return now.difference(lastTyped).inSeconds > 5;
|
||||
});
|
||||
state = List.of(_typingStatuses);
|
||||
}
|
||||
});
|
||||
|
||||
// Set up periodic subscribe timer (every 5 minutes)
|
||||
_periodicSubscribeTimer = Timer.periodic(const Duration(minutes: 5), (_) {
|
||||
final wsState = ref.read(websocketStateProvider.notifier);
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.subscribe',
|
||||
data: {'chat_room_id': roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Listen to app lifecycle changes
|
||||
ref.listen(appLifecycleStateProvider, (previous, next) {
|
||||
final lifecycleState = next.value;
|
||||
if (lifecycleState == AppLifecycleState.paused ||
|
||||
lifecycleState == AppLifecycleState.inactive) {
|
||||
// Unsubscribe when app goes to background
|
||||
final wsState = ref.read(websocketStateProvider.notifier);
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.unsubscribe',
|
||||
data: {'chat_room_id': roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (lifecycleState == AppLifecycleState.resumed) {
|
||||
// Resubscribe when app comes back to foreground
|
||||
final wsState = ref.read(websocketStateProvider.notifier);
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.subscribe',
|
||||
data: {'chat_room_id': roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on dispose
|
||||
ref.onDispose(() {
|
||||
ref.read(currentSubscribedChatIdProvider.notifier).state = null;
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.unsubscribe',
|
||||
data: {'chat_room_id': roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
_wsSubscription?.cancel();
|
||||
_typingCleanupTimer?.cancel();
|
||||
_typingCooldownTimer?.cancel();
|
||||
_periodicSubscribeTimer?.cancel();
|
||||
});
|
||||
|
||||
return _typingStatuses;
|
||||
}
|
||||
|
||||
void onMessage(WebSocketPacket pkt) {
|
||||
if (!pkt.type.startsWith('messages')) return;
|
||||
if (['messages.read'].contains(pkt.type)) return;
|
||||
|
||||
if (pkt.type == 'messages.typing' && pkt.data?['sender'] != null) {
|
||||
if (pkt.data?['room_id'] != _chatRoom.id) return;
|
||||
if (pkt.data?['sender_id'] == _chatIdentity.id) return;
|
||||
|
||||
final sender = SnChatMember.fromJson(
|
||||
pkt.data?['sender'],
|
||||
).copyWith(lastTyped: DateTime.now());
|
||||
|
||||
// Check if the sender is already in the typing list
|
||||
final existingIndex = _typingStatuses.indexWhere(
|
||||
(member) => member.id == sender.id,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
// Update the existing entry with new timestamp
|
||||
_typingStatuses[existingIndex] = sender;
|
||||
} else {
|
||||
// Add new typing status
|
||||
_typingStatuses.add(sender);
|
||||
}
|
||||
state = List.of(_typingStatuses);
|
||||
return;
|
||||
}
|
||||
|
||||
final message = SnChatMessage.fromJson(pkt.data!);
|
||||
if (message.chatRoomId != _chatRoom.id) return;
|
||||
switch (pkt.type) {
|
||||
case 'messages.new':
|
||||
case 'messages.update':
|
||||
case 'messages.delete':
|
||||
if (message.type.startsWith('call')) {
|
||||
// Handle the ongoing call.
|
||||
ref.invalidate(ongoingCallProvider(message.chatRoomId));
|
||||
}
|
||||
_messagesNotifier.receiveMessage(message);
|
||||
// Send read receipt for new message
|
||||
sendReadReceipt();
|
||||
}
|
||||
}
|
||||
|
||||
void sendReadReceipt() {
|
||||
// Send websocket packet
|
||||
final wsState = ref.read(websocketStateProvider.notifier);
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.read',
|
||||
data: {'chat_room_id': _roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void sendTypingStatus() {
|
||||
// Don't send if we're already in a cooldown period
|
||||
if (_typingCooldownTimer != null) return;
|
||||
|
||||
// Send typing status immediately
|
||||
final wsState = ref.read(websocketStateProvider.notifier);
|
||||
wsState.sendMessage(
|
||||
jsonEncode(
|
||||
WebSocketPacket(
|
||||
type: 'messages.typing',
|
||||
data: {'chat_room_id': _roomId},
|
||||
endpoint: 'sphere',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
_typingCooldownTimer = Timer(const Duration(milliseconds: 850), () {
|
||||
_typingCooldownTimer = null;
|
||||
});
|
||||
}
|
||||
}
|
176
lib/pods/chat/chat_subscribe.g.dart
Normal file
176
lib/pods/chat/chat_subscribe.g.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'chat_subscribe.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatSubscribeNotifierHash() =>
|
||||
r'c605e0c9c45df64e5ba7b65f8de9b47bde8e2b3b';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
_SystemHash._();
|
||||
|
||||
static int combine(int hash, int value) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + value);
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
|
||||
return hash ^ (hash >> 6);
|
||||
}
|
||||
|
||||
static int finish(int hash) {
|
||||
// ignore: parameter_assignments
|
||||
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
|
||||
// ignore: parameter_assignments
|
||||
hash = hash ^ (hash >> 11);
|
||||
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _$ChatSubscribeNotifier
|
||||
extends BuildlessAutoDisposeNotifier<List<SnChatMember>> {
|
||||
late final String roomId;
|
||||
|
||||
List<SnChatMember> build(String roomId);
|
||||
}
|
||||
|
||||
/// See also [ChatSubscribeNotifier].
|
||||
@ProviderFor(ChatSubscribeNotifier)
|
||||
const chatSubscribeNotifierProvider = ChatSubscribeNotifierFamily();
|
||||
|
||||
/// See also [ChatSubscribeNotifier].
|
||||
class ChatSubscribeNotifierFamily extends Family<List<SnChatMember>> {
|
||||
/// See also [ChatSubscribeNotifier].
|
||||
const ChatSubscribeNotifierFamily();
|
||||
|
||||
/// See also [ChatSubscribeNotifier].
|
||||
ChatSubscribeNotifierProvider call(String roomId) {
|
||||
return ChatSubscribeNotifierProvider(roomId);
|
||||
}
|
||||
|
||||
@override
|
||||
ChatSubscribeNotifierProvider getProviderOverride(
|
||||
covariant ChatSubscribeNotifierProvider provider,
|
||||
) {
|
||||
return call(provider.roomId);
|
||||
}
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _dependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
|
||||
|
||||
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
|
||||
|
||||
@override
|
||||
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
|
||||
_allTransitiveDependencies;
|
||||
|
||||
@override
|
||||
String? get name => r'chatSubscribeNotifierProvider';
|
||||
}
|
||||
|
||||
/// See also [ChatSubscribeNotifier].
|
||||
class ChatSubscribeNotifierProvider
|
||||
extends
|
||||
AutoDisposeNotifierProviderImpl<
|
||||
ChatSubscribeNotifier,
|
||||
List<SnChatMember>
|
||||
> {
|
||||
/// See also [ChatSubscribeNotifier].
|
||||
ChatSubscribeNotifierProvider(String roomId)
|
||||
: this._internal(
|
||||
() => ChatSubscribeNotifier()..roomId = roomId,
|
||||
from: chatSubscribeNotifierProvider,
|
||||
name: r'chatSubscribeNotifierProvider',
|
||||
debugGetCreateSourceHash:
|
||||
const bool.fromEnvironment('dart.vm.product')
|
||||
? null
|
||||
: _$chatSubscribeNotifierHash,
|
||||
dependencies: ChatSubscribeNotifierFamily._dependencies,
|
||||
allTransitiveDependencies:
|
||||
ChatSubscribeNotifierFamily._allTransitiveDependencies,
|
||||
roomId: roomId,
|
||||
);
|
||||
|
||||
ChatSubscribeNotifierProvider._internal(
|
||||
super._createNotifier, {
|
||||
required super.name,
|
||||
required super.dependencies,
|
||||
required super.allTransitiveDependencies,
|
||||
required super.debugGetCreateSourceHash,
|
||||
required super.from,
|
||||
required this.roomId,
|
||||
}) : super.internal();
|
||||
|
||||
final String roomId;
|
||||
|
||||
@override
|
||||
List<SnChatMember> runNotifierBuild(
|
||||
covariant ChatSubscribeNotifier notifier,
|
||||
) {
|
||||
return notifier.build(roomId);
|
||||
}
|
||||
|
||||
@override
|
||||
Override overrideWith(ChatSubscribeNotifier Function() create) {
|
||||
return ProviderOverride(
|
||||
origin: this,
|
||||
override: ChatSubscribeNotifierProvider._internal(
|
||||
() => create()..roomId = roomId,
|
||||
from: from,
|
||||
name: null,
|
||||
dependencies: null,
|
||||
allTransitiveDependencies: null,
|
||||
debugGetCreateSourceHash: null,
|
||||
roomId: roomId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
AutoDisposeNotifierProviderElement<ChatSubscribeNotifier, List<SnChatMember>>
|
||||
createElement() {
|
||||
return _ChatSubscribeNotifierProviderElement(this);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is ChatSubscribeNotifierProvider && other.roomId == roomId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
var hash = _SystemHash.combine(0, runtimeType.hashCode);
|
||||
hash = _SystemHash.combine(hash, roomId.hashCode);
|
||||
|
||||
return _SystemHash.finish(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||
// ignore: unused_element
|
||||
mixin ChatSubscribeNotifierRef
|
||||
on AutoDisposeNotifierProviderRef<List<SnChatMember>> {
|
||||
/// The parameter `roomId` of this provider.
|
||||
String get roomId;
|
||||
}
|
||||
|
||||
class _ChatSubscribeNotifierProviderElement
|
||||
extends
|
||||
AutoDisposeNotifierProviderElement<
|
||||
ChatSubscribeNotifier,
|
||||
List<SnChatMember>
|
||||
>
|
||||
with ChatSubscribeNotifierRef {
|
||||
_ChatSubscribeNotifierProviderElement(super.provider);
|
||||
|
||||
@override
|
||||
String get roomId => (origin as ChatSubscribeNotifierProvider).roomId;
|
||||
}
|
||||
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
@@ -1,6 +1,8 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/pods/chat/chat_subscribe.dart';
|
||||
|
||||
part 'chat_summary.g.dart';
|
||||
|
||||
@@ -12,9 +14,27 @@ class ChatSummary extends _$ChatSummary {
|
||||
final resp = await client.get('/sphere/chat/summary');
|
||||
|
||||
final Map<String, dynamic> data = resp.data;
|
||||
return data.map(
|
||||
final summaries = data.map(
|
||||
(key, value) => MapEntry(key, SnChatSummary.fromJson(value)),
|
||||
);
|
||||
|
||||
final ws = ref.watch(websocketProvider);
|
||||
final subscription = ws.dataStream.listen((WebSocketPacket pkt) {
|
||||
if (!pkt.type.startsWith('messages')) return;
|
||||
if (pkt.type == 'messages.new') {
|
||||
final message = SnChatMessage.fromJson(pkt.data!);
|
||||
updateLastMessage(message.chatRoomId, message);
|
||||
} else if (pkt.type == 'messages.update') {
|
||||
final message = SnChatMessage.fromJson(pkt.data!);
|
||||
updateMessageContent(message.chatRoomId, message);
|
||||
}
|
||||
});
|
||||
|
||||
ref.onDispose(() {
|
||||
subscription.cancel();
|
||||
});
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
Future<void> clearUnreadCount(String chatId) async {
|
||||
@@ -36,10 +56,12 @@ class ChatSummary extends _$ChatSummary {
|
||||
state.whenData((summaries) {
|
||||
final summary = summaries[chatId];
|
||||
if (summary != null) {
|
||||
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
|
||||
final increment = (chatId != currentSubscribed) ? 1 : 0;
|
||||
state = AsyncData({
|
||||
...summaries,
|
||||
chatId: SnChatSummary(
|
||||
unreadCount: summary.unreadCount + 1,
|
||||
unreadCount: summary.unreadCount + increment,
|
||||
lastMessage: message,
|
||||
),
|
||||
});
|
||||
@@ -61,4 +83,19 @@ class ChatSummary extends _$ChatSummary {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void updateMessageContent(String chatId, SnChatMessage message) {
|
||||
state.whenData((summaries) {
|
||||
final summary = summaries[chatId];
|
||||
if (summary != null && summary.lastMessage?.id == message.id) {
|
||||
state = AsyncData({
|
||||
...summaries,
|
||||
chatId: SnChatSummary(
|
||||
unreadCount: summary.unreadCount,
|
||||
lastMessage: message,
|
||||
),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ part of 'chat_summary.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$chatSummaryHash() => r'87a10e4cefa37dc5fa8eadb175ef1b2bed6070bf';
|
||||
String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
|
||||
|
||||
/// See also [ChatSummary].
|
||||
@ProviderFor(ChatSummary)
|
@@ -1,5 +1,4 @@
|
||||
import "dart:async";
|
||||
import "dart:developer" as developer;
|
||||
import "package:dio/dio.dart";
|
||||
import "package:drift/drift.dart" show Variable;
|
||||
import "package:easy_localization/easy_localization.dart";
|
||||
@@ -8,15 +7,16 @@ import "package:island/database/drift_db.dart";
|
||||
import "package:island/database/message.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/pods/database.dart";
|
||||
import "package:island/pods/lifecycle.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/services/file.dart";
|
||||
import "package:island/services/file_uploader.dart";
|
||||
import "package:island/talker.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
import "package:riverpod_annotation/riverpod_annotation.dart";
|
||||
import "package:uuid/uuid.dart";
|
||||
import "package:island/screens/chat/chat.dart";
|
||||
import "package:island/pods/chat_rooms.dart";
|
||||
import "package:island/pods/chat/chat_rooms.dart";
|
||||
|
||||
part 'messages_notifier.g.dart';
|
||||
|
||||
@@ -39,6 +39,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
bool _hasMore = true;
|
||||
bool _isSyncing = false;
|
||||
bool _isJumping = false;
|
||||
DateTime? _lastPauseTime;
|
||||
|
||||
@override
|
||||
FutureOr<List<LocalChatMessage>> build(String roomId) async {
|
||||
@@ -58,21 +59,27 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_identity = identity;
|
||||
}
|
||||
|
||||
developer.log(
|
||||
'MessagesNotifier built for room $roomId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('MessagesNotifier built for room $roomId');
|
||||
|
||||
// Only setup sync and lifecycle listeners if user is a member
|
||||
if (identity != null) {
|
||||
ref.listen(appLifecycleStateProvider, (_, next) {
|
||||
if (next.hasValue && next.value == AppLifecycleState.resumed) {
|
||||
developer.log(
|
||||
'App resumed, syncing messages',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
syncMessages();
|
||||
}
|
||||
next.whenData((state) {
|
||||
if (state == AppLifecycleState.paused) {
|
||||
_lastPauseTime = DateTime.now();
|
||||
talker.log('App paused, recording time');
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
if (_lastPauseTime != null) {
|
||||
final diff = DateTime.now().difference(_lastPauseTime!);
|
||||
if (diff > const Duration(minutes: 1)) {
|
||||
talker.log('App resumed after >1 min, syncing messages');
|
||||
syncMessages();
|
||||
} else {
|
||||
talker.log('App resumed within 1 min, skipping sync');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,10 +96,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
}) async {
|
||||
developer.log(
|
||||
'Getting cached messages from offset $offset, take $take',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Getting cached messages from offset $offset, take $take');
|
||||
final List<LocalChatMessage> dbMessages;
|
||||
if (_searchQuery != null && _searchQuery!.isNotEmpty) {
|
||||
dbMessages = await _database.searchMessages(
|
||||
@@ -154,10 +158,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
int offset = 0,
|
||||
int take = 20,
|
||||
}) async {
|
||||
developer.log(
|
||||
'Fetching messages from API, offset $offset, take $take',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Fetching messages from API, offset $offset, take $take');
|
||||
if (_totalCount == null) {
|
||||
final response = await _apiClient.get(
|
||||
'/sphere/chat/$_roomId/messages',
|
||||
@@ -201,15 +202,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> syncMessages() async {
|
||||
if (_isSyncing) {
|
||||
developer.log(
|
||||
'Sync already in progress, skipping.',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Sync already in progress, skipping.');
|
||||
return;
|
||||
}
|
||||
_isSyncing = true;
|
||||
|
||||
developer.log('Starting message sync', name: 'MessagesNotifier');
|
||||
talker.log('Starting message sync');
|
||||
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
|
||||
try {
|
||||
final dbMessages = await _database.getMessagesForRoom(
|
||||
@@ -223,10 +221,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
: _database.companionToMessage(dbMessages.first);
|
||||
|
||||
if (lastMessage == null) {
|
||||
developer.log(
|
||||
'No local messages, fetching from network',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('No local messages, fetching from network');
|
||||
final newMessages = await _fetchAndCacheMessages(
|
||||
offset: 0,
|
||||
take: _pageSize,
|
||||
@@ -244,10 +239,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
);
|
||||
|
||||
final response = MessageSyncResponse.fromJson(resp.data);
|
||||
developer.log(
|
||||
'Sync response: ${response.messages.length} changes',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Sync response: ${response.messages.length} changes');
|
||||
for (final message in response.messages) {
|
||||
switch (message.type) {
|
||||
case "messages.update":
|
||||
@@ -262,15 +254,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
await receiveMessage(message);
|
||||
}
|
||||
} catch (err, stackTrace) {
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Error syncing messages',
|
||||
name: 'MessagesNotifier',
|
||||
error: err,
|
||||
exception: err,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
developer.log('Finished message sync', name: 'MessagesNotifier');
|
||||
talker.log('Finished message sync');
|
||||
Future.microtask(
|
||||
() => ref.read(isSyncingProvider.notifier).state = false,
|
||||
);
|
||||
@@ -320,7 +311,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<void> loadInitial() async {
|
||||
developer.log('Loading initial messages', name: 'MessagesNotifier');
|
||||
talker.log('Loading initial messages');
|
||||
if (_searchQuery == null || _searchQuery!.isEmpty) {
|
||||
syncMessages();
|
||||
}
|
||||
@@ -334,7 +325,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> loadMore() async {
|
||||
if (!_hasMore || state is AsyncLoading) return;
|
||||
developer.log('Loading more messages', name: 'MessagesNotifier');
|
||||
talker.log('Loading more messages');
|
||||
|
||||
try {
|
||||
final currentMessages = state.value ?? [];
|
||||
@@ -350,10 +341,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_sortMessages([...currentMessages, ...newMessages]),
|
||||
);
|
||||
} catch (err, stackTrace) {
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Error loading more messages',
|
||||
name: 'MessagesNotifier',
|
||||
error: err,
|
||||
|
||||
exception: err,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
@@ -369,13 +360,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
Function(String, Map<int, double>)? onProgress,
|
||||
}) async {
|
||||
final nonce = const Uuid().v4();
|
||||
developer.log(
|
||||
'Sending message with nonce $nonce',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
final baseUrl = ref.read(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Access token is null');
|
||||
talker.log('Sending message with nonce $nonce');
|
||||
|
||||
final mockMessage = SnChatMessage(
|
||||
id: 'pending_$nonce',
|
||||
@@ -404,19 +389,9 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
var cloudAttachments = List.empty(growable: true);
|
||||
for (var idx = 0; idx < attachments.length; idx++) {
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
fileData: attachments[idx],
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: attachments[idx].data.name ?? 'Post media',
|
||||
mimetype:
|
||||
attachments[idx].data.mimeType ??
|
||||
switch (attachments[idx].type) {
|
||||
UniversalFileType.image => 'image/unknown',
|
||||
UniversalFileType.video => 'video/unknown',
|
||||
UniversalFileType.audio => 'audio/unknown',
|
||||
UniversalFileType.file => 'application/octet-stream',
|
||||
},
|
||||
client: ref.read(apiClientProvider),
|
||||
onProgress: (progress, _) {
|
||||
_fileUploadProgress[localMessage.id]?[idx] = progress;
|
||||
onProgress?.call(
|
||||
@@ -476,15 +451,12 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
}
|
||||
developer.log(
|
||||
'Message with nonce $nonce sent successfully',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Message with nonce $nonce sent successfully');
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Failed to send message with nonce $nonce',
|
||||
name: 'MessagesNotifier',
|
||||
error: e,
|
||||
|
||||
exception: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
localMessage.status = MessageStatus.failed;
|
||||
@@ -506,10 +478,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<void> retryMessage(String pendingMessageId) async {
|
||||
developer.log(
|
||||
'Retrying message $pendingMessageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Retrying message $pendingMessageId');
|
||||
final message = await fetchMessageById(pendingMessageId);
|
||||
if (message == null) {
|
||||
throw Exception('Message not found');
|
||||
@@ -553,10 +522,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}).toList();
|
||||
state = AsyncValue.data(newMessages);
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Failed to retry message $pendingMessageId',
|
||||
name: 'MessagesNotifier',
|
||||
error: e,
|
||||
|
||||
exception: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
message.status = MessageStatus.failed;
|
||||
@@ -579,10 +548,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
|
||||
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
developer.log(
|
||||
'Received new message ${remoteMessage.id}',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Received new message ${remoteMessage.id}');
|
||||
|
||||
final localMessage = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
@@ -613,17 +579,28 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
_sortMessages([localMessage, ...currentMessages]),
|
||||
);
|
||||
}
|
||||
|
||||
switch (remoteMessage.type) {
|
||||
case "messages.delete":
|
||||
await receiveMessageDeletion(
|
||||
remoteMessage.meta['message_id'] ?? remoteMessage.id,
|
||||
);
|
||||
case "messages.update":
|
||||
case "messages.update.links":
|
||||
await receiveMessageUpdate(remoteMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
|
||||
if (remoteMessage.chatRoomId != _roomId) return;
|
||||
developer.log(
|
||||
'Received message update ${remoteMessage.id}',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Received message update ${remoteMessage.id}');
|
||||
|
||||
final targetId = remoteMessage.meta['message_id'] ?? remoteMessage.id;
|
||||
final updatedMessage = LocalChatMessage.fromRemoteMessage(
|
||||
remoteMessage,
|
||||
remoteMessage.copyWith(
|
||||
id: targetId,
|
||||
meta: Map.of(remoteMessage.meta)..remove('message_id'),
|
||||
),
|
||||
MessageStatus.sent,
|
||||
);
|
||||
await _database.updateMessage(_database.messageToCompanion(updatedMessage));
|
||||
@@ -639,10 +616,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<void> receiveMessageDeletion(String messageId) async {
|
||||
developer.log(
|
||||
'Received message deletion $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Received message deletion $messageId');
|
||||
_pendingMessages.remove(messageId);
|
||||
|
||||
final currentMessages = state.value ?? [];
|
||||
@@ -679,15 +653,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
developer.log('Deleting message $messageId', name: 'MessagesNotifier');
|
||||
talker.log('Deleting message $messageId');
|
||||
try {
|
||||
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
|
||||
await receiveMessageDeletion(messageId);
|
||||
} catch (err, stackTrace) {
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Error deleting message $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
error: err,
|
||||
|
||||
exception: err,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
showErrorAlert(err);
|
||||
@@ -709,10 +683,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
|
||||
developer.log(
|
||||
'Fetching message by id $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Fetching message by id $messageId');
|
||||
try {
|
||||
final localMessage =
|
||||
await (_database.select(_database.chatMessages)
|
||||
@@ -739,24 +710,18 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
|
||||
Future<int> jumpToMessage(String messageId) async {
|
||||
developer.log(
|
||||
'Starting jump to message $messageId',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Starting jump to message $messageId');
|
||||
if (_isJumping) {
|
||||
developer.log(
|
||||
'Jump already in progress, skipping',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Jump already in progress, skipping');
|
||||
return -1;
|
||||
}
|
||||
_isJumping = true;
|
||||
|
||||
try {
|
||||
developer.log('Fetching message $messageId', name: 'MessagesNotifier');
|
||||
talker.log('Fetching message $messageId');
|
||||
final message = await fetchMessageById(messageId);
|
||||
if (message == null) {
|
||||
developer.log('Message $messageId not found', name: 'MessagesNotifier');
|
||||
talker.log('Message $messageId not found');
|
||||
showSnackBar('messageNotFound'.tr());
|
||||
return -1;
|
||||
}
|
||||
@@ -767,16 +732,14 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Message $messageId already in current state at index $existingIndex, jumping directly',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
return existingIndex;
|
||||
}
|
||||
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Message $messageId not in current state, loading messages around it',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
// Count messages newer than this one
|
||||
@@ -794,10 +757,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
// Load messages around this position
|
||||
final offset =
|
||||
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
|
||||
developer.log(
|
||||
'Loading messages with offset $offset, take $_pageSize',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Loading messages with offset $offset, take $_pageSize');
|
||||
final loadedMessages = await _getCachedMessages(
|
||||
offset: offset,
|
||||
take: _pageSize,
|
||||
@@ -807,9 +767,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
final currentIds = currentMessages.map((m) => m.id).toSet();
|
||||
final newMessages =
|
||||
loadedMessages.where((m) => !currentIds.contains(m.id)).toList();
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
|
||||
if (newMessages.isNotEmpty) {
|
||||
@@ -824,19 +783,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
||||
}
|
||||
_sortMessages(uniqueMessages);
|
||||
state = AsyncValue.data(uniqueMessages);
|
||||
developer.log(
|
||||
talker.log(
|
||||
'Updated state with ${uniqueMessages.length} total messages',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
}
|
||||
|
||||
final finalIndex = (state.value ?? []).indexWhere(
|
||||
(m) => m.id == messageId,
|
||||
);
|
||||
developer.log(
|
||||
'Final index for message $messageId is $finalIndex',
|
||||
name: 'MessagesNotifier',
|
||||
);
|
||||
talker.log('Final index for message $messageId is $finalIndex');
|
||||
return finalIndex;
|
||||
} finally {
|
||||
_isJumping = false;
|
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237';
|
||||
String _$messagesNotifierHash() => r'b0cff44cea9f15a2684b602c48b32cd3d78875ab';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
@@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:island/pods/theme.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
part 'config.freezed.dart';
|
||||
part 'config.g.dart';
|
||||
@@ -24,9 +25,11 @@ const kAppDataSavingMode = 'app_data_saving_mode';
|
||||
const kAppSoundEffects = 'app_sound_effects';
|
||||
const kAppAprilFoolFeatures = 'app_april_fool_features';
|
||||
const kAppWindowSize = 'app_window_size';
|
||||
const kAppWindowOpacity = 'app_window_opacity';
|
||||
const kAppEnterToSend = 'app_enter_to_send';
|
||||
const kAppDefaultPoolId = 'app_default_pool_id';
|
||||
const kAppMessageDisplayStyle = 'app_message_display_style';
|
||||
const kAppThemeMode = 'app_theme_mode';
|
||||
const kFeaturedPostsCollapsedId =
|
||||
'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post
|
||||
|
||||
@@ -67,8 +70,10 @@ sealed class AppSettings with _$AppSettings {
|
||||
required String? customFonts,
|
||||
required int? appColorScheme, // The color stored via the int type
|
||||
required Size? windowSize, // The window size for desktop platforms
|
||||
required double windowOpacity, // The window opacity for desktop platforms
|
||||
required String? defaultPoolId,
|
||||
required String messageDisplayStyle,
|
||||
required String? themeMode,
|
||||
}) = _AppSettings;
|
||||
}
|
||||
|
||||
@@ -88,8 +93,10 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
customFonts: prefs.getString(kAppCustomFonts),
|
||||
appColorScheme: prefs.getInt(kAppColorSchemeStoreKey),
|
||||
windowSize: _getWindowSizeFromPrefs(prefs),
|
||||
windowOpacity: prefs.getDouble(kAppWindowOpacity) ?? 1.0,
|
||||
defaultPoolId: prefs.getString(kAppDefaultPoolId),
|
||||
messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble',
|
||||
themeMode: prefs.getString(kAppThemeMode) ?? 'system',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,6 +203,19 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
||||
prefs.setString(kAppMessageDisplayStyle, value);
|
||||
state = state.copyWith(messageDisplayStyle: value);
|
||||
}
|
||||
|
||||
void setWindowOpacity(double value) {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
prefs.setDouble(kAppWindowOpacity, value);
|
||||
state = state.copyWith(windowOpacity: value);
|
||||
Future(() => windowManager.setOpacity(value));
|
||||
}
|
||||
|
||||
void setThemeMode(String value) {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
prefs.setString(kAppThemeMode, value);
|
||||
state = state.copyWith(themeMode: value);
|
||||
}
|
||||
}
|
||||
|
||||
final updateInfoProvider =
|
||||
|
@@ -16,7 +16,8 @@ mixin _$AppSettings {
|
||||
|
||||
bool get autoTranslate; bool get dataSavingMode; bool get soundEffects; bool get aprilFoolFeatures; bool get enterToSend; bool get appBarTransparent; bool get showBackgroundImage; String? get customFonts; int? get appColorScheme;// The color stored via the int type
|
||||
Size? get windowSize;// The window size for desktop platforms
|
||||
String? get defaultPoolId; String get messageDisplayStyle;
|
||||
double get windowOpacity;// The window opacity for desktop platforms
|
||||
String? get defaultPoolId; String get messageDisplayStyle; String? get themeMode;
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -27,16 +28,16 @@ $AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppS
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,defaultPoolId,messageDisplayStyle);
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,windowOpacity,defaultPoolId,messageDisplayStyle,themeMode);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle)';
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, windowOpacity: $windowOpacity, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode)';
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +48,7 @@ abstract mixin class $AppSettingsCopyWith<$Res> {
|
||||
factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, double windowOpacity, String? defaultPoolId, String messageDisplayStyle, String? themeMode
|
||||
});
|
||||
|
||||
|
||||
@@ -64,7 +65,7 @@ class _$AppSettingsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,}) {
|
||||
@pragma('vm:prefer-inline') @override $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,}) {
|
||||
return _then(_self.copyWith(
|
||||
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
|
||||
as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
|
||||
@@ -76,9 +77,11 @@ as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundI
|
||||
as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable
|
||||
as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable
|
||||
as int?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable
|
||||
as Size?,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
|
||||
as Size?,windowOpacity: null == windowOpacity ? _self.windowOpacity : windowOpacity // ignore: cast_nullable_to_non_nullable
|
||||
as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -160,10 +163,10 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, double windowOpacity, String? defaultPoolId, String messageDisplayStyle, String? themeMode)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettings() when $default != null:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId,_that.messageDisplayStyle);case _:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.windowOpacity,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode);case _:
|
||||
return orElse();
|
||||
|
||||
}
|
||||
@@ -181,10 +184,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, double windowOpacity, String? defaultPoolId, String messageDisplayStyle, String? themeMode) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettings():
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId,_that.messageDisplayStyle);}
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.windowOpacity,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode);}
|
||||
}
|
||||
/// A variant of `when` that fallback to returning `null`
|
||||
///
|
||||
@@ -198,10 +201,10 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, double windowOpacity, String? defaultPoolId, String messageDisplayStyle, String? themeMode)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _AppSettings() when $default != null:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.defaultPoolId,_that.messageDisplayStyle);case _:
|
||||
return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_that.aprilFoolFeatures,_that.enterToSend,_that.appBarTransparent,_that.showBackgroundImage,_that.customFonts,_that.appColorScheme,_that.windowSize,_that.windowOpacity,_that.defaultPoolId,_that.messageDisplayStyle,_that.themeMode);case _:
|
||||
return null;
|
||||
|
||||
}
|
||||
@@ -213,7 +216,7 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
|
||||
|
||||
|
||||
class _AppSettings implements AppSettings {
|
||||
const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.windowSize, required this.defaultPoolId, required this.messageDisplayStyle});
|
||||
const _AppSettings({required this.autoTranslate, required this.dataSavingMode, required this.soundEffects, required this.aprilFoolFeatures, required this.enterToSend, required this.appBarTransparent, required this.showBackgroundImage, required this.customFonts, required this.appColorScheme, required this.windowSize, required this.windowOpacity, required this.defaultPoolId, required this.messageDisplayStyle, required this.themeMode});
|
||||
|
||||
|
||||
@override final bool autoTranslate;
|
||||
@@ -228,8 +231,11 @@ class _AppSettings implements AppSettings {
|
||||
// The color stored via the int type
|
||||
@override final Size? windowSize;
|
||||
// The window size for desktop platforms
|
||||
@override final double windowOpacity;
|
||||
// The window opacity for desktop platforms
|
||||
@override final String? defaultPoolId;
|
||||
@override final String messageDisplayStyle;
|
||||
@override final String? themeMode;
|
||||
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -241,16 +247,16 @@ _$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle));
|
||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _AppSettings&&(identical(other.autoTranslate, autoTranslate) || other.autoTranslate == autoTranslate)&&(identical(other.dataSavingMode, dataSavingMode) || other.dataSavingMode == dataSavingMode)&&(identical(other.soundEffects, soundEffects) || other.soundEffects == soundEffects)&&(identical(other.aprilFoolFeatures, aprilFoolFeatures) || other.aprilFoolFeatures == aprilFoolFeatures)&&(identical(other.enterToSend, enterToSend) || other.enterToSend == enterToSend)&&(identical(other.appBarTransparent, appBarTransparent) || other.appBarTransparent == appBarTransparent)&&(identical(other.showBackgroundImage, showBackgroundImage) || other.showBackgroundImage == showBackgroundImage)&&(identical(other.customFonts, customFonts) || other.customFonts == customFonts)&&(identical(other.appColorScheme, appColorScheme) || other.appColorScheme == appColorScheme)&&(identical(other.windowSize, windowSize) || other.windowSize == windowSize)&&(identical(other.windowOpacity, windowOpacity) || other.windowOpacity == windowOpacity)&&(identical(other.defaultPoolId, defaultPoolId) || other.defaultPoolId == defaultPoolId)&&(identical(other.messageDisplayStyle, messageDisplayStyle) || other.messageDisplayStyle == messageDisplayStyle)&&(identical(other.themeMode, themeMode) || other.themeMode == themeMode));
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,defaultPoolId,messageDisplayStyle);
|
||||
int get hashCode => Object.hash(runtimeType,autoTranslate,dataSavingMode,soundEffects,aprilFoolFeatures,enterToSend,appBarTransparent,showBackgroundImage,customFonts,appColorScheme,windowSize,windowOpacity,defaultPoolId,messageDisplayStyle,themeMode);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle)';
|
||||
return 'AppSettings(autoTranslate: $autoTranslate, dataSavingMode: $dataSavingMode, soundEffects: $soundEffects, aprilFoolFeatures: $aprilFoolFeatures, enterToSend: $enterToSend, appBarTransparent: $appBarTransparent, showBackgroundImage: $showBackgroundImage, customFonts: $customFonts, appColorScheme: $appColorScheme, windowSize: $windowSize, windowOpacity: $windowOpacity, defaultPoolId: $defaultPoolId, messageDisplayStyle: $messageDisplayStyle, themeMode: $themeMode)';
|
||||
}
|
||||
|
||||
|
||||
@@ -261,7 +267,7 @@ abstract mixin class _$AppSettingsCopyWith<$Res> implements $AppSettingsCopyWith
|
||||
factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, String? defaultPoolId, String messageDisplayStyle
|
||||
bool autoTranslate, bool dataSavingMode, bool soundEffects, bool aprilFoolFeatures, bool enterToSend, bool appBarTransparent, bool showBackgroundImage, String? customFonts, int? appColorScheme, Size? windowSize, double windowOpacity, String? defaultPoolId, String messageDisplayStyle, String? themeMode
|
||||
});
|
||||
|
||||
|
||||
@@ -278,7 +284,7 @@ class __$AppSettingsCopyWithImpl<$Res>
|
||||
|
||||
/// Create a copy of AppSettings
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,}) {
|
||||
@override @pragma('vm:prefer-inline') $Res call({Object? autoTranslate = null,Object? dataSavingMode = null,Object? soundEffects = null,Object? aprilFoolFeatures = null,Object? enterToSend = null,Object? appBarTransparent = null,Object? showBackgroundImage = null,Object? customFonts = freezed,Object? appColorScheme = freezed,Object? windowSize = freezed,Object? windowOpacity = null,Object? defaultPoolId = freezed,Object? messageDisplayStyle = null,Object? themeMode = freezed,}) {
|
||||
return _then(_AppSettings(
|
||||
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable
|
||||
as bool,dataSavingMode: null == dataSavingMode ? _self.dataSavingMode : dataSavingMode // ignore: cast_nullable_to_non_nullable
|
||||
@@ -290,9 +296,11 @@ as bool,showBackgroundImage: null == showBackgroundImage ? _self.showBackgroundI
|
||||
as bool,customFonts: freezed == customFonts ? _self.customFonts : customFonts // ignore: cast_nullable_to_non_nullable
|
||||
as String?,appColorScheme: freezed == appColorScheme ? _self.appColorScheme : appColorScheme // ignore: cast_nullable_to_non_nullable
|
||||
as int?,windowSize: freezed == windowSize ? _self.windowSize : windowSize // ignore: cast_nullable_to_non_nullable
|
||||
as Size?,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
|
||||
as Size?,windowOpacity: null == windowOpacity ? _self.windowOpacity : windowOpacity // ignore: cast_nullable_to_non_nullable
|
||||
as double,defaultPoolId: freezed == defaultPoolId ? _self.defaultPoolId : defaultPoolId // ignore: cast_nullable_to_non_nullable
|
||||
as String?,messageDisplayStyle: null == messageDisplayStyle ? _self.messageDisplayStyle : messageDisplayStyle // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
as String,themeMode: freezed == themeMode ? _self.themeMode : themeMode // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
|
||||
|
@@ -7,7 +7,7 @@ part of 'config.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$appSettingsNotifierHash() =>
|
||||
r'9f0979f18b107e61185391e7c39bd81ac4b8ca50';
|
||||
r'c3042f77067b5f36102f17277e174a756121ac74';
|
||||
|
||||
/// See also [AppSettingsNotifier].
|
||||
@ProviderFor(AppSettingsNotifier)
|
||||
|
@@ -2,10 +2,6 @@ import "dart:async";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
|
||||
final isSyncingProvider = StateProvider.autoDispose<bool>((ref) => false);
|
||||
|
||||
final flashingMessagesProvider = StateProvider<Set<String>>((ref) => {});
|
||||
|
||||
final appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
|
||||
final controller = StreamController<AppLifecycleState>();
|
||||
|
@@ -10,6 +10,8 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:island/models/auth.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
import 'config.dart';
|
||||
|
||||
@@ -75,7 +77,7 @@ final apiClientProvider = Provider<Dio>((ref) {
|
||||
),
|
||||
);
|
||||
|
||||
dio.interceptors.add(
|
||||
dio.interceptors.addAll([
|
||||
InterceptorsWrapper(
|
||||
onRequest: (
|
||||
RequestOptions options,
|
||||
@@ -97,7 +99,17 @@ final apiClientProvider = Provider<Dio>((ref) {
|
||||
return handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
TalkerDioLogger(
|
||||
talker: talker,
|
||||
settings: const TalkerDioLoggerSettings(
|
||||
printRequestHeaders: false,
|
||||
printResponseHeaders: false,
|
||||
printResponseMessage: false,
|
||||
printRequestData: false,
|
||||
printResponseData: false,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return dio;
|
||||
});
|
||||
|
@@ -100,10 +100,6 @@ Future<ThemeData> createAppTheme(
|
||||
opticalSize: 20,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: useM3 ? SnackBarBehavior.floating : SnackBarBehavior.fixed,
|
||||
width: 480,
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: hasAppBarTransparent ? 0 : null,
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -12,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
|
||||
final Ref _ref;
|
||||
@@ -21,7 +21,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
|
||||
Future<void> fetchUser() async {
|
||||
final token = _ref.watch(tokenProvider);
|
||||
if (token == null) {
|
||||
log('[UserInfo] No token found, not going to fetch...');
|
||||
talker.info('[UserInfo] No token found, not going to fetch...');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -75,11 +75,10 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
|
||||
}
|
||||
});
|
||||
}
|
||||
log(
|
||||
talker.error(
|
||||
"[UserInfo] Failed to fetch user info...",
|
||||
name: 'UserInfoNotifier',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
state = AsyncValue.data(null);
|
||||
}
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
@@ -8,6 +7,7 @@ import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
part 'websocket.freezed.dart';
|
||||
part 'websocket.g.dart';
|
||||
@@ -64,7 +64,7 @@ class WebSocketService {
|
||||
|
||||
final url = '$baseUrl/ws'.replaceFirst('http', 'ws');
|
||||
|
||||
log('[WebSocket] Trying connecting to $url');
|
||||
talker.info('[WebSocket] Trying connecting to $url');
|
||||
try {
|
||||
if (kIsWeb) {
|
||||
_channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token'));
|
||||
@@ -88,24 +88,24 @@ class WebSocketService {
|
||||
return;
|
||||
}
|
||||
_streamController.sink.add(packet);
|
||||
log(
|
||||
talker.info(
|
||||
"[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}",
|
||||
);
|
||||
if (packet.type == 'pong' && _heartbeatAt != null) {
|
||||
var now = DateTime.now();
|
||||
heartbeatDelay = now.difference(_heartbeatAt!);
|
||||
log(
|
||||
talker.info(
|
||||
"[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms",
|
||||
);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
log('[WebSocket] Connection closed, attempting to reconnect...');
|
||||
talker.info('[WebSocket] Connection closed, attempting to reconnect...');
|
||||
_scheduleReconnect();
|
||||
_statusStreamController.sink.add(WebSocketState.disconnected());
|
||||
},
|
||||
onError: (error) {
|
||||
log('[WebSocket] Error occurred: $error, attempting to reconnect...');
|
||||
talker.error('[WebSocket] Error occurred: $error, attempting to reconnect...');
|
||||
_scheduleReconnect();
|
||||
_statusStreamController.sink.add(
|
||||
WebSocketState.error(error.toString()),
|
||||
@@ -113,7 +113,7 @@ class WebSocketService {
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
log('[WebSocket] Failed to connect: $err');
|
||||
talker.error('[WebSocket] Failed to connect: $err');
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ class WebSocketService {
|
||||
|
||||
void _beatTheHeart() {
|
||||
_heartbeatAt = DateTime.now();
|
||||
log('[WebSocket] We\'re beating the heart! $_heartbeatAt');
|
||||
talker.info('[WebSocket] We\'re beating the heart! $_heartbeatAt');
|
||||
sendMessage(jsonEncode(WebSocketPacket(type: 'ping', data: null)));
|
||||
}
|
||||
|
||||
|
@@ -64,7 +64,9 @@ import 'package:island/screens/account/event_calendar.dart';
|
||||
import 'package:island/screens/discovery/realms.dart';
|
||||
import 'package:island/screens/reports/report_detail.dart';
|
||||
import 'package:island/screens/reports/report_list.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/post/post_shuffle.dart';
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
|
||||
// Shell route keys for nested navigation
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -96,6 +98,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
observers: [
|
||||
if (_supportsAnalytics)
|
||||
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
|
||||
TalkerRouteObserver(talker),
|
||||
],
|
||||
routes: [
|
||||
ShellRoute(
|
||||
@@ -132,6 +135,11 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
return CallScreen(roomId: id);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
name: 'logs',
|
||||
path: '/logs',
|
||||
builder: (context, state) => TalkerScreen(talker: talker),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'accountCalendar',
|
||||
path: '/account/:name/calendar',
|
||||
|
@@ -211,21 +211,11 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
|
||||
icon: Symbols.system_update,
|
||||
title: 'Check for updates',
|
||||
onTap: () async {
|
||||
// Fetch latest release and show the unified sheet
|
||||
final svc = UpdateService();
|
||||
// Reuse service fetch + compare to decide content
|
||||
showLoadingModal(context);
|
||||
final release = await svc.fetchLatestRelease();
|
||||
svc.checkForUpdates(context);
|
||||
if (!context.mounted) return;
|
||||
hideLoadingModal(context);
|
||||
if (release != null) {
|
||||
await svc.showUpdateSheet(context, release);
|
||||
} else {
|
||||
showInfoAlert(
|
||||
'Currently cannot get update from the GitHub.',
|
||||
'Unable to check for updates',
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
_buildListTile(
|
||||
|
@@ -66,7 +66,6 @@ class AccountScreen extends HookConsumerWidget {
|
||||
isNoBackground: isWide,
|
||||
appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0),
|
||||
body: SingleChildScrollView(
|
||||
padding: getTabbedPadding(context),
|
||||
child: Column(
|
||||
spacing: 4,
|
||||
children: <Widget>[
|
||||
|
@@ -10,7 +10,6 @@ import 'package:island/models/wallet.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/account/credits.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/widgets/account/leveling_progress.dart';
|
||||
import 'package:island/widgets/account/restore_purchase_sheet.dart';
|
||||
@@ -259,7 +258,7 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
SliverGap(getTabbedPadding(context, vertical: 20).vertical),
|
||||
SliverGap(20),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -270,7 +269,7 @@ class LevelingScreen extends HookConsumerWidget {
|
||||
final stellarSubscription = ref.watch(accountStellarSubscriptionProvider);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: getTabbedPadding(context, horizontal: 20, vertical: 20),
|
||||
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
@@ -9,10 +9,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/services/timezone.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
@@ -62,19 +62,13 @@ class UpdateProfileScreen extends HookConsumerWidget {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: ref.read(apiClientProvider),
|
||||
fileData: UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: result.name,
|
||||
mimetype: result.mimeType ?? 'image/jpeg',
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
|
@@ -168,7 +168,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
|
||||
scopes: [AppleIDAuthorizationScopes.email],
|
||||
webAuthenticationOptions: WebAuthenticationOptions(
|
||||
clientId: 'dev.solsynth.solarpass',
|
||||
redirectUri: Uri.parse('https://id.solian.app/auth/callback'),
|
||||
redirectUri: Uri.parse('https://solian.app/auth/callback'),
|
||||
),
|
||||
);
|
||||
|
||||
|
@@ -98,7 +98,7 @@ class _AccountBasicInfo extends StatelessWidget {
|
||||
onPressed: () {
|
||||
SharePlus.instance.share(
|
||||
ShareParams(
|
||||
uri: Uri.parse('https://id.solian.app/@${data.name}'),
|
||||
uri: Uri.parse('https://solian.app/@${data.name}'),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart' hide ConnectionState;
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/call_button.dart';
|
||||
import 'package:island/widgets/chat/call_overlay.dart';
|
||||
@@ -26,14 +25,14 @@ class CallScreen extends HookConsumerWidget {
|
||||
final callNotifier = ref.watch(callNotifierProvider.notifier);
|
||||
|
||||
useEffect(() {
|
||||
log('[Call] Joining the call...');
|
||||
talker.info('[Call] Joining the call...');
|
||||
callNotifier.joinRoom(roomId).catchError((_) {
|
||||
showConfirmAlert(
|
||||
'Seems there already has a call connected, do you want override it?',
|
||||
'Call already connected',
|
||||
).then((value) {
|
||||
if (value != true) return;
|
||||
log('[Call] Joining the call... with overrides');
|
||||
talker.info('[Call] Joining the call... with overrides');
|
||||
callNotifier.disconnect();
|
||||
callNotifier.dispose();
|
||||
callNotifier.joinRoom(roomId);
|
||||
|
@@ -11,12 +11,12 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat_summary.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/pods/chat/chat_summary.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/realm/realms.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/account/account_picker.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
@@ -25,7 +25,6 @@ import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/realm/realm_selection_dropdown.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:island/screens/tabs.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:relative_time/relative_time.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
@@ -178,6 +177,101 @@ Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
|
||||
.toList();
|
||||
}
|
||||
|
||||
class ChatListBodyWidget extends HookConsumerWidget {
|
||||
final bool isFloating;
|
||||
final TabController tabController;
|
||||
final ValueNotifier<int> selectedTab;
|
||||
|
||||
const ChatListBodyWidget({
|
||||
super.key,
|
||||
this.isFloating = false,
|
||||
required this.tabController,
|
||||
required this.selectedTab,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chats = ref.watch(chatroomsJoinedProvider);
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
|
||||
Widget bodyWidget = Column(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final summaryState = ref.watch(chatSummaryProvider);
|
||||
return summaryState.maybeWhen(
|
||||
loading:
|
||||
() => const LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: chats.when(
|
||||
data:
|
||||
(items) => RefreshIndicator(
|
||||
onRefresh:
|
||||
() => Future.sync(() {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: callState.isConnected ? 96 : 0,
|
||||
),
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 && item.type == 1) ||
|
||||
(selectedTab.value == 2 && item.type != 1),
|
||||
)
|
||||
.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filteredItems =
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 && item.type != 1),
|
||||
)
|
||||
.toList();
|
||||
final item = filteredItems[index];
|
||||
return ChatRoomListTile(
|
||||
room: item,
|
||||
isDirect: item.type == 1,
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': item.id},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return isFloating ? Card(child: bodyWidget) : bodyWidget;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatShellScreen extends HookConsumerWidget {
|
||||
final Widget child;
|
||||
const ChatShellScreen({super.key, required this.child});
|
||||
@@ -191,9 +285,23 @@ class ChatShellScreen extends HookConsumerWidget {
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(flex: 2, child: ChatListScreen(isAside: true)),
|
||||
const VerticalDivider(width: 1),
|
||||
Flexible(flex: 4, child: child),
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: ChatListScreen(
|
||||
isAside: true,
|
||||
isFloating: true,
|
||||
).padding(left: 16, vertical: 16),
|
||||
),
|
||||
const Gap(8),
|
||||
Flexible(
|
||||
flex: 4,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(8),
|
||||
),
|
||||
child: child,
|
||||
).padding(top: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -205,24 +313,23 @@ class ChatShellScreen extends HookConsumerWidget {
|
||||
|
||||
class ChatListScreen extends HookConsumerWidget {
|
||||
final bool isAside;
|
||||
const ChatListScreen({super.key, this.isAside = false});
|
||||
final bool isFloating;
|
||||
const ChatListScreen({
|
||||
super.key,
|
||||
this.isAside = false,
|
||||
this.isFloating = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isWide = isWideScreen(context);
|
||||
if (isWide && !isAside) {
|
||||
return const EmptyPageHolder();
|
||||
}
|
||||
|
||||
final chats = ref.watch(chatroomsJoinedProvider);
|
||||
final chatInvites = ref.watch(chatroomInvitesProvider);
|
||||
final tabController = useTabController(initialLength: 3);
|
||||
final selectedTab = useState(
|
||||
0,
|
||||
); // 0 for All, 1 for Direct Messages, 2 for Group Chats
|
||||
|
||||
final callState = ref.watch(callNotifierProvider);
|
||||
|
||||
useEffect(() {
|
||||
tabController.addListener(() {
|
||||
selectedTab.value = tabController.index;
|
||||
@@ -250,6 +357,76 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
if (isAside) {
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
dividerColor: Colors.transparent,
|
||||
controller: tabController,
|
||||
tabAlignment: TabAlignment.start,
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
const Tab(icon: Icon(Symbols.chat)),
|
||||
const Tab(icon: Icon(Symbols.person)),
|
||||
const Tab(icon: Icon(Symbols.group)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: IconButton(
|
||||
icon: Badge(
|
||||
label: Text(
|
||||
chatInvites.when(
|
||||
data: (invites) => invites.length.toString(),
|
||||
error: (_, _) => '0',
|
||||
loading: () => '0',
|
||||
),
|
||||
),
|
||||
isLabelVisible: chatInvites.when(
|
||||
data: (invites) => invites.isNotEmpty,
|
||||
error: (_, _) => false,
|
||||
loading: () => false,
|
||||
),
|
||||
child: const Icon(Symbols.email),
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const _ChatInvitesSheet(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ChatListBodyWidget(
|
||||
isFloating: false,
|
||||
tabController: tabController,
|
||||
selectedTab: selectedTab,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isWide && !isAside) {
|
||||
return const EmptyPageHolder();
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
extendBody: false, // Prevent conflicts with tabs navigation
|
||||
appBar: AppBar(
|
||||
@@ -352,82 +529,10 @@ class ChatListScreen extends HookConsumerWidget {
|
||||
},
|
||||
child: const Icon(Symbols.add),
|
||||
),
|
||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
||||
body: Column(
|
||||
children: [
|
||||
Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final summaryState = ref.watch(chatSummaryProvider);
|
||||
return summaryState.maybeWhen(
|
||||
loading:
|
||||
() => const LinearProgressIndicator(
|
||||
minHeight: 2,
|
||||
borderRadius: BorderRadius.zero,
|
||||
),
|
||||
orElse: () => const SizedBox.shrink(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: chats.when(
|
||||
data:
|
||||
(items) => RefreshIndicator(
|
||||
onRefresh:
|
||||
() => Future.sync(() {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
}),
|
||||
child: ListView.builder(
|
||||
padding: getTabbedPadding(
|
||||
context,
|
||||
bottom: callState.isConnected ? 96 : null,
|
||||
),
|
||||
itemCount:
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 && item.type != 1),
|
||||
)
|
||||
.length,
|
||||
itemBuilder: (context, index) {
|
||||
final filteredItems =
|
||||
items
|
||||
.where(
|
||||
(item) =>
|
||||
selectedTab.value == 0 ||
|
||||
(selectedTab.value == 1 &&
|
||||
item.type == 1) ||
|
||||
(selectedTab.value == 2 &&
|
||||
item.type != 1),
|
||||
)
|
||||
.toList();
|
||||
final item = filteredItems[index];
|
||||
return ChatRoomListTile(
|
||||
room: item,
|
||||
isDirect: item.type == 1,
|
||||
onTap: () {
|
||||
context.pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {'id': item.id},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error:
|
||||
(error, stack) => ResponseErrorWidget(
|
||||
error: error,
|
||||
onRetry: () {
|
||||
ref.invalidate(chatroomsJoinedProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: ChatListBodyWidget(
|
||||
isFloating: false,
|
||||
tabController: tabController,
|
||||
selectedTab: selectedTab,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -539,19 +644,13 @@ class EditChatScreen extends HookConsumerWidget {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: ref.read(apiClientProvider),
|
||||
fileData: UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: result.name,
|
||||
mimetype: result.mimeType ?? 'image/jpeg',
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
|
@@ -17,7 +17,7 @@ import "package:island/widgets/chat/message_item.dart";
|
||||
import "package:island/widgets/response.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
import "package:island/pods/messages_notifier.dart";
|
||||
import "package:island/pods/chat/messages_notifier.dart";
|
||||
|
||||
class PublicRoomPreview extends HookConsumerWidget {
|
||||
final String id;
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/messages_notifier.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/chat/message_list_tile.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
|
@@ -10,12 +10,11 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/publisher.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/realm/realms.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@@ -95,19 +94,13 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
fileData: UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: result.name,
|
||||
mimetype: result.mimeType ?? 'image/jpeg',
|
||||
client: ref.read(apiClientProvider),
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
@@ -191,7 +184,7 @@ class EditPublisherScreen extends HookConsumerWidget {
|
||||
leading: const PageBackButton(),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: getTabbedPadding(context, bottom: 16),
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
RealmSelectionDropdown(
|
||||
|
@@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/custom_app.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/developers/apps.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@@ -137,19 +137,13 @@ class EditAppScreen extends HookConsumerWidget {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: ref.read(apiClientProvider),
|
||||
fileData: UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: result.name,
|
||||
mimetype: result.mimeType ?? 'image/jpeg',
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
|
@@ -7,9 +7,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/bot.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@@ -123,19 +123,13 @@ class EditBotScreen extends HookConsumerWidget {
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
fileData: UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: result.name,
|
||||
mimetype: result.mimeType ?? 'image/jpeg',
|
||||
client: ref.read(apiClientProvider),
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
|
@@ -13,13 +13,13 @@ import 'package:island/pods/event_calendar.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/screens/notification.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/account/fortune_graph.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/widgets/check_in.dart';
|
||||
import 'package:island/widgets/post/post_featured.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/screens/tabs.dart';
|
||||
import 'package:island/widgets/post/compose_card.dart';
|
||||
import 'package:island/widgets/post/compose_dialog.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||
@@ -42,6 +42,7 @@ Widget notificationIndicatorWidget(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.notifications),
|
||||
title: Row(
|
||||
children: [
|
||||
@@ -51,7 +52,6 @@ Widget notificationIndicatorWidget(
|
||||
],
|
||||
),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
minTileHeight: 40,
|
||||
contentPadding: EdgeInsets.only(left: 16, right: 15),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('notifications');
|
||||
@@ -99,10 +99,6 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
final events = ref.watch(eventCalendarProvider(query.value));
|
||||
|
||||
final selectedDay = useState(now);
|
||||
// Function to handle day selection for synchronizing between widgets
|
||||
void onDaySelected(DateTime day) {
|
||||
selectedDay.value = day;
|
||||
}
|
||||
|
||||
final user = ref.watch(userInfoProvider);
|
||||
|
||||
@@ -110,251 +106,143 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 0,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(48),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: tabController,
|
||||
tabAlignment: TabAlignment.start,
|
||||
isScrollable: true,
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: Tooltip(
|
||||
message: 'explore'.tr(),
|
||||
child: Icon(
|
||||
Symbols.explore,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
icon: Tooltip(
|
||||
message: 'exploreFilterSubscriptions'.tr(),
|
||||
child: Icon(
|
||||
Symbols.subscriptions,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
icon: Tooltip(
|
||||
message: 'exploreFilterFriends'.tr(),
|
||||
child: Icon(
|
||||
Symbols.people,
|
||||
color:
|
||||
Theme.of(
|
||||
context,
|
||||
).appBarTheme.foregroundColor!,
|
||||
),
|
||||
),
|
||||
),
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
final filterBar = Card(
|
||||
margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TabBar(
|
||||
controller: tabController,
|
||||
tabAlignment: TabAlignment.start,
|
||||
isScrollable: true,
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: [
|
||||
Tab(
|
||||
icon: Tooltip(
|
||||
message: 'explore'.tr(),
|
||||
child: Icon(Symbols.explore),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
icon: Tooltip(
|
||||
message: 'exploreFilterSubscriptions'.tr(),
|
||||
child: Icon(Symbols.subscriptions),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
icon: Tooltip(
|
||||
message: 'exploreFilterFriends'.tr(),
|
||||
child: Icon(Symbols.people),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.pushNamed('articles');
|
||||
},
|
||||
icon: Icon(Symbols.auto_stories),
|
||||
tooltip: 'webArticlesStand'.tr(),
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.category),
|
||||
const Gap(12),
|
||||
Text('categories').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.pushNamed('articles');
|
||||
onTap: () {
|
||||
context.pushNamed('postCategories');
|
||||
},
|
||||
icon: Icon(
|
||||
Symbols.auto_stories,
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
),
|
||||
tooltip: 'webArticlesStand'.tr(),
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.category),
|
||||
const Gap(12),
|
||||
Text('categories').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postCategories');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.label),
|
||||
const Gap(12),
|
||||
Text('tags').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postTags');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.shuffle),
|
||||
const Gap(12),
|
||||
Text('postShuffle').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postShuffle');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.search),
|
||||
const Gap(12),
|
||||
Text('search').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postSearch');
|
||||
},
|
||||
),
|
||||
],
|
||||
icon: Icon(
|
||||
Symbols.action_key,
|
||||
color: Theme.of(context).appBarTheme.foregroundColor!,
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.label),
|
||||
const Gap(12),
|
||||
Text('tags').tr(),
|
||||
],
|
||||
),
|
||||
tooltip: 'search'.tr(),
|
||||
onTap: () {
|
||||
context.pushNamed('postTags');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.shuffle),
|
||||
const Gap(12),
|
||||
Text('postShuffle').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postShuffle');
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.search),
|
||||
const Gap(12),
|
||||
Text('search').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
context.pushNamed('postSearch');
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
.padding(horizontal: 8)
|
||||
.border(
|
||||
bottom: 1 / MediaQuery.of(context).devicePixelRatio,
|
||||
color: Theme.of(context).dividerColor,
|
||||
icon: Icon(Symbols.action_key),
|
||||
tooltip: 'search'.tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8),
|
||||
);
|
||||
|
||||
return AppScaffold(
|
||||
isNoBackground: false,
|
||||
floatingActionButton:
|
||||
isWide
|
||||
? null
|
||||
: InkWell(
|
||||
onLongPress: () async {
|
||||
final result = await PostComposeDialog.show(context);
|
||||
if (result != null) {
|
||||
activitiesNotifier.forceRefresh();
|
||||
}
|
||||
},
|
||||
child: FloatingActionButton(
|
||||
heroTag: Key("explore-page-fab"),
|
||||
onPressed: () async {
|
||||
final result = await PostComposeDialog.show(context);
|
||||
if (result != null) {
|
||||
activitiesNotifier.forceRefresh();
|
||||
}
|
||||
},
|
||||
child: const Icon(Symbols.edit),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
floatingActionButton: InkWell(
|
||||
onLongPress: () {
|
||||
context.pushNamed('postCompose', queryParameters: {'type': '1'}).then(
|
||||
(value) {
|
||||
if (value != null) {
|
||||
activitiesNotifier.forceRefresh();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
child: FloatingActionButton(
|
||||
heroTag: Key("explore-page-fab"),
|
||||
onPressed: () {
|
||||
context.pushNamed('postCompose').then((value) {
|
||||
if (value != null) {
|
||||
activitiesNotifier.forceRefresh();
|
||||
}
|
||||
});
|
||||
},
|
||||
child: const Icon(Symbols.edit),
|
||||
),
|
||||
),
|
||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
final bodyView = _buildActivityList(
|
||||
context,
|
||||
ref,
|
||||
currentFilter.value,
|
||||
);
|
||||
|
||||
if (isWide) {
|
||||
return Row(
|
||||
children: [
|
||||
Flexible(flex: 3, child: bodyView.padding(left: 8)),
|
||||
if (user.value != null)
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
CheckInWidget(
|
||||
margin: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 12,
|
||||
top: 16,
|
||||
),
|
||||
onChecked: () {
|
||||
ref.invalidate(
|
||||
eventCalendarProvider(query.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (notificationCount.value != null &&
|
||||
notificationCount.value! > 0)
|
||||
notificationIndicatorWidget(
|
||||
context,
|
||||
count: notificationCount.value ?? 0,
|
||||
margin: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 12,
|
||||
top: 8,
|
||||
),
|
||||
),
|
||||
PostFeaturedList().padding(
|
||||
left: 8,
|
||||
right: 12,
|
||||
top: 8,
|
||||
),
|
||||
FortuneGraphWidget(
|
||||
margin: EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 12,
|
||||
top: 8,
|
||||
),
|
||||
events: events,
|
||||
constrainWidth: true,
|
||||
onPointSelected: onDaySelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to\nthe Solar Network',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).bold(),
|
||||
const Gap(2),
|
||||
Text(
|
||||
'Login to explore more!',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 36, vertical: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return bodyView;
|
||||
},
|
||||
),
|
||||
body:
|
||||
isWide
|
||||
? _buildWideBody(
|
||||
context,
|
||||
ref,
|
||||
filterBar,
|
||||
user,
|
||||
notificationCount,
|
||||
query,
|
||||
events,
|
||||
selectedDay,
|
||||
currentFilter.value,
|
||||
)
|
||||
: _buildNarrowBody(context, ref, filterBar, currentFilter.value),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -369,23 +257,167 @@ class ExploreScreen extends HookConsumerWidget {
|
||||
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
return ExtendedRefreshIndicator(
|
||||
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
|
||||
child: PagingHelperView(
|
||||
provider: activityListNotifierProvider(filter),
|
||||
futureRefreshable: activityListNotifierProvider(filter).future,
|
||||
notifierRefreshable: activityListNotifierProvider(filter).notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => Center(
|
||||
child: _ActivityListView(
|
||||
data: data,
|
||||
widgetCount: widgetCount,
|
||||
endItemView: endItemView,
|
||||
activitiesNotifier: activitiesNotifier,
|
||||
contentOnly: isWide || filter != null,
|
||||
return PagingHelperSliverView(
|
||||
provider: activityListNotifierProvider(filter),
|
||||
futureRefreshable: activityListNotifierProvider(filter).future,
|
||||
notifierRefreshable: activityListNotifierProvider(filter).notifier,
|
||||
contentBuilder:
|
||||
(data, widgetCount, endItemView) => _ActivityListView(
|
||||
data: data,
|
||||
widgetCount: widgetCount,
|
||||
endItemView: endItemView,
|
||||
activitiesNotifier: activitiesNotifier,
|
||||
isWide: isWide,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWideBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Widget filterBar,
|
||||
AsyncValue<dynamic> user,
|
||||
AsyncValue<int?> notificationCount,
|
||||
ValueNotifier<EventCalendarQuery> query,
|
||||
AsyncValue<List<dynamic>> events,
|
||||
ValueNotifier<DateTime> selectedDay,
|
||||
String? currentFilter,
|
||||
) {
|
||||
final bodyView = _buildActivityList(context, ref, currentFilter);
|
||||
|
||||
final activitiesNotifier = ref.watch(
|
||||
activityListNotifierProvider(currentFilter).notifier,
|
||||
);
|
||||
|
||||
return Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 3,
|
||||
child: ExtendedRefreshIndicator(
|
||||
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(child: filterBar),
|
||||
const SliverGap(8),
|
||||
bodyView,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (user.value != null)
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
CheckInWidget(
|
||||
margin: EdgeInsets.only(top: 12),
|
||||
onChecked: () {
|
||||
ref.invalidate(eventCalendarProvider(query.value));
|
||||
},
|
||||
),
|
||||
if (notificationCount.value != null &&
|
||||
notificationCount.value! > 0)
|
||||
notificationIndicatorWidget(
|
||||
context,
|
||||
count: notificationCount.value ?? 0,
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
PostFeaturedList(),
|
||||
PostComposeCard(
|
||||
onSubmit: (post) {
|
||||
activitiesNotifier.forceRefresh();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Flexible(
|
||||
flex: 2,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Welcome to\nthe Solar Network',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).bold(),
|
||||
const Gap(2),
|
||||
Text(
|
||||
'Login to explore more!',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 36, vertical: 16),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12);
|
||||
}
|
||||
|
||||
Widget _buildNarrowBody(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Widget filterBar,
|
||||
String? currentFilter,
|
||||
) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
final notificationCount = ref.watch(
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
|
||||
final activitiesNotifier = ref.watch(
|
||||
activityListNotifierProvider(currentFilter).notifier,
|
||||
);
|
||||
|
||||
final bodyView = _buildActivityList(context, ref, currentFilter);
|
||||
|
||||
return Column(
|
||||
spacing: 8,
|
||||
children: [
|
||||
filterBar.padding(horizontal: 8, top: 8),
|
||||
Expanded(
|
||||
child: ExtendedRefreshIndicator(
|
||||
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (user.value != null)
|
||||
SliverToBoxAdapter(
|
||||
child: CheckInWidget(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: PostFeaturedList(),
|
||||
),
|
||||
),
|
||||
if (notificationCount.value != null &&
|
||||
notificationCount.value! > 0)
|
||||
SliverToBoxAdapter(
|
||||
child: notificationIndicatorWidget(
|
||||
context,
|
||||
count: notificationCount.value ?? 0,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
),
|
||||
),
|
||||
bodyView,
|
||||
],
|
||||
),
|
||||
).padding(horizontal: 8),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -464,7 +496,7 @@ class _DiscoveryActivityItem extends StatelessWidget {
|
||||
};
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -505,92 +537,60 @@ class _ActivityListView extends HookConsumerWidget {
|
||||
final CursorPagingData<SnActivity> data;
|
||||
final int widgetCount;
|
||||
final Widget endItemView;
|
||||
final bool contentOnly;
|
||||
final ActivityListNotifier activitiesNotifier;
|
||||
final bool isWide;
|
||||
|
||||
const _ActivityListView({
|
||||
required this.data,
|
||||
required this.widgetCount,
|
||||
required this.endItemView,
|
||||
required this.activitiesNotifier,
|
||||
this.contentOnly = false,
|
||||
required this.isWide,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(userInfoProvider);
|
||||
return SliverList.separated(
|
||||
itemCount: widgetCount,
|
||||
separatorBuilder: (_, _) => const Gap(8),
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
|
||||
final notificationCount = ref.watch(
|
||||
notificationUnreadCountNotifierProvider,
|
||||
);
|
||||
final item = data.items[index];
|
||||
if (item.data == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
Widget itemWidget;
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverGap(12),
|
||||
if (user.value != null && !contentOnly)
|
||||
SliverToBoxAdapter(
|
||||
child: CheckInWidget(
|
||||
margin: EdgeInsets.only(left: 8, right: 8, bottom: 4),
|
||||
),
|
||||
),
|
||||
if (!contentOnly)
|
||||
SliverToBoxAdapter(
|
||||
child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4),
|
||||
),
|
||||
if (!contentOnly && (notificationCount.value ?? 0) > 0)
|
||||
SliverToBoxAdapter(
|
||||
child: notificationIndicatorWidget(
|
||||
context,
|
||||
count: notificationCount.value ?? 0,
|
||||
margin: EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 4),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: widgetCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index == widgetCount - 1) {
|
||||
return endItemView;
|
||||
}
|
||||
|
||||
final item = data.items[index];
|
||||
if (item.data == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
Widget itemWidget;
|
||||
|
||||
switch (item.type) {
|
||||
case 'posts.new':
|
||||
case 'posts.new.replies':
|
||||
itemWidget = PostActionableItem(
|
||||
borderRadius: 8,
|
||||
item: SnPost.fromJson(item.data!),
|
||||
onRefresh: () {
|
||||
activitiesNotifier.forceRefresh();
|
||||
},
|
||||
onUpdate: (post) {
|
||||
activitiesNotifier.updateOne(
|
||||
index,
|
||||
item.copyWith(data: post.toJson()),
|
||||
);
|
||||
},
|
||||
switch (item.type) {
|
||||
case 'posts.new':
|
||||
case 'posts.new.replies':
|
||||
itemWidget = PostActionableItem(
|
||||
borderRadius: 8,
|
||||
item: SnPost.fromJson(item.data!),
|
||||
onRefresh: () {
|
||||
activitiesNotifier.forceRefresh();
|
||||
},
|
||||
onUpdate: (post) {
|
||||
activitiesNotifier.updateOne(
|
||||
index,
|
||||
item.copyWith(data: post.toJson()),
|
||||
);
|
||||
itemWidget = Card(
|
||||
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: itemWidget,
|
||||
);
|
||||
break;
|
||||
case 'discovery':
|
||||
itemWidget = _DiscoveryActivityItem(data: item.data!);
|
||||
break;
|
||||
default:
|
||||
itemWidget = const Placeholder();
|
||||
}
|
||||
},
|
||||
);
|
||||
itemWidget = Card(margin: EdgeInsets.zero, child: itemWidget);
|
||||
break;
|
||||
case 'discovery':
|
||||
itemWidget = _DiscoveryActivityItem(data: item.data!);
|
||||
break;
|
||||
default:
|
||||
itemWidget = const Placeholder();
|
||||
}
|
||||
|
||||
return itemWidget;
|
||||
},
|
||||
),
|
||||
SliverGap(getTabbedPadding(context).bottom),
|
||||
],
|
||||
return itemWidget;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,3 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -7,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/models/poll.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
@@ -92,10 +91,10 @@ class PollEditor extends Notifier<PollEditorState> {
|
||||
questions: poll.questions,
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
log('Failed to load poll $id: ${e.message}');
|
||||
talker.error('Failed to load poll $id: ${e.message}');
|
||||
// Keep state with id set; UI may handle error display.
|
||||
} catch (e) {
|
||||
log('Unexpected error loading poll $id: $e');
|
||||
talker.error('Unexpected error loading poll $id: $e');
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
|
@@ -12,6 +12,7 @@ import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/extended_refresh_indicator.dart';
|
||||
import 'package:island/widgets/post/post_award_sheet.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/post/post_award_history_sheet.dart';
|
||||
import 'package:island/widgets/post/post_pin_sheet.dart';
|
||||
@@ -273,7 +274,14 @@ class PostActionButtons extends HookConsumerWidget {
|
||||
|
||||
actions.add(
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: () {},
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => PostAwardSheet(post: post),
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
|
@@ -9,16 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/widgets/response.dart';
|
||||
import 'package:island/screens/tabs.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@@ -90,7 +88,6 @@ class RealmListScreen extends HookConsumerWidget {
|
||||
});
|
||||
},
|
||||
),
|
||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
||||
body: ExtendedRefreshIndicator(
|
||||
child: realms.when(
|
||||
data:
|
||||
@@ -100,7 +97,7 @@ class RealmListScreen extends HookConsumerWidget {
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.only(
|
||||
top: 8,
|
||||
bottom: getTabbedPadding(context).bottom + 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
itemCount: value.length,
|
||||
itemBuilder: (context, item) {
|
||||
@@ -207,19 +204,13 @@ class EditRealmScreen extends HookConsumerWidget {
|
||||
showLoadingModal(context);
|
||||
submitting.value = true;
|
||||
try {
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Access token is null');
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: ref.read(apiClientProvider),
|
||||
fileData: UniversalFile(
|
||||
data: result,
|
||||
type: UniversalFileType.image,
|
||||
),
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: result.name,
|
||||
mimetype: result.mimeType ?? 'image/jpeg',
|
||||
).future;
|
||||
if (cloudFile == null) {
|
||||
throw ArgumentError('Failed to upload the file...');
|
||||
|
@@ -26,6 +26,27 @@ import 'package:island/pods/file_pool.dart';
|
||||
class SettingsScreen extends HookConsumerWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
String _getLanguageDisplayName(Locale locale) {
|
||||
switch ('${locale.languageCode}-${locale.countryCode}') {
|
||||
case 'en-US':
|
||||
return 'English (US)';
|
||||
case 'es-ES':
|
||||
return 'Español (España)';
|
||||
case 'ja-JP':
|
||||
return '日本語 (日本)';
|
||||
case 'ko-KR':
|
||||
return '한국어 (대한민국)';
|
||||
case 'zh-CN':
|
||||
return '简体中文';
|
||||
case 'zh-OG':
|
||||
return '文言文 (华夏)';
|
||||
case 'zh-TW':
|
||||
return '繁體中文 (台灣)';
|
||||
default:
|
||||
return '${locale.languageCode}-${locale.countryCode}';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
@@ -64,9 +85,7 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
) {
|
||||
return DropdownMenuItem<Locale?>(
|
||||
value: ele,
|
||||
child: Text(
|
||||
'${ele.languageCode}-${ele.countryCode}',
|
||||
).fontSize(14),
|
||||
child: Text(_getLanguageDisplayName(ele)).fontSize(14),
|
||||
);
|
||||
}),
|
||||
DropdownMenuItem<Locale?>(
|
||||
@@ -92,6 +111,48 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Theme mode settings
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsThemeMode').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.dark_mode),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<String>(
|
||||
isExpanded: true,
|
||||
items: [
|
||||
DropdownMenuItem<String>(
|
||||
value: 'system',
|
||||
child: Text('settingsThemeModeSystem').tr().fontSize(14),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'light',
|
||||
child: Text('settingsThemeModeLight').tr().fontSize(14),
|
||||
),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'dark',
|
||||
child: Text('settingsThemeModeDark').tr().fontSize(14),
|
||||
),
|
||||
],
|
||||
value: settings.themeMode,
|
||||
onChanged: (String? value) {
|
||||
if (value != null) {
|
||||
ref
|
||||
.read(appSettingsNotifierProvider.notifier)
|
||||
.setThemeMode(value);
|
||||
showSnackBar('settingsApplied'.tr());
|
||||
}
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 5),
|
||||
height: 40,
|
||||
width: 140,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(height: 40),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Custom fonts settings
|
||||
ListTile(
|
||||
isThreeLine: true,
|
||||
@@ -579,8 +640,33 @@ class SettingsScreen extends HookConsumerWidget {
|
||||
];
|
||||
|
||||
// Desktop-specific settings
|
||||
// But nothing for now
|
||||
final desktopSettings = !isDesktop ? <Widget>[] : <Widget>[];
|
||||
final desktopSettings =
|
||||
!isDesktop
|
||||
? <Widget>[]
|
||||
: [
|
||||
ListTile(
|
||||
minLeadingWidth: 48,
|
||||
title: Text('settingsWindowOpacity').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
leading: const Icon(Symbols.opacity),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Slider(
|
||||
value: settings.windowOpacity,
|
||||
min: 0.1,
|
||||
max: 1.0,
|
||||
year2023: true,
|
||||
padding: EdgeInsets.only(right: 24),
|
||||
label: '${(settings.windowOpacity * 100).round()}%',
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(appSettingsNotifierProvider.notifier)
|
||||
.setWindowOpacity(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// Create a responsive layout based on screen width
|
||||
Widget buildSettingsList() {
|
||||
|
@@ -68,68 +68,76 @@ class TabsScreen extends HookConsumerWidget {
|
||||
final currentIndex = getCurrentIndex();
|
||||
|
||||
if (isWideScreen(context)) {
|
||||
return Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
destinations:
|
||||
destinations
|
||||
.map(
|
||||
(e) => NavigationRailDestination(
|
||||
icon: e.icon,
|
||||
label: Text(e.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
),
|
||||
const VerticalDivider(width: 1),
|
||||
Expanded(child: child ?? const SizedBox.shrink()),
|
||||
],
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
backgroundColor: Colors.transparent,
|
||||
destinations:
|
||||
destinations
|
||||
.map(
|
||||
(e) => NavigationRailDestination(
|
||||
icon: e.icon,
|
||||
label: Text(e.label),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: child ?? const SizedBox.shrink()),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: ConditionalBottomNav(
|
||||
child: ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withOpacity(0.8),
|
||||
),
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: NavigationBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
overlayColor: const WidgetStatePropertyAll(
|
||||
Colors.transparent,
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
height: 56,
|
||||
labelBehavior:
|
||||
NavigationDestinationLabelBehavior.alwaysHide,
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: destinations,
|
||||
),
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBody: true,
|
||||
body: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
bottomNavigationBar: ConditionalBottomNav(
|
||||
child: ClipRRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 1, sigmaY: 1),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
|
||||
),
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: NavigationBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
overlayColor: const WidgetStatePropertyAll(
|
||||
Colors.transparent,
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
height: 56,
|
||||
labelBehavior: NavigationDestinationLabelBehavior.alwaysHide,
|
||||
selectedIndex: currentIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
destinations: destinations,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -157,7 +165,6 @@ class TabbedFabLocation extends FloatingActionButtonLocation {
|
||||
scaffoldGeometry.floatingActionButtonSize.height -
|
||||
scaffoldGeometry.bottomSheetSize.height -
|
||||
safeAreaPadding.bottom -
|
||||
(isWideScreen(context) ? 32 : 80) +
|
||||
16;
|
||||
|
||||
return Offset(fabX, fabY);
|
||||
|
@@ -1,8 +1,7 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class TrayService {
|
||||
TrayService._();
|
||||
@@ -48,15 +47,10 @@ class TrayService {
|
||||
void handleAction(MenuItem item) {
|
||||
switch (item.key) {
|
||||
case 'show_window':
|
||||
() async {
|
||||
appWindow.show();
|
||||
appWindow.restore();
|
||||
await Future.delayed(const Duration(milliseconds: 32));
|
||||
appWindow.show();
|
||||
}();
|
||||
windowManager.show();
|
||||
break;
|
||||
case 'exit_app':
|
||||
appWindow.close();
|
||||
windowManager.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@@ -32,7 +32,6 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
|
||||
|
||||
Future<void> saveDraft(SnPost draft) async {
|
||||
final updatedDraft = draft.copyWith(updatedAt: DateTime.now());
|
||||
state = {...state, updatedDraft.id: updatedDraft};
|
||||
|
||||
try {
|
||||
final database = ref.read(databaseProvider);
|
||||
@@ -48,11 +47,11 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
|
||||
postData: Value(jsonEncode(updatedDraft.toJson())),
|
||||
),
|
||||
);
|
||||
// Update state after successful database operation, delayed to avoid widget building issues
|
||||
Future(() {
|
||||
state = {...state, updatedDraft.id: updatedDraft};
|
||||
});
|
||||
} catch (e) {
|
||||
// Revert state on error
|
||||
final newState = Map<String, SnPost>.from(state);
|
||||
newState.remove(updatedDraft.id);
|
||||
state = newState;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ part of 'compose_storage_db.dart';
|
||||
// **************************************************************************
|
||||
|
||||
String _$composeStorageNotifierHash() =>
|
||||
r'8baf17aa06b6f69641c20645ba8a3dfe01c97f8c';
|
||||
r'e78dbfd8dbaf728970985aaa2ac4df3575ddfcdf';
|
||||
|
||||
/// See also [ComposeStorageNotifier].
|
||||
@ProviderFor(ComposeStorageNotifier)
|
||||
|
@@ -1,17 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:croppy/croppy.dart';
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:native_exif/native_exif.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
enum FileUploadMode { generic, mediaSafe }
|
||||
|
||||
Future<XFile?> cropImage(
|
||||
BuildContext context, {
|
||||
@@ -19,10 +10,12 @@ Future<XFile?> cropImage(
|
||||
List<CropAspectRatio?>? allowedAspectRatios,
|
||||
bool replacePath = true,
|
||||
}) async {
|
||||
if (!context.mounted) return null;
|
||||
final imageBytes = await image.readAsBytes();
|
||||
if (!context.mounted) return null;
|
||||
final result = await showMaterialImageCropper(
|
||||
context,
|
||||
imageProvider:
|
||||
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)),
|
||||
imageProvider: MemoryImage(imageBytes),
|
||||
showLoadingIndicatorOnSubmit: true,
|
||||
allowedAspectRatios: allowedAspectRatios,
|
||||
);
|
||||
@@ -41,210 +34,3 @@ Future<XFile?> cropImage(
|
||||
mimeType: image.mimeType,
|
||||
);
|
||||
}
|
||||
|
||||
Completer<SnCloudFile?> putFileToCloud({
|
||||
required UniversalFile fileData,
|
||||
required String atk,
|
||||
required String baseUrl,
|
||||
String? poolId,
|
||||
String? filename,
|
||||
String? mimetype,
|
||||
FileUploadMode? mode,
|
||||
Function(double progress, Duration estimate)? onProgress,
|
||||
}) {
|
||||
final completer = Completer<SnCloudFile?>();
|
||||
|
||||
final effectiveMode =
|
||||
mode ??
|
||||
(fileData.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
: FileUploadMode.mediaSafe);
|
||||
|
||||
if (effectiveMode == FileUploadMode.mediaSafe &&
|
||||
fileData.isOnDevice &&
|
||||
fileData.type == UniversalFileType.image) {
|
||||
final data = fileData.data;
|
||||
if (data is XFile && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) {
|
||||
Exif.fromPath(data.path)
|
||||
.then((exif) async {
|
||||
final gpsAttributes = {
|
||||
'GPSLatitude': '',
|
||||
'GPSLatitudeRef': '',
|
||||
'GPSLongitude': '',
|
||||
'GPSLongitudeRef': '',
|
||||
'GPSAltitude': '',
|
||||
'GPSAltitudeRef': '',
|
||||
'GPSTimeStamp': '',
|
||||
'GPSProcessingMethod': '',
|
||||
'GPSDateStamp': '',
|
||||
};
|
||||
await exif.writeAttributes(gpsAttributes);
|
||||
})
|
||||
.then(
|
||||
(_) => _processUpload(
|
||||
fileData,
|
||||
atk,
|
||||
baseUrl,
|
||||
poolId,
|
||||
filename,
|
||||
mimetype,
|
||||
onProgress,
|
||||
completer,
|
||||
),
|
||||
)
|
||||
.catchError((e) {
|
||||
debugPrint('Error removing GPS EXIF data: $e');
|
||||
return _processUpload(
|
||||
fileData,
|
||||
atk,
|
||||
baseUrl,
|
||||
poolId,
|
||||
filename,
|
||||
mimetype,
|
||||
onProgress,
|
||||
completer,
|
||||
);
|
||||
});
|
||||
|
||||
return completer;
|
||||
}
|
||||
}
|
||||
|
||||
_processUpload(
|
||||
fileData,
|
||||
atk,
|
||||
baseUrl,
|
||||
poolId,
|
||||
filename,
|
||||
mimetype,
|
||||
onProgress,
|
||||
completer,
|
||||
);
|
||||
return completer;
|
||||
}
|
||||
|
||||
// Helper method to process the upload after any EXIF processing
|
||||
Completer<SnCloudFile?> _processUpload(
|
||||
UniversalFile fileData,
|
||||
String atk,
|
||||
String baseUrl,
|
||||
String? poolId,
|
||||
String? filename,
|
||||
String? mimetype,
|
||||
Function(double progress, Duration estimate)? onProgress,
|
||||
Completer<SnCloudFile?> completer,
|
||||
) {
|
||||
late XFile file;
|
||||
String actualFilename = filename ?? 'randomly_file';
|
||||
String actualMimetype = mimetype ?? '';
|
||||
Uint8List? byteData;
|
||||
|
||||
// Handle the data based on what's in the UniversalFile
|
||||
final data = fileData.data;
|
||||
|
||||
if (data is XFile) {
|
||||
file = data;
|
||||
actualFilename = filename ?? data.name;
|
||||
actualMimetype = mimetype ?? data.mimeType ?? '';
|
||||
} else if (data is List<int> || data is Uint8List) {
|
||||
byteData = data is List<int> ? Uint8List.fromList(data) : data;
|
||||
actualFilename = filename ?? 'uploaded_file';
|
||||
actualMimetype = mimetype ?? 'application/octet-stream';
|
||||
if (mimetype == null) {
|
||||
completer.completeError(
|
||||
ArgumentError('Mimetype is required when providing raw bytes.'),
|
||||
);
|
||||
return completer;
|
||||
}
|
||||
file = XFile.fromData(byteData!, mimeType: actualMimetype);
|
||||
} else if (data is SnCloudFile) {
|
||||
// If the file is already on the cloud, just return it
|
||||
completer.complete(data);
|
||||
return completer;
|
||||
} else {
|
||||
completer.completeError(
|
||||
ArgumentError(
|
||||
'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.',
|
||||
),
|
||||
);
|
||||
return completer;
|
||||
}
|
||||
|
||||
// Create Dio instance
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
headers: {
|
||||
'Authorization': 'AtField $atk',
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final uploader = FileUploader(dio);
|
||||
|
||||
// Get File object
|
||||
File fileObj;
|
||||
if (file.path.isNotEmpty) {
|
||||
fileObj = File(file.path);
|
||||
// Call progress start
|
||||
onProgress?.call(0.0, Duration.zero);
|
||||
uploader
|
||||
.uploadFile(
|
||||
file: fileObj,
|
||||
fileName: actualFilename,
|
||||
contentType: actualMimetype,
|
||||
poolId: poolId,
|
||||
)
|
||||
.then((result) {
|
||||
// Call progress end
|
||||
onProgress?.call(1.0, Duration.zero);
|
||||
completer.complete(result);
|
||||
})
|
||||
.catchError((e) {
|
||||
completer.completeError(e);
|
||||
throw e;
|
||||
});
|
||||
} else {
|
||||
// Write to temp file
|
||||
getTemporaryDirectory()
|
||||
.then((tempDir) {
|
||||
final tempFile = File('${tempDir.path}/temp_upload_$actualFilename');
|
||||
file
|
||||
.readAsBytes()
|
||||
.then((bytes) => tempFile.writeAsBytes(bytes))
|
||||
.then((_) {
|
||||
fileObj = tempFile;
|
||||
// Call progress start
|
||||
onProgress?.call(0.0, Duration.zero);
|
||||
uploader
|
||||
.uploadFile(
|
||||
file: fileObj,
|
||||
fileName: actualFilename,
|
||||
contentType: actualMimetype,
|
||||
poolId: poolId,
|
||||
)
|
||||
.then((result) {
|
||||
// Call progress end
|
||||
onProgress?.call(1.0, Duration.zero);
|
||||
completer.complete(result);
|
||||
})
|
||||
.catchError((e) {
|
||||
completer.completeError(e);
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.catchError((e) {
|
||||
completer.completeError(e);
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.catchError((e) {
|
||||
completer.completeError(e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
return completer;
|
||||
}
|
||||
|
@@ -1,20 +1,22 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:native_exif/native_exif.dart';
|
||||
|
||||
class FileUploader {
|
||||
final Dio _dio;
|
||||
final Dio _client;
|
||||
|
||||
FileUploader(this._dio);
|
||||
FileUploader(this._client);
|
||||
|
||||
/// Calculates the MD5 hash of a file.
|
||||
Future<String> _calculateFileHash(File file) async {
|
||||
Future<String> _calculateFileHash(XFile file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
final digest = md5.convert(bytes);
|
||||
return digest.toString();
|
||||
@@ -22,7 +24,7 @@ class FileUploader {
|
||||
|
||||
/// Creates an upload task for the given file.
|
||||
Future<Map<String, dynamic>> createUploadTask({
|
||||
required File file,
|
||||
required XFile file,
|
||||
required String fileName,
|
||||
required String contentType,
|
||||
String? poolId,
|
||||
@@ -34,7 +36,7 @@ class FileUploader {
|
||||
final hash = await _calculateFileHash(file);
|
||||
final fileSize = await file.length();
|
||||
|
||||
final response = await _dio.post(
|
||||
final response = await _client.post(
|
||||
'/drive/files/upload/create',
|
||||
data: {
|
||||
'hash': hash,
|
||||
@@ -65,7 +67,7 @@ class FileUploader {
|
||||
),
|
||||
});
|
||||
|
||||
await _dio.post(
|
||||
await _client.post(
|
||||
'/drive/files/upload/chunk/$taskId/$chunkIndex',
|
||||
data: formData,
|
||||
);
|
||||
@@ -73,14 +75,14 @@ class FileUploader {
|
||||
|
||||
/// Completes the upload and returns the CloudFile object.
|
||||
Future<SnCloudFile> completeUpload(String taskId) async {
|
||||
final response = await _dio.post('/drive/files/upload/complete/$taskId');
|
||||
final response = await _client.post('/drive/files/upload/complete/$taskId');
|
||||
|
||||
return SnCloudFile.fromJson(response.data);
|
||||
}
|
||||
|
||||
/// Uploads a file in chunks using the multi-part API.
|
||||
Future<SnCloudFile> uploadFile({
|
||||
required File file,
|
||||
required XFile file,
|
||||
required String fileName,
|
||||
required String contentType,
|
||||
String? poolId,
|
||||
@@ -146,8 +148,155 @@ class FileUploader {
|
||||
// Step 3: Complete upload
|
||||
return await completeUpload(taskId);
|
||||
}
|
||||
|
||||
static Completer<SnCloudFile?> createCloudFile({
|
||||
required UniversalFile fileData,
|
||||
required Dio client,
|
||||
String? poolId,
|
||||
FileUploadMode? mode,
|
||||
Function(double progress, Duration estimate)? onProgress,
|
||||
}) {
|
||||
final completer = Completer<SnCloudFile?>();
|
||||
|
||||
final effectiveMode =
|
||||
mode ??
|
||||
(fileData.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
: FileUploadMode.mediaSafe);
|
||||
|
||||
if (effectiveMode == FileUploadMode.mediaSafe &&
|
||||
fileData.isOnDevice &&
|
||||
fileData.type == UniversalFileType.image) {
|
||||
final data = fileData.data;
|
||||
if (data is XFile &&
|
||||
!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.iOS ||
|
||||
defaultTargetPlatform == TargetPlatform.android)) {
|
||||
Exif.fromPath(data.path)
|
||||
.then((exif) async {
|
||||
final gpsAttributes = {
|
||||
'GPSLatitude': '',
|
||||
'GPSLatitudeRef': '',
|
||||
'GPSLongitude': '',
|
||||
'GPSLongitudeRef': '',
|
||||
'GPSAltitude': '',
|
||||
'GPSAltitudeRef': '',
|
||||
'GPSTimeStamp': '',
|
||||
'GPSProcessingMethod': '',
|
||||
'GPSDateStamp': '',
|
||||
};
|
||||
await exif.writeAttributes(gpsAttributes);
|
||||
})
|
||||
.then(
|
||||
(_) => _processUpload(
|
||||
fileData,
|
||||
client,
|
||||
poolId,
|
||||
onProgress,
|
||||
completer,
|
||||
),
|
||||
)
|
||||
.catchError((e) {
|
||||
debugPrint('Error removing GPS EXIF data: $e');
|
||||
return _processUpload(
|
||||
fileData,
|
||||
client,
|
||||
poolId,
|
||||
onProgress,
|
||||
completer,
|
||||
);
|
||||
});
|
||||
|
||||
return completer;
|
||||
}
|
||||
}
|
||||
|
||||
_processUpload(fileData, client, poolId, onProgress, completer);
|
||||
return completer;
|
||||
}
|
||||
|
||||
// Helper method to process the upload
|
||||
static Completer<SnCloudFile?> _processUpload(
|
||||
UniversalFile fileData,
|
||||
Dio client,
|
||||
String? poolId,
|
||||
Function(double progress, Duration estimate)? onProgress,
|
||||
Completer<SnCloudFile?> completer,
|
||||
) {
|
||||
String actualMimetype = getMimeType(fileData);
|
||||
late XFile file;
|
||||
String actualFilename = fileData.displayName ?? 'randomly_file';
|
||||
Uint8List? byteData;
|
||||
|
||||
// Handle the data based on what's in the UniversalFile
|
||||
final data = fileData.data;
|
||||
|
||||
if (data is XFile) {
|
||||
file = data;
|
||||
actualFilename = fileData.displayName ?? data.name;
|
||||
} else if (data is List<int> || data is Uint8List) {
|
||||
byteData = data is List<int> ? Uint8List.fromList(data) : data;
|
||||
actualFilename = fileData.displayName ?? 'uploaded_file';
|
||||
file = XFile.fromData(byteData!, mimeType: actualMimetype);
|
||||
} else if (data is SnCloudFile) {
|
||||
// If the file is already on the cloud, just return it
|
||||
completer.complete(data);
|
||||
return completer;
|
||||
} else {
|
||||
completer.completeError(
|
||||
ArgumentError(
|
||||
'Invalid fileData type. Expected data to be XFile, List<int>, Uint8List, or SnCloudFile.',
|
||||
),
|
||||
);
|
||||
return completer;
|
||||
}
|
||||
|
||||
final uploader = FileUploader(client);
|
||||
|
||||
// Call progress start
|
||||
onProgress?.call(0.0, Duration.zero);
|
||||
uploader
|
||||
.uploadFile(
|
||||
file: file,
|
||||
fileName: actualFilename,
|
||||
contentType: actualMimetype,
|
||||
poolId: poolId,
|
||||
)
|
||||
.then((result) {
|
||||
// Call progress end
|
||||
onProgress?.call(1.0, Duration.zero);
|
||||
completer.complete(result);
|
||||
})
|
||||
.catchError((e) {
|
||||
completer.completeError(e);
|
||||
throw e;
|
||||
});
|
||||
|
||||
return completer;
|
||||
}
|
||||
|
||||
/// Gets the MIME type of a UniversalFile.
|
||||
static String getMimeType(UniversalFile file) {
|
||||
final data = file.data;
|
||||
if (data is XFile) {
|
||||
final mime = data.mimeType;
|
||||
if (mime != null && mime.isNotEmpty) return mime;
|
||||
final filename = file.displayName ?? data.name;
|
||||
final detected = lookupMimeType(filename);
|
||||
if (detected != null) return detected;
|
||||
throw Exception('Cannot detect mime type for file: $filename');
|
||||
} else if (data is List<int> || data is Uint8List) {
|
||||
return 'application/octet-stream';
|
||||
} else if (data is SnCloudFile) {
|
||||
return data.mimeType ?? 'application/octet-stream';
|
||||
} else {
|
||||
throw ArgumentError('Invalid file data type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum FileUploadMode { generic, mediaSafe }
|
||||
|
||||
// Riverpod provider for the FileUploader service
|
||||
final fileUploaderProvider = Provider<FileUploader>((ref) {
|
||||
final dio = ref.watch(apiClientProvider);
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -13,6 +11,7 @@ import 'package:island/main.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/app_notification.dart';
|
||||
import 'package:top_snackbar_flutter/top_snack_bar.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@@ -96,7 +95,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
final notification = SnNotification.fromJson(pkt.data!);
|
||||
if (_appLifecycleState == AppLifecycleState.resumed) {
|
||||
// App is focused, show in-app notification
|
||||
log(
|
||||
talker.info(
|
||||
'[Notification] Showing in-app notification: ${notification.title}',
|
||||
);
|
||||
showTopSnackBar(
|
||||
@@ -142,7 +141,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
} else {
|
||||
// App is in background, show system notification (only on supported platforms)
|
||||
if (!kIsWeb && !Platform.isIOS) {
|
||||
log(
|
||||
talker.info(
|
||||
'[Notification] Showing system notification: ${notification.title}',
|
||||
);
|
||||
|
||||
@@ -167,7 +166,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
payload: notification.meta['action_uri'] as String?,
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
talker.info(
|
||||
'[Notification] Skipping system notification for unsupported platform: ${notification.title}',
|
||||
);
|
||||
}
|
||||
@@ -206,7 +205,7 @@ Future<void> subscribePushNotification(
|
||||
_putTokenToRemote(apiClient, fcmToken, 1);
|
||||
})
|
||||
.onError((err) {
|
||||
log("Failed to get firebase cloud messaging push token: $err");
|
||||
talker.error("Failed to get firebase cloud messaging push token: $err");
|
||||
});
|
||||
|
||||
if (deviceToken != null) {
|
||||
|
@@ -1,27 +1,27 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:island/main.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/models/account.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/app_notification.dart';
|
||||
import 'package:top_snackbar_flutter/top_snack_bar.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:windows_notification/windows_notification.dart'
|
||||
as windows_notification;
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:windows_notification/windows_notification.dart' as winty;
|
||||
import 'package:windows_notification/notification_message.dart';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
// Windows notification instance
|
||||
windows_notification.WindowsNotification? windowsNotification;
|
||||
winty.WindowsNotification? windowsNotification;
|
||||
|
||||
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
|
||||
|
||||
@@ -31,8 +31,8 @@ void _onAppLifecycleChanged(AppLifecycleState state) {
|
||||
|
||||
Future<void> initializeLocalNotifications() async {
|
||||
// Initialize Windows notification for Windows platform
|
||||
windowsNotification = windows_notification.WindowsNotification(
|
||||
applicationId: 'dev.solsynth.solian',
|
||||
windowsNotification = winty.WindowsNotification(
|
||||
applicationId: "Solian",
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addObserver(
|
||||
@@ -61,7 +61,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
final notification = SnNotification.fromJson(pkt.data!);
|
||||
if (_appLifecycleState == AppLifecycleState.resumed) {
|
||||
// App is focused, show in-app notification
|
||||
log(
|
||||
talker.info(
|
||||
'[Notification] Showing in-app notification: ${notification.title}',
|
||||
);
|
||||
showTopSnackBar(
|
||||
@@ -99,17 +99,51 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
|
||||
);
|
||||
} else {
|
||||
// App is in background, show Windows system notification
|
||||
log(
|
||||
talker.info(
|
||||
'[Notification] Showing Windows system notification: ${notification.title}',
|
||||
);
|
||||
|
||||
if (windowsNotification != null) {
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
final pfp = notification.meta['pfp'] as String?;
|
||||
final img = notification.meta['images'] as List<dynamic>?;
|
||||
final actionUrl = notification.meta['action_uri'] as String?;
|
||||
|
||||
// Download and cache images
|
||||
String? imagePath;
|
||||
String? largeImagePath;
|
||||
|
||||
if (pfp != null) {
|
||||
try {
|
||||
final file = await DefaultCacheManager().getSingleFile(
|
||||
'$serverUrl/drive/files/$pfp',
|
||||
);
|
||||
imagePath = file.path;
|
||||
} catch (e) {
|
||||
talker.error('Failed to download pfp image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (img != null && img.isNotEmpty) {
|
||||
try {
|
||||
final file = await DefaultCacheManager().getSingleFile(
|
||||
'$serverUrl/drive/files/${img.firstOrNull}',
|
||||
);
|
||||
largeImagePath = file.path;
|
||||
} catch (e) {
|
||||
talker.error('Failed to download large image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Use Windows notification for Windows platform
|
||||
final notificationMessage = NotificationMessage.fromPluginTemplate(
|
||||
DateTime.now().millisecondsSinceEpoch.toString(), // unique id
|
||||
notification.id, // unique id
|
||||
notification.title,
|
||||
notification.content,
|
||||
launch: notification.meta['action_uri'] as String?,
|
||||
[notification.subtitle, notification.content].where((e) => e.isNotEmpty).join('\n'),
|
||||
group: notification.topic,
|
||||
image: imagePath,
|
||||
largeImage: largeImagePath,
|
||||
launch: actionUrl != null ? 'solian://$actionUrl' : null,
|
||||
);
|
||||
await windowsNotification!.showNotificationPluginTemplate(
|
||||
notificationMessage,
|
||||
@@ -150,7 +184,7 @@ Future<void> subscribePushNotification(
|
||||
_putTokenToRemote(apiClient, fcmToken, 1);
|
||||
})
|
||||
.onError((err) {
|
||||
log("Failed to get firebase cloud messaging push token: $err");
|
||||
talker.error("Failed to get firebase cloud messaging push token: $err");
|
||||
});
|
||||
|
||||
if (deviceToken != null) {
|
||||
|
@@ -15,32 +15,3 @@ bool isWiderScreen(BuildContext context) {
|
||||
bool isWidestScreen(BuildContext context) {
|
||||
return MediaQuery.of(context).size.width > kWidescreenWidth;
|
||||
}
|
||||
|
||||
EdgeInsets getTabbedPadding(
|
||||
BuildContext context, {
|
||||
double? horizontal,
|
||||
double? vertical,
|
||||
double? left,
|
||||
double? right,
|
||||
double? top,
|
||||
double? bottom,
|
||||
}) {
|
||||
if (isWideScreen(context)) {
|
||||
return EdgeInsets.only(
|
||||
left: left ?? horizontal ?? 0,
|
||||
right: right ?? horizontal ?? 0,
|
||||
top: top ?? vertical ?? 0,
|
||||
bottom: bottom ?? vertical ?? 0,
|
||||
);
|
||||
}
|
||||
final effectiveBottom = bottom ?? vertical;
|
||||
return EdgeInsets.only(
|
||||
left: left ?? horizontal ?? 0,
|
||||
right: right ?? horizontal ?? 0,
|
||||
top: top ?? vertical ?? 0,
|
||||
bottom:
|
||||
effectiveBottom != null
|
||||
? effectiveBottom + MediaQuery.of(context).padding.bottom + 56
|
||||
: MediaQuery.of(context).padding.bottom + 56,
|
||||
);
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_app_update/azhon_app_update.dart';
|
||||
@@ -18,6 +18,7 @@ import 'package:collection/collection.dart'; // Added for firstWhereOrNull
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
/// Data model for a GitHub release we care about
|
||||
class GithubReleaseInfo {
|
||||
@@ -120,40 +121,40 @@ class UpdateService {
|
||||
/// Checks GitHub for the latest release and compares against the current app version.
|
||||
/// If update is available, shows a bottom sheet with changelog and an action to open release page.
|
||||
Future<void> checkForUpdates(BuildContext context) async {
|
||||
log('[Update] Checking for updates...');
|
||||
talker.info('[Update] Checking for updates...');
|
||||
try {
|
||||
final release = await fetchLatestRelease();
|
||||
if (release == null) {
|
||||
log('[Update] No latest release found or could not fetch.');
|
||||
talker.info('[Update] No latest release found or could not fetch.');
|
||||
return;
|
||||
}
|
||||
log('[Update] Fetched latest release: ${release.tagName}');
|
||||
talker.info('[Update] Fetched latest release: ${release.tagName}');
|
||||
|
||||
final info = await PackageInfo.fromPlatform();
|
||||
final localVersionStr = '${info.version}+${info.buildNumber}';
|
||||
log('[Update] Local app version: $localVersionStr');
|
||||
talker.info('[Update] Local app version: $localVersionStr');
|
||||
|
||||
final latest = _ParsedVersion.tryParse(release.tagName);
|
||||
final local = _ParsedVersion.tryParse(localVersionStr);
|
||||
|
||||
if (latest == null || local == null) {
|
||||
log(
|
||||
talker.info(
|
||||
'[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr',
|
||||
);
|
||||
// If parsing fails, do nothing silently
|
||||
return;
|
||||
}
|
||||
log('[Update] Parsed versions. Latest: $latest, Local: $local');
|
||||
talker.info('[Update] Parsed versions. Latest: $latest, Local: $local');
|
||||
|
||||
final needsUpdate = latest.compareTo(local) > 0;
|
||||
if (!needsUpdate) {
|
||||
log('[Update] App is up to date. No update needed.');
|
||||
talker.info('[Update] App is up to date. No update needed.');
|
||||
return;
|
||||
}
|
||||
log('[Update] Update available! Latest: $latest, Local: $local');
|
||||
talker.info('[Update] Update available! Latest: $latest, Local: $local');
|
||||
|
||||
if (!context.mounted) {
|
||||
log('[Update] Context not mounted, cannot show update sheet.');
|
||||
talker.info('[Update] Context not mounted, cannot show update sheet.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -162,10 +163,10 @@ class UpdateService {
|
||||
|
||||
if (context.mounted) {
|
||||
await showUpdateSheet(context, release);
|
||||
log('[Update] Update sheet shown.');
|
||||
talker.info('[Update] Update sheet shown.');
|
||||
}
|
||||
} catch (e) {
|
||||
log('[Update] Error checking for updates: $e');
|
||||
talker.error('[Update] Error checking for updates: $e');
|
||||
// Ignore errors (network, api, etc.)
|
||||
return;
|
||||
}
|
||||
@@ -233,39 +234,240 @@ class UpdateService {
|
||||
return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip';
|
||||
}
|
||||
|
||||
/// Downloads the Windows installer ZIP file
|
||||
Future<String?> _downloadWindowsInstaller(String url) async {
|
||||
/// Performs automatic Windows update: download, extract, and install
|
||||
Future<void> performAutomaticWindowsUpdate(
|
||||
BuildContext context,
|
||||
String url,
|
||||
) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => _WindowsUpdateDialog(
|
||||
updateUrl: url,
|
||||
onComplete: () {
|
||||
// Close the update sheet
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch the latest release info from GitHub.
|
||||
/// Public so other screens (e.g., About) can manually trigger update checks.
|
||||
Future<GithubReleaseInfo?> fetchLatestRelease() async {
|
||||
final apiEndpoint =
|
||||
useProxy
|
||||
? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
|
||||
: _releasesLatestApi;
|
||||
|
||||
talker.info(
|
||||
'[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)',
|
||||
);
|
||||
final resp = await _dio.get(apiEndpoint);
|
||||
if (resp.statusCode != 200) {
|
||||
talker.error(
|
||||
'[Update] Failed to fetch latest release. Status code: ${resp.statusCode}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final data = resp.data as Map<String, dynamic>;
|
||||
talker.info('[Update] Successfully fetched release data.');
|
||||
|
||||
final tagName = (data['tag_name'] ?? '').toString();
|
||||
final name = (data['name'] ?? tagName).toString();
|
||||
final body = (data['body'] ?? '').toString();
|
||||
final htmlUrl = (data['html_url'] ?? '').toString();
|
||||
final createdAtStr = (data['created_at'] ?? '').toString();
|
||||
final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
|
||||
final assetsData =
|
||||
(data['assets'] as List<dynamic>?)
|
||||
?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
if (tagName.isEmpty || htmlUrl.isEmpty) {
|
||||
talker.error(
|
||||
'[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
talker.info('[Update] Returning GithubReleaseInfo for tag: $tagName');
|
||||
return GithubReleaseInfo(
|
||||
tagName: tagName,
|
||||
name: name,
|
||||
body: body,
|
||||
htmlUrl: htmlUrl,
|
||||
createdAt: createdAt,
|
||||
assets: assetsData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WindowsUpdateDialog extends StatefulWidget {
|
||||
const _WindowsUpdateDialog({
|
||||
required this.updateUrl,
|
||||
required this.onComplete,
|
||||
});
|
||||
|
||||
final String updateUrl;
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
State<_WindowsUpdateDialog> createState() => _WindowsUpdateDialogState();
|
||||
}
|
||||
|
||||
class _WindowsUpdateDialogState extends State<_WindowsUpdateDialog> {
|
||||
final ValueNotifier<double?> progressNotifier = ValueNotifier<double?>(null);
|
||||
final ValueNotifier<String> messageNotifier = ValueNotifier<String>('Downloading installer...');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startUpdate();
|
||||
}
|
||||
|
||||
Future<void> _startUpdate() async {
|
||||
try {
|
||||
log('[Update] Starting Windows installer download from: $url');
|
||||
// Step 1: Download
|
||||
final zipPath = await _downloadWindowsInstaller(
|
||||
widget.updateUrl,
|
||||
onProgress: (received, total) {
|
||||
if (total == -1) {
|
||||
progressNotifier.value = null;
|
||||
} else {
|
||||
progressNotifier.value = received / total;
|
||||
}
|
||||
},
|
||||
);
|
||||
if (zipPath == null) {
|
||||
_showError('Failed to download installer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Extract
|
||||
messageNotifier.value = 'Extracting installer...';
|
||||
progressNotifier.value = null; // Indeterminate for extraction
|
||||
|
||||
final extractDir = await _extractWindowsInstaller(zipPath);
|
||||
if (extractDir == null) {
|
||||
_showError('Failed to extract installer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Run installer
|
||||
messageNotifier.value = 'Running installer...';
|
||||
|
||||
final success = await _runWindowsInstaller(extractDir);
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
messageNotifier.value = 'Update Complete';
|
||||
progressNotifier.value = 1.0;
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
widget.onComplete();
|
||||
}
|
||||
} else {
|
||||
_showError('Failed to run installer');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await File(zipPath).delete();
|
||||
await Directory(extractDir).delete(recursive: true);
|
||||
} catch (e) {
|
||||
talker.error('[Update] Error cleaning up temporary files: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
_showError('Update failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Update Failed'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Installing Update'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ValueListenableBuilder<double?>(
|
||||
valueListenable: progressNotifier,
|
||||
builder: (context, progress, child) {
|
||||
return LinearProgressIndicator(value: progress);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ValueListenableBuilder<String>(
|
||||
valueListenable: messageNotifier,
|
||||
builder: (context, message, child) {
|
||||
return Text(message);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Downloads the Windows installer ZIP file
|
||||
Future<String?> _downloadWindowsInstaller(
|
||||
String url, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
try {
|
||||
talker.info('[Update] Starting Windows installer download from: $url');
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final fileName =
|
||||
'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip';
|
||||
final filePath = path.join(tempDir.path, fileName);
|
||||
|
||||
final response = await _dio.download(
|
||||
final response = await Dio().download(
|
||||
url,
|
||||
filePath,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total != -1) {
|
||||
log(
|
||||
talker.info(
|
||||
'[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%',
|
||||
);
|
||||
}
|
||||
onProgress?.call(received, total);
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
log('[Update] Windows installer downloaded successfully to: $filePath');
|
||||
talker.info('[Update] Windows installer downloaded successfully to: $filePath');
|
||||
return filePath;
|
||||
} else {
|
||||
log(
|
||||
talker.error(
|
||||
'[Update] Failed to download Windows installer. Status: ${response.statusCode}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
log('[Update] Error downloading Windows installer: $e');
|
||||
talker.error('[Update] Error downloading Windows installer: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -273,7 +475,7 @@ class UpdateService {
|
||||
/// Extracts the ZIP file to a temporary directory
|
||||
Future<String?> _extractWindowsInstaller(String zipPath) async {
|
||||
try {
|
||||
log('[Update] Extracting Windows installer from: $zipPath');
|
||||
talker.info('[Update] Extracting Windows installer from: $zipPath');
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final extractDir = path.join(
|
||||
@@ -298,10 +500,10 @@ class UpdateService {
|
||||
}
|
||||
}
|
||||
|
||||
log('[Update] Windows installer extracted successfully to: $extractDir');
|
||||
talker.info('[Update] Windows installer extracted successfully to: $extractDir');
|
||||
return extractDir;
|
||||
} catch (e) {
|
||||
log('[Update] Error extracting Windows installer: $e');
|
||||
talker.error('[Update] Error extracting Windows installer: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -309,231 +511,42 @@ class UpdateService {
|
||||
/// Runs the setup.exe file
|
||||
Future<bool> _runWindowsInstaller(String extractDir) async {
|
||||
try {
|
||||
log('[Update] Running Windows installer from: $extractDir');
|
||||
talker.info('[Update] Running Windows installer from: $extractDir');
|
||||
|
||||
final setupExePath = path.join(extractDir, 'setup.exe');
|
||||
final dir = Directory(extractDir);
|
||||
final exeFiles = dir
|
||||
.listSync()
|
||||
.where((f) => f is File && f.path.endsWith('.exe'))
|
||||
.toList();
|
||||
|
||||
if (!await File(setupExePath).exists()) {
|
||||
log('[Update] setup.exe not found in extracted directory');
|
||||
if (exeFiles.isEmpty) {
|
||||
talker.info('[Update] No .exe file found in extracted directory');
|
||||
return false;
|
||||
}
|
||||
|
||||
final setupExePath = exeFiles.first.path;
|
||||
talker.info('[Update] Found installer executable: $setupExePath');
|
||||
|
||||
final shell = Shell();
|
||||
final results = await shell.run(setupExePath);
|
||||
final result = results.first;
|
||||
|
||||
if (result.exitCode == 0) {
|
||||
log('[Update] Windows installer completed successfully');
|
||||
talker.info('[Update] Windows installer completed successfully');
|
||||
return true;
|
||||
} else {
|
||||
log(
|
||||
talker.error(
|
||||
'[Update] Windows installer failed with exit code: ${result.exitCode}',
|
||||
);
|
||||
log('[Update] Installer output: ${result.stdout}');
|
||||
log('[Update] Installer errors: ${result.stderr}');
|
||||
talker.error('[Update] Installer output: ${result.stdout}');
|
||||
talker.error('[Update] Installer errors: ${result.stderr}');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
log('[Update] Error running Windows installer: $e');
|
||||
talker.error('[Update] Error running Windows installer: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs automatic Windows update: download, extract, and install
|
||||
Future<void> _performAutomaticWindowsUpdate(
|
||||
BuildContext context,
|
||||
String url,
|
||||
) async {
|
||||
if (!context.mounted) return;
|
||||
|
||||
// Show progress dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder:
|
||||
(context) => const AlertDialog(
|
||||
title: Text('Installing Update'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Downloading installer...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
// Step 1: Download
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(); // Close progress dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder:
|
||||
(context) => const AlertDialog(
|
||||
title: Text('Installing Update'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Extracting installer...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final zipPath = await _downloadWindowsInstaller(url);
|
||||
if (zipPath == null) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
_showErrorDialog(context, 'Failed to download installer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Extract
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(); // Close progress dialog
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder:
|
||||
(context) => const AlertDialog(
|
||||
title: Text('Installing Update'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('Running installer...'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final extractDir = await _extractWindowsInstaller(zipPath);
|
||||
if (extractDir == null) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
_showErrorDialog(context, 'Failed to extract installer');
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Run installer
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(); // Close progress dialog
|
||||
|
||||
final success = await _runWindowsInstaller(extractDir);
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (success) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Update Complete'),
|
||||
content: const Text(
|
||||
'The application has been updated successfully. Please restart the application.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Close the update sheet
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
_showErrorDialog(context, 'Failed to run installer');
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
try {
|
||||
await File(zipPath).delete();
|
||||
await Directory(extractDir).delete(recursive: true);
|
||||
} catch (e) {
|
||||
log('[Update] Error cleaning up temporary files: $e');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop(); // Close any open dialogs
|
||||
_showErrorDialog(context, 'Update failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Update Failed'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetch the latest release info from GitHub.
|
||||
/// Public so other screens (e.g., About) can manually trigger update checks.
|
||||
Future<GithubReleaseInfo?> fetchLatestRelease() async {
|
||||
final apiEndpoint =
|
||||
useProxy
|
||||
? '$_proxyBaseUrl${Uri.encodeComponent(_releasesLatestApi)}'
|
||||
: _releasesLatestApi;
|
||||
|
||||
log(
|
||||
'[Update] Fetching latest release from GitHub API: $apiEndpoint (Proxy: $useProxy)',
|
||||
);
|
||||
final resp = await _dio.get(apiEndpoint);
|
||||
if (resp.statusCode != 200) {
|
||||
log(
|
||||
'[Update] Failed to fetch latest release. Status code: ${resp.statusCode}',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final data = resp.data as Map<String, dynamic>;
|
||||
log('[Update] Successfully fetched release data.');
|
||||
|
||||
final tagName = (data['tag_name'] ?? '').toString();
|
||||
final name = (data['name'] ?? tagName).toString();
|
||||
final body = (data['body'] ?? '').toString();
|
||||
final htmlUrl = (data['html_url'] ?? '').toString();
|
||||
final createdAtStr = (data['created_at'] ?? '').toString();
|
||||
final createdAt = DateTime.tryParse(createdAtStr) ?? DateTime.now();
|
||||
final assetsData =
|
||||
(data['assets'] as List<dynamic>?)
|
||||
?.map((e) => GithubReleaseAsset.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
if (tagName.isEmpty || htmlUrl.isEmpty) {
|
||||
log(
|
||||
'[Update] Missing tag_name or html_url in release data. TagName: "$tagName", HtmlUrl: "$htmlUrl"',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
log('[Update] Returning GithubReleaseInfo for tag: $tagName');
|
||||
return GithubReleaseInfo(
|
||||
tagName: tagName,
|
||||
name: name,
|
||||
body: body,
|
||||
htmlUrl: htmlUrl,
|
||||
createdAt: createdAt,
|
||||
assets: assetsData,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UpdateSheet extends StatefulWidget {
|
||||
@@ -584,7 +597,7 @@ class _UpdateSheetState extends State<_UpdateSheet> {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return SheetScaffold(
|
||||
titleText: 'Update available',
|
||||
titleText: 'updateAvailable'.tr(),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: 16 + MediaQuery.of(context).padding.bottom,
|
||||
@@ -612,14 +625,14 @@ class _UpdateSheetState extends State<_UpdateSheet> {
|
||||
child: MarkdownTextContent(
|
||||
content:
|
||||
widget.release.body.isEmpty
|
||||
? 'No changelog provided.'
|
||||
? 'noChangelogProvided'.tr()
|
||||
: widget.release.body,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!kIsWeb && Platform.isAndroid)
|
||||
SwitchListTile(
|
||||
title: const Text('Use secondary source for download'),
|
||||
title: Text('useSecondarySourceForDownload'.tr()),
|
||||
value: _useProxy,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@@ -638,11 +651,11 @@ class _UpdateSheetState extends State<_UpdateSheet> {
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
log(widget.androidUpdateUrl!);
|
||||
talker.info(widget.androidUpdateUrl!);
|
||||
_installUpdate(widget.androidUpdateUrl!);
|
||||
},
|
||||
icon: const Icon(Symbols.update),
|
||||
label: const Text('Install update'),
|
||||
label: Text('installUpdate'.tr()),
|
||||
),
|
||||
),
|
||||
if (!kIsWeb &&
|
||||
@@ -655,20 +668,20 @@ class _UpdateSheetState extends State<_UpdateSheet> {
|
||||
final updateService = UpdateService(
|
||||
useProxy: widget.useProxy,
|
||||
);
|
||||
updateService._performAutomaticWindowsUpdate(
|
||||
updateService.performAutomaticWindowsUpdate(
|
||||
context,
|
||||
widget.windowsUpdateUrl!,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.update),
|
||||
label: const Text('Install update'),
|
||||
label: Text('installUpdate'.tr()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: widget.onOpen,
|
||||
icon: const Icon(Icons.open_in_new),
|
||||
label: const Text('Open release page'),
|
||||
label: Text('openReleasePage'.tr()),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
4
lib/talker.dart
Normal file
4
lib/talker.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
|
||||
final talker = TalkerFlutter.init();
|
@@ -120,7 +120,7 @@ class LevelingProgressCard extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Tooltip(
|
||||
message: '${progress.toStringAsFixed(1)}%',
|
||||
message: '${(progress * 100).toStringAsFixed(1)}%',
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: progressHeight,
|
||||
value: progress,
|
||||
|
@@ -4,7 +4,6 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/account/me/settings_connections.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
@@ -50,7 +49,6 @@ class RestorePurchaseSheet extends HookConsumerWidget {
|
||||
return SheetScaffold(
|
||||
titleText: 'restorePurchase'.tr(),
|
||||
child: SingleChildScrollView(
|
||||
padding: getTabbedPadding(context),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
|
@@ -6,7 +6,7 @@ part of 'status.dart';
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
String _$accountStatusHash() => r'c861a0565d6229fd35666bba7cb2f5c6b7298e46';
|
||||
String _$accountStatusHash() => r'abc2f11f0fbaf637efc182cf85ab838936c4d875';
|
||||
|
||||
/// Copied from Dart SDK
|
||||
class _SystemHash {
|
||||
|
@@ -1,6 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -11,10 +10,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/userinfo.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/screens/tabs.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class AppScrollBehavior extends MaterialScrollBehavior {
|
||||
@override
|
||||
@@ -31,16 +32,19 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isMaximized = useState(false);
|
||||
|
||||
// Add window resize listener for desktop platforms
|
||||
useEffect(() {
|
||||
if (!kIsWeb &&
|
||||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||
void saveWindowSize() {
|
||||
final size = appWindow.size;
|
||||
final settingsNotifier = ref.read(
|
||||
appSettingsNotifierProvider.notifier,
|
||||
);
|
||||
settingsNotifier.setWindowSize(size);
|
||||
windowManager.getBounds().then((bounds) {
|
||||
final settingsNotifier = ref.read(
|
||||
appSettingsNotifierProvider.notifier,
|
||||
);
|
||||
settingsNotifier.setWindowSize(bounds.size);
|
||||
});
|
||||
}
|
||||
|
||||
// Save window size when app is about to close
|
||||
@@ -48,11 +52,16 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
_WindowSizeObserver(saveWindowSize),
|
||||
);
|
||||
|
||||
final maximizeListener = _WindowMaximizeListener(isMaximized);
|
||||
windowManager.addListener(maximizeListener);
|
||||
windowManager.isMaximized().then((max) => isMaximized.value = max);
|
||||
|
||||
return () {
|
||||
// Cleanup observer when widget is disposed
|
||||
WidgetsBinding.instance.removeObserver(
|
||||
_WindowSizeObserver(saveWindowSize),
|
||||
);
|
||||
windowManager.removeListener(maximizeListener);
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -60,60 +69,84 @@ class WindowScaffold extends HookConsumerWidget {
|
||||
|
||||
if (!kIsWeb &&
|
||||
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||
final windowButtonColor = WindowButtonColors(
|
||||
iconNormal: Theme.of(context).colorScheme.primary,
|
||||
mouseOver: Theme.of(context).colorScheme.primaryContainer,
|
||||
mouseDown: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
iconMouseOver: Theme.of(context).colorScheme.primary,
|
||||
iconMouseDown: Theme.of(context).colorScheme.primary,
|
||||
);
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
WindowTitleBarBox(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1 / devicePixelRatio,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: MoveWindow(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment:
|
||||
DragToMoveArea(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment:
|
||||
Platform.isMacOS
|
||||
? MainAxisAlignment.center
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child:
|
||||
Platform.isMacOS
|
||||
? MainAxisAlignment.center
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Solar Network',
|
||||
textAlign:
|
||||
Platform.isMacOS
|
||||
? TextAlign.center
|
||||
: TextAlign.start,
|
||||
).padding(horizontal: 12, vertical: 5),
|
||||
),
|
||||
if (!Platform.isMacOS)
|
||||
MinimizeWindowButton(colors: windowButtonColor),
|
||||
if (!Platform.isMacOS)
|
||||
MaximizeWindowButton(colors: windowButtonColor),
|
||||
if (!Platform.isMacOS)
|
||||
CloseWindowButton(
|
||||
colors: windowButtonColor,
|
||||
onPressed: () => appWindow.hide(),
|
||||
),
|
||||
],
|
||||
? Text(
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.center,
|
||||
).padding(horizontal: 12, vertical: 5)
|
||||
: Row(
|
||||
children: [
|
||||
Image.asset(
|
||||
Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? 'assets/icons/icon-dark.png'
|
||||
: 'assets/icons/icon.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Solar Network',
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 12, vertical: 5),
|
||||
),
|
||||
),
|
||||
if (!Platform.isMacOS)
|
||||
...([
|
||||
IconButton(
|
||||
icon: Icon(Symbols.minimize),
|
||||
onPressed: () => windowManager.minimize(),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isMaximized.value
|
||||
? Symbols.fullscreen_exit
|
||||
: Symbols.fullscreen,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (await windowManager.isMaximized()) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.maximize();
|
||||
}
|
||||
},
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Symbols.close),
|
||||
onPressed: () => windowManager.hide(),
|
||||
iconSize: 16,
|
||||
padding: EdgeInsets.all(8),
|
||||
constraints: BoxConstraints(),
|
||||
color: Theme.of(context).iconTheme.color,
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: child),
|
||||
@@ -162,6 +195,21 @@ class _WindowSizeObserver extends WidgetsBindingObserver {
|
||||
int get hashCode => onSaveWindowSize.hashCode;
|
||||
}
|
||||
|
||||
class _WindowMaximizeListener with WindowListener {
|
||||
final ValueNotifier<bool> isMaximized;
|
||||
_WindowMaximizeListener(this.isMaximized);
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
isMaximized.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
isMaximized.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
final rootScaffoldKey = GlobalKey<ScaffoldState>();
|
||||
|
||||
class AppScaffold extends HookConsumerWidget {
|
||||
@@ -245,7 +293,7 @@ class AppScaffold extends HookConsumerWidget {
|
||||
endDrawer: endDrawer,
|
||||
floatingActionButton: floatingActionButton,
|
||||
floatingActionButtonAnimator: floatingActionButtonAnimator,
|
||||
floatingActionButtonLocation: floatingActionButtonLocation,
|
||||
floatingActionButtonLocation: TabbedFabLocation(context),
|
||||
onDrawerChanged: onDrawerChanged,
|
||||
onEndDrawerChanged: onEndDrawerChanged,
|
||||
),
|
||||
|
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/activity/activity_rpc.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/route.dart';
|
||||
import 'package:island/screens/tray_manager.dart';
|
||||
import 'package:island/services/notify.dart';
|
||||
import 'package:island/services/sharing_intent.dart';
|
||||
@@ -12,6 +13,7 @@ import 'package:island/services/update_service.dart';
|
||||
import 'package:island/widgets/content/network_status_sheet.dart';
|
||||
import 'package:island/widgets/tour/tour.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class AppWrapper extends HookConsumerWidget with TrayListener {
|
||||
final Widget child;
|
||||
@@ -21,24 +23,37 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
useEffect(() {
|
||||
StreamSubscription? ntySubs;
|
||||
Future(() {
|
||||
StreamSubscription? appLinksSubs;
|
||||
Future(() async {
|
||||
final appLinks = AppLinks();
|
||||
|
||||
if (context.mounted) ntySubs = setupNotificationListener(context, ref);
|
||||
|
||||
final sharingService = SharingIntentService();
|
||||
if (context.mounted) sharingService.initialize(context);
|
||||
if (context.mounted) UpdateService().checkForUpdates(context);
|
||||
|
||||
TrayService.instance.initialize(this);
|
||||
|
||||
ref.read(rpcServerStateProvider.notifier).start();
|
||||
|
||||
final initialUri = await appLinks.getLatestLink();
|
||||
if (initialUri != null && context.mounted) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handleDeepLink(initialUri, ref);
|
||||
});
|
||||
}
|
||||
|
||||
appLinksSubs = appLinks.uriLinkStream.listen((uri) {
|
||||
_handleDeepLink(uri, ref);
|
||||
});
|
||||
});
|
||||
|
||||
final sharingService = SharingIntentService();
|
||||
sharingService.initialize(context);
|
||||
|
||||
UpdateService().checkForUpdates(context);
|
||||
|
||||
TrayService.instance.initialize(this);
|
||||
|
||||
ref.read(rpcServerStateProvider.notifier).start();
|
||||
|
||||
return () {
|
||||
ref.read(rpcServerProvider).stop();
|
||||
TrayService.instance.dispose(this);
|
||||
sharingService.dispose();
|
||||
ntySubs?.cancel();
|
||||
appLinksSubs?.cancel();
|
||||
};
|
||||
}, const []);
|
||||
|
||||
@@ -67,11 +82,7 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
|
||||
}
|
||||
|
||||
void _trayIconPrimaryAction() {
|
||||
if (appWindow.isVisible) {
|
||||
appWindow.restore();
|
||||
} else {
|
||||
appWindow.show();
|
||||
}
|
||||
windowManager.show();
|
||||
}
|
||||
|
||||
void _trayIconSecondaryAction() {
|
||||
@@ -92,4 +103,16 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
|
||||
void onTrayMenuItemClick(MenuItem menuItem) {
|
||||
TrayService.instance.handleAction(menuItem);
|
||||
}
|
||||
|
||||
void _handleDeepLink(Uri uri, WidgetRef ref) {
|
||||
final router = ref.read(routerProvider);
|
||||
String path = '/${uri.path}';
|
||||
if (uri.queryParameters.isNotEmpty) {
|
||||
path =
|
||||
Uri.parse(
|
||||
path,
|
||||
).replace(queryParameters: uri.queryParameters).toString();
|
||||
}
|
||||
router.go(path);
|
||||
}
|
||||
}
|
||||
|
@@ -26,15 +26,20 @@ class AttachmentUploadConfig {
|
||||
|
||||
class AttachmentUploaderSheet extends StatefulWidget {
|
||||
final WidgetRef ref;
|
||||
final ComposeState state;
|
||||
final ComposeState? state;
|
||||
final List<UniversalFile>? attachments;
|
||||
final int index;
|
||||
|
||||
const AttachmentUploaderSheet({
|
||||
super.key,
|
||||
required this.ref,
|
||||
required this.state,
|
||||
this.state,
|
||||
this.attachments,
|
||||
required this.index,
|
||||
});
|
||||
}) : assert(
|
||||
state != null || attachments != null,
|
||||
'Either state or attachments must be provided',
|
||||
);
|
||||
|
||||
@override
|
||||
State<AttachmentUploaderSheet> createState() =>
|
||||
@@ -46,7 +51,9 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final attachment = widget.state.attachments.value[widget.index];
|
||||
final attachment =
|
||||
widget.attachments?[widget.index] ??
|
||||
widget.state!.attachments.value[widget.index];
|
||||
|
||||
return SheetScaffold(
|
||||
titleText: 'uploadAttachment'.tr(),
|
||||
@@ -111,19 +118,18 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
|
||||
|
||||
// Check accepted types
|
||||
final acceptTypes =
|
||||
selectedPool.policyConfig?['accept_types']
|
||||
as List?;
|
||||
(selectedPool.policyConfig?['accept_types']
|
||||
as List?)
|
||||
?.cast<String>();
|
||||
final mimeType =
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(
|
||||
attachment.type,
|
||||
);
|
||||
final typeAccepted =
|
||||
acceptTypes == null ||
|
||||
acceptTypes.isEmpty ||
|
||||
acceptTypes.any(
|
||||
(type) => mimeType.startsWith(type),
|
||||
);
|
||||
final typeAccepted = _isMimeTypeAccepted(
|
||||
mimeType,
|
||||
acceptTypes,
|
||||
);
|
||||
|
||||
final hasIssues = fileSizeExceeded || !typeAccepted;
|
||||
|
||||
@@ -279,7 +285,9 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
|
||||
}
|
||||
|
||||
Future<AttachmentUploadConfig?> _getUploadConfig() async {
|
||||
final attachment = widget.state.attachments.value[widget.index];
|
||||
final attachment =
|
||||
widget.attachments?[widget.index] ??
|
||||
widget.state!.attachments.value[widget.index];
|
||||
final fileSize = await _getFileSize(attachment);
|
||||
|
||||
if (fileSize == null) return null;
|
||||
@@ -292,14 +300,12 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
|
||||
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
|
||||
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize;
|
||||
|
||||
final acceptTypes = selectedPool.policyConfig?['accept_types'] as List?;
|
||||
final acceptTypes =
|
||||
(selectedPool.policyConfig?['accept_types'] as List?)?.cast<String>();
|
||||
final mimeType =
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(attachment.type);
|
||||
final typeAccepted =
|
||||
acceptTypes == null ||
|
||||
acceptTypes.isEmpty ||
|
||||
acceptTypes.any((type) => mimeType.startsWith(type));
|
||||
final typeAccepted = _isMimeTypeAccepted(mimeType, acceptTypes);
|
||||
|
||||
final hasConstraints = fileSizeExceeded || !typeAccepted;
|
||||
|
||||
@@ -360,4 +366,16 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
|
||||
final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round();
|
||||
return _formatNumber(quotaCost);
|
||||
}
|
||||
|
||||
bool _isMimeTypeAccepted(String mimeType, List<String>? acceptTypes) {
|
||||
if (acceptTypes == null || acceptTypes.isEmpty) return true;
|
||||
return acceptTypes.any((type) {
|
||||
if (type.endsWith('/*')) {
|
||||
final mainType = type.substring(0, type.length - 2);
|
||||
return mimeType.startsWith('$mainType/');
|
||||
} else {
|
||||
return mimeType == type;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/chat/call_participant_tile.dart';
|
||||
|
@@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_popup_card/flutter_popup_card.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/widgets/account/account_nameplate.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/screens/account/profile.dart';
|
||||
import 'package:island/widgets/chat/call_participant_card.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import "dart:async";
|
||||
import "dart:io";
|
||||
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_hooks/flutter_hooks.dart";
|
||||
@@ -11,12 +9,14 @@ import "package:image_picker/image_picker.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/models/file.dart";
|
||||
import "package:island/pods/config.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
import "package:island/widgets/content/attachment_preview.dart";
|
||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||
import "package:pasteboard/pasteboard.dart";
|
||||
import "package:styled_widget/styled_widget.dart";
|
||||
import "package:material_symbols_icons/symbols.dart";
|
||||
import "package:island/widgets/stickers/picker.dart";
|
||||
import "package:island/pods/chat/chat_subscribe.dart";
|
||||
|
||||
class ChatInput extends HookConsumerWidget {
|
||||
final TextEditingController messageController;
|
||||
@@ -55,10 +55,7 @@ class ChatInput extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final inputFocusNode = useFocusNode();
|
||||
|
||||
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
|
||||
|
||||
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
|
||||
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
|
||||
|
||||
void send() {
|
||||
onSend.call();
|
||||
@@ -67,6 +64,18 @@ class ChatInput extends HookConsumerWidget {
|
||||
});
|
||||
}
|
||||
|
||||
void insertNewLine() {
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start = selection.start >= 0 ? selection.start : text.length;
|
||||
final end = selection.end >= 0 ? selection.end : text.length;
|
||||
final newText = text.replaceRange(start, end, '\n');
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: start + 1),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handlePaste() async {
|
||||
final clipboard = await Pasteboard.image;
|
||||
if (clipboard == null) return;
|
||||
@@ -80,213 +89,272 @@ class ChatInput extends HookConsumerWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
void handleKeyPress(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
RawKeyEvent event,
|
||||
) {
|
||||
if (event is! RawKeyDownEvent) return;
|
||||
inputFocusNode.onKeyEvent = (node, event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
|
||||
final isModifierPressed = event.isMetaPressed || event.isControlPressed;
|
||||
final isModifierPressed =
|
||||
HardwareKeyboard.instance.isMetaPressed ||
|
||||
HardwareKeyboard.instance.isControlPressed;
|
||||
|
||||
if (isPaste && isModifierPressed) {
|
||||
handlePaste();
|
||||
return;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
|
||||
final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
|
||||
|
||||
if (isEnter) {
|
||||
if (enterToSend && !isModifierPressed) {
|
||||
send();
|
||||
} else if (!enterToSend && isModifierPressed) {
|
||||
if (isModifierPressed) {
|
||||
insertNewLine();
|
||||
return KeyEventResult.handled;
|
||||
} else if (enterToSend) {
|
||||
send();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 8,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
if (attachments.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 280,
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return SizedBox(
|
||||
height: 280,
|
||||
width: 280,
|
||||
child: AttachmentPreview(
|
||||
item: attachments[idx],
|
||||
progress: attachmentProgress['chat-upload']?[idx],
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
attachments[idx] = value;
|
||||
onAttachmentsChanged(attachments);
|
||||
},
|
||||
onMove: (delta) => onMoveAttachment(idx, delta),
|
||||
return KeyEventResult.ignored;
|
||||
};
|
||||
|
||||
final double leftMargin = isWideScreen(context) ? 8 : 16;
|
||||
final double rightMargin = isWideScreen(context) ? leftMargin + 8 : 16;
|
||||
const double bottomMargin = 16;
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(
|
||||
left: leftMargin,
|
||||
right: rightMargin,
|
||||
bottom: bottomMargin,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 2,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
switchInCurve: Curves.fastEaseInToSlowEaseOut,
|
||||
switchOutCurve: Curves.fastEaseInToSlowEaseOut,
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, -0.3),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
child: SizeTransition(
|
||||
sizeFactor: animation,
|
||||
axisAlignment: -1.0,
|
||||
child: FadeTransition(opacity: animation, child: child),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, _) => const Gap(8),
|
||||
),
|
||||
).padding(top: 12),
|
||||
if (messageReplyingTo != null ||
|
||||
messageForwardingTo != null ||
|
||||
messageEditingTo != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.only(left: 8, right: 8, top: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
messageReplyingTo != null
|
||||
? Symbols.reply
|
||||
: messageForwardingTo != null
|
||||
? Symbols.forward
|
||||
: Symbols.edit,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
messageReplyingTo != null
|
||||
? 'Replying to ${messageReplyingTo?.sender.account.nick}'
|
||||
: messageForwardingTo != null
|
||||
? 'Forwarding message'
|
||||
: 'Editing message',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: onClear,
|
||||
padding: EdgeInsets.zero,
|
||||
style: ButtonStyle(
|
||||
minimumSize: WidgetStatePropertyAll(Size(28, 28)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'stickers'.tr(),
|
||||
icon: const Icon(Symbols.add_reaction),
|
||||
onPressed: () {
|
||||
final size = MediaQuery.of(context).size;
|
||||
showStickerPickerPopover(
|
||||
context,
|
||||
Offset(
|
||||
20,
|
||||
size.height -
|
||||
480 -
|
||||
MediaQuery.of(context).padding.bottom,
|
||||
child:
|
||||
chatSubscribe.isNotEmpty
|
||||
? Container(
|
||||
key: const ValueKey('typing-indicator'),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
onPick: (placeholder) {
|
||||
// Insert placeholder at current cursor position
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start =
|
||||
selection.start >= 0
|
||||
? selection.start
|
||||
: text.length;
|
||||
final end =
|
||||
selection.end >= 0
|
||||
? selection.end
|
||||
: text.length;
|
||||
final newText = text.replaceRange(
|
||||
start,
|
||||
end,
|
||||
placeholder,
|
||||
);
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: start + placeholder.length,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Symbols.more_horiz,
|
||||
size: 16,
|
||||
).padding(horizontal: 8),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'typingHint'.plural(
|
||||
chatSubscribe.length,
|
||||
args: [
|
||||
chatSubscribe
|
||||
.map((x) => x.nick ?? x.account.nick)
|
||||
.join(', '),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
);
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('typing-indicator-none'),
|
||||
),
|
||||
),
|
||||
if (attachments.isNotEmpty)
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: ListView.separated(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: attachments.length,
|
||||
itemBuilder: (context, idx) {
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
child: AttachmentPreview(
|
||||
isCompact: true,
|
||||
item: attachments[idx],
|
||||
progress: attachmentProgress['chat-upload']?[idx],
|
||||
onRequestUpload: () => onUploadAttachment(idx),
|
||||
onDelete: () => onDeleteAttachment(idx),
|
||||
onUpdate: (value) {
|
||||
attachments[idx] = value;
|
||||
onAttachmentsChanged(attachments);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Symbols.photo_library),
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(true),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.photo),
|
||||
Text('addPhoto').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(false),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.video_call),
|
||||
Text('addVideo').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onMove: (delta) => onMoveAttachment(idx, delta),
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, _) => const Gap(8),
|
||||
),
|
||||
).padding(vertical: 12),
|
||||
if (messageReplyingTo != null ||
|
||||
messageForwardingTo != null ||
|
||||
messageEditingTo != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
margin: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
messageReplyingTo != null
|
||||
? Symbols.reply
|
||||
: messageForwardingTo != null
|
||||
? Symbols.forward
|
||||
: Symbols.edit,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
messageReplyingTo != null
|
||||
? 'Replying to ${messageReplyingTo?.sender.account.nick}'
|
||||
: messageForwardingTo != null
|
||||
? 'Forwarding message'
|
||||
: 'Editing message',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: InkWell(
|
||||
onTap: onClear,
|
||||
child: const Icon(Icons.close, size: 20).center(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: (event) => handleKeyPress(context, ref, event),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: 'stickers'.tr(),
|
||||
icon: const Icon(Symbols.add_reaction),
|
||||
onPressed: () {
|
||||
final size = MediaQuery.of(context).size;
|
||||
showStickerPickerPopover(
|
||||
context,
|
||||
Offset(
|
||||
20,
|
||||
size.height -
|
||||
480 -
|
||||
MediaQuery.of(context).padding.bottom,
|
||||
),
|
||||
onPick: (placeholder) {
|
||||
// Insert placeholder at current cursor position
|
||||
final text = messageController.text;
|
||||
final selection = messageController.selection;
|
||||
final start =
|
||||
selection.start >= 0
|
||||
? selection.start
|
||||
: text.length;
|
||||
final end =
|
||||
selection.end >= 0
|
||||
? selection.end
|
||||
: text.length;
|
||||
final newText = text.replaceRange(
|
||||
start,
|
||||
end,
|
||||
placeholder,
|
||||
);
|
||||
messageController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(
|
||||
offset: start + placeholder.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
PopupMenuButton(
|
||||
icon: const Icon(Symbols.photo_library),
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(true),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.photo),
|
||||
Text('addPhoto').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
onTap: () => onPickFile(false),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const Icon(Symbols.video_call),
|
||||
Text('addVideo').tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
focusNode: inputFocusNode,
|
||||
controller: messageController,
|
||||
onSubmitted:
|
||||
(enterToSend && isMobile)
|
||||
? (_) {
|
||||
send();
|
||||
}
|
||||
: null,
|
||||
keyboardType:
|
||||
(enterToSend && isMobile)
|
||||
? TextInputType.text
|
||||
: TextInputType.multiline,
|
||||
textInputAction: TextInputAction.send,
|
||||
inputFormatters: [
|
||||
if (enterToSend && !isMobile)
|
||||
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||
if (newValue.text.endsWith('\n')) {
|
||||
return oldValue;
|
||||
}
|
||||
return newValue;
|
||||
}),
|
||||
],
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintMaxLines: 1,
|
||||
hintText:
|
||||
(chatRoom.type == 1 && chatRoom.name == null)
|
||||
? 'chatDirectMessageHint'.tr(
|
||||
@@ -301,7 +369,7 @@ class ChatInput extends HookConsumerWidget {
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
vertical: 12,
|
||||
),
|
||||
counterText:
|
||||
messageController.text.length > 1024
|
||||
@@ -314,16 +382,16 @@ class ChatInput extends HookConsumerWidget {
|
||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: send,
|
||||
),
|
||||
],
|
||||
).padding(bottom: MediaQuery.of(context).padding.bottom),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onPressed: send,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/pods/call.dart';
|
||||
import 'package:island/pods/chat/call.dart';
|
||||
import 'package:island/widgets/content/markdown.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:pretty_diff_text/pretty_diff_text.dart';
|
||||
@@ -56,7 +56,7 @@ class MessageContent extends StatelessWidget {
|
||||
case 'messages.update.links':
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
@@ -64,27 +64,29 @@ class MessageContent extends StatelessWidget {
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.6),
|
||||
),
|
||||
).padding(top: 2),
|
||||
const Gap(4),
|
||||
if (item.meta['previous_content'] is String)
|
||||
PrettyDiffText(
|
||||
oldText: item.meta['previous_content'],
|
||||
newText: item.content ?? 'Edited a message',
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
addedTextStyle: TextStyle(
|
||||
backgroundColor: Theme.of(
|
||||
Flexible(
|
||||
child: PrettyDiffText(
|
||||
oldText: item.meta['previous_content'],
|
||||
newText: item.content ?? 'Edited a message',
|
||||
defaultTextStyle: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
||||
),
|
||||
deletedTextStyle: TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
).textTheme.bodyMedium!.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
addedTextStyle: TextStyle(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primaryFixedDim.withOpacity(0.4),
|
||||
),
|
||||
deletedTextStyle: TextStyle(
|
||||
decoration: TextDecoration.lineThrough,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurfaceVariant.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
@@ -104,10 +106,12 @@ class MessageContent extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
Flexible(
|
||||
child: MarkdownTextContent(
|
||||
content: item.content ?? '*${item.type} has no content*',
|
||||
isSelectable: true,
|
||||
linesMargin: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (translatedText?.isNotEmpty ?? false)
|
||||
...([
|
||||
|
@@ -11,10 +11,10 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/database/message.dart';
|
||||
import 'package:island/models/embed.dart';
|
||||
import 'package:island/pods/messages_notifier.dart';
|
||||
import 'package:island/pods/chat/chat_rooms.dart';
|
||||
import 'package:island/pods/chat/messages_notifier.dart';
|
||||
import 'package:island/pods/translate.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/screens/chat/room.dart';
|
||||
import 'package:island/utils/mapping.dart';
|
||||
import 'package:island/widgets/account/account_pfc.dart';
|
||||
import 'package:island/widgets/app_scaffold.dart';
|
||||
@@ -538,7 +538,7 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
|
||||
isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('HH:mm').format(message.createdAt),
|
||||
DateFormat('HH:mm').format(message.createdAt.toLocal()),
|
||||
style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
|
||||
).padding(top: isMultiline ? 2 : 0),
|
||||
AccountPfcGestureDetector(
|
||||
|
@@ -6,7 +6,7 @@ import "package:gap/gap.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:island/database/message.dart";
|
||||
import "package:island/models/chat.dart";
|
||||
import "package:island/pods/messages_notifier.dart";
|
||||
import "package:island/pods/chat/messages_notifier.dart";
|
||||
import "package:island/pods/network.dart";
|
||||
import "package:island/services/responsive.dart";
|
||||
import "package:island/widgets/alert.dart";
|
||||
|
@@ -1,11 +1,9 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter_platform_alert/flutter_platform_alert.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
String _parseRemoteError(DioException err) {
|
||||
log('${err.requestOptions.method} ${err.requestOptions.uri} ${err.message}');
|
||||
String? message;
|
||||
if (err.response?.data is String) {
|
||||
message = err.response?.data;
|
||||
@@ -30,7 +28,7 @@ String _parseRemoteError(DioException err) {
|
||||
|
||||
void showErrorAlert(dynamic err) async {
|
||||
if (err is Error) {
|
||||
log('${err.stackTrace}');
|
||||
talker.error('Something went wrong...', err, err.stackTrace);
|
||||
}
|
||||
final text = switch (err) {
|
||||
String _ => err,
|
||||
|
@@ -1,13 +1,11 @@
|
||||
// ignore_for_file: avoid_web_libraries_in_flutter
|
||||
|
||||
import 'dart:developer';
|
||||
import 'dart:js' as js;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
|
||||
String _parseRemoteError(DioException err) {
|
||||
log('${err.requestOptions.method} ${err.requestOptions.uri} ${err.message}');
|
||||
String? message;
|
||||
if (err.response?.data is String) {
|
||||
message = err.response?.data;
|
||||
|
@@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
@@ -107,13 +108,23 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
|
||||
GlobalKey<SensitiveMarksSelectorState>();
|
||||
|
||||
Future<void> _showRenameDialog(BuildContext context, WidgetRef ref) async {
|
||||
final nameController = TextEditingController(text: item.data.name);
|
||||
String _getDisplayName() {
|
||||
return item.displayName ??
|
||||
(item.data is XFile
|
||||
? (item.data as XFile).name
|
||||
: item.isOnCloud
|
||||
? item.data.name
|
||||
: '');
|
||||
}
|
||||
|
||||
Future<void> _showRenameSheet(BuildContext context, WidgetRef ref) async {
|
||||
final nameController = TextEditingController(text: _getDisplayName());
|
||||
String? errorMessage;
|
||||
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => SheetScaffold(
|
||||
heightFactor: 0.6,
|
||||
@@ -152,22 +163,32 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.patch(
|
||||
'/drive/files/${item.data.id}/name',
|
||||
data: jsonEncode(newName),
|
||||
);
|
||||
final newData = item.data;
|
||||
newData.name = newName;
|
||||
final updatedFile = item.copyWith(data: newData);
|
||||
onUpdate?.call(item.copyWith(data: updatedFile));
|
||||
if (item.isOnCloud) {
|
||||
try {
|
||||
showLoadingModal(context);
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
await apiClient.patch(
|
||||
'/drive/files/${item.data.id}/name',
|
||||
data: jsonEncode(newName),
|
||||
);
|
||||
final newData = item.data;
|
||||
newData.name = newName;
|
||||
onUpdate?.call(
|
||||
item.copyWith(
|
||||
data: newData,
|
||||
displayName: newName,
|
||||
),
|
||||
);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
} else {
|
||||
// Local file rename
|
||||
onUpdate?.call(item.copyWith(displayName: newName));
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
} catch (err) {
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
if (context.mounted) hideLoadingModal(context);
|
||||
}
|
||||
},
|
||||
child: Text('rename'.tr()),
|
||||
@@ -292,6 +313,8 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
_ => Symbols.insert_drive_file,
|
||||
};
|
||||
|
||||
final mimeType = FileUploader.getMimeType(item);
|
||||
|
||||
if (item.isOnCloud) {
|
||||
return CloudFileWidget(item: item.data);
|
||||
} else if (item.data is XFile) {
|
||||
@@ -321,7 +344,12 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(file.name, textAlign: TextAlign.center),
|
||||
Text(
|
||||
_getDisplayName(),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
FutureBuilder(
|
||||
future: file.length(),
|
||||
builder: (context, snapshot) {
|
||||
@@ -347,6 +375,8 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
children: [
|
||||
Icon(fallbackIcon),
|
||||
const Gap(6),
|
||||
Text(mimeType, style: TextStyle(fontSize: 10)),
|
||||
const Gap(1),
|
||||
Text(
|
||||
formatFileSize(item.data.length),
|
||||
).fontSize(11),
|
||||
@@ -542,12 +572,20 @@ class AttachmentPreview extends HookConsumerWidget {
|
||||
onUpdate?.call(item.copyWith(data: result));
|
||||
},
|
||||
),
|
||||
if (item.isOnDevice)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
MenuAction(
|
||||
title: 'rename'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () async {
|
||||
await _showRenameDialog(context, ref);
|
||||
await _showRenameSheet(context, ref);
|
||||
},
|
||||
),
|
||||
if (item.isOnCloud)
|
||||
|
@@ -1,11 +1,10 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
@@ -57,7 +56,7 @@ class _UniversalAudioState extends ConsumerState<UniversalAudio> {
|
||||
String? uri;
|
||||
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
|
||||
if (inCacheInfo == null) {
|
||||
log('[MediaPlayer] Miss cache: $url');
|
||||
talker.info('[MediaPlayer] Miss cache: $url');
|
||||
final token = ref.watch(tokenProvider)?.token;
|
||||
DefaultCacheManager().downloadFile(
|
||||
url,
|
||||
@@ -66,7 +65,7 @@ class _UniversalAudioState extends ConsumerState<UniversalAudio> {
|
||||
uri = url;
|
||||
} else {
|
||||
uri = inCacheInfo.file.path;
|
||||
log('[MediaPlayer] Hit cache: $url');
|
||||
talker.info('[MediaPlayer] Hit cache: $url');
|
||||
}
|
||||
|
||||
_player!.open(Media(uri), play: widget.autoplay);
|
||||
|
@@ -6,9 +6,8 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -42,10 +41,6 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
Future<void> startUpload() async {
|
||||
if (files.value.isEmpty) return;
|
||||
|
||||
final baseUrl = ref.read(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw Exception("Unauthorized");
|
||||
|
||||
List<SnCloudFile> result = List.empty(growable: true);
|
||||
|
||||
uploadProgress.value = 0;
|
||||
@@ -55,19 +50,9 @@ class CloudFilePicker extends HookConsumerWidget {
|
||||
uploadPosition.value = idx;
|
||||
final file = files.value[idx];
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
fileData: file,
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename: file.data.name ?? 'Post media',
|
||||
mimetype:
|
||||
file.data.mimeType ??
|
||||
switch (file.type) {
|
||||
UniversalFileType.image => 'image/unknown',
|
||||
UniversalFileType.video => 'video/unknown',
|
||||
UniversalFileType.audio => 'audio/unknown',
|
||||
UniversalFileType.file => 'application/octet-stream',
|
||||
},
|
||||
client: ref.read(apiClientProvider),
|
||||
onProgress: (progress, _) {
|
||||
uploadProgress.value = progress;
|
||||
},
|
||||
|
@@ -8,11 +8,13 @@ import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/utils/format.dart';
|
||||
import 'package:island/widgets/content/audio.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
import 'package:island/widgets/data_saving_gate.dart';
|
||||
|
||||
@@ -49,72 +51,139 @@ class CloudFileWidget extends HookConsumerWidget {
|
||||
var ratio = meta['ratio'] is num ? (meta['ratio'] as num).toDouble() : 1.0;
|
||||
if (ratio == 0) ratio = 1.0;
|
||||
|
||||
Widget cloudImage() => UniversalImage(uri: uri, blurHash: blurHash, fit: fit);
|
||||
Widget cloudImage() =>
|
||||
UniversalImage(uri: uri, blurHash: blurHash, fit: fit);
|
||||
Widget cloudVideo() => CloudVideoWidget(item: item);
|
||||
|
||||
Widget dataPlaceHolder(IconData icon) => _DataSavingPlaceholder(
|
||||
icon: icon,
|
||||
onTap: () {
|
||||
unlocked.value = true;
|
||||
icon: icon,
|
||||
onTap: () {
|
||||
unlocked.value = true;
|
||||
},
|
||||
);
|
||||
|
||||
if (item.mimeType == 'application/pdf') {
|
||||
return Stack(
|
||||
children: [
|
||||
SizedBox(height: 600, child: SfPdfViewer.network(uri)),
|
||||
Positioned(
|
||||
top: 8,
|
||||
left: 8,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Symbols.picture_as_pdf, size: 16, color: Colors.white),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'PDF',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (item.mimeType?.startsWith('text/') == true) {
|
||||
return SizedBox(
|
||||
height: 400,
|
||||
child: FutureBuilder<String>(
|
||||
future: ref
|
||||
.read(apiClientProvider)
|
||||
.get(uri)
|
||||
.then((response) => response.data as String),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('Error loading text: ${snapshot.error}'),
|
||||
);
|
||||
} else if (snapshot.hasData) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
snapshot.data!,
|
||||
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(child: Text('No content'));
|
||||
},
|
||||
);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
||||
'image' => AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: (useInternalGate && dataSaving && !unlocked.value) ? dataPlaceHolder(Symbols.image) : cloudImage(),
|
||||
),
|
||||
aspectRatio: ratio,
|
||||
child:
|
||||
(useInternalGate && dataSaving && !unlocked.value)
|
||||
? dataPlaceHolder(Symbols.image)
|
||||
: cloudImage(),
|
||||
),
|
||||
'video' => AspectRatio(
|
||||
aspectRatio: ratio,
|
||||
child: (useInternalGate && dataSaving && !unlocked.value) ? dataPlaceHolder(Symbols.play_arrow) : cloudVideo(),
|
||||
),
|
||||
aspectRatio: ratio,
|
||||
child:
|
||||
(useInternalGate && dataSaving && !unlocked.value)
|
||||
? dataPlaceHolder(Symbols.play_arrow)
|
||||
: cloudVideo(),
|
||||
),
|
||||
'audio' => Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
||||
),
|
||||
child: UniversalAudio(uri: uri, filename: item.name),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
||||
),
|
||||
child: UniversalAudio(uri: uri, filename: item.name),
|
||||
),
|
||||
),
|
||||
_ => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.insert_drive_file,
|
||||
size: 48,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.insert_drive_file,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
item.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Text(
|
||||
formatFileSize(item.size),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://fs.solian.app/files/${item.id}',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.launch),
|
||||
label: Text('openInBrowser').tr(),
|
||||
),
|
||||
],
|
||||
).padding(all: 8),
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
launchUrlString(
|
||||
'https://solian.app/files/${item.id}',
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.launch),
|
||||
label: Text('openInBrowser').tr(),
|
||||
),
|
||||
],
|
||||
).padding(all: 8),
|
||||
};
|
||||
|
||||
if (heroTag != null) {
|
||||
@@ -140,8 +209,11 @@ class _DataSavingPlaceholder extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 36,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
Icon(
|
||||
icon,
|
||||
size: 36,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'dataSavingHint'.tr(),
|
||||
@@ -154,6 +226,7 @@ class _DataSavingPlaceholder extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CloudVideoWidget extends HookConsumerWidget {
|
||||
final SnCloudFile item;
|
||||
const CloudVideoWidget({super.key, required this.item});
|
||||
@@ -352,35 +425,40 @@ class ProfilePictureWidget extends ConsumerWidget {
|
||||
this.fallbackColor,
|
||||
});
|
||||
|
||||
@override
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final serverUrl = ref.watch(serverUrlProvider);
|
||||
final String? id = file?.id ?? fileId;
|
||||
|
||||
final fallback = Icon(
|
||||
fallbackIcon ?? Symbols.account_circle,
|
||||
size: radius,
|
||||
color: fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
).center();
|
||||
final fallback =
|
||||
Icon(
|
||||
fallbackIcon ?? Symbols.account_circle,
|
||||
size: radius,
|
||||
color:
|
||||
fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
).center();
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: borderRadius == null
|
||||
? BorderRadius.all(Radius.circular(radius))
|
||||
: BorderRadius.all(Radius.circular(borderRadius!)),
|
||||
borderRadius:
|
||||
borderRadius == null
|
||||
? BorderRadius.all(Radius.circular(radius))
|
||||
: BorderRadius.all(Radius.circular(borderRadius!)),
|
||||
child: Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: id == null
|
||||
? fallback
|
||||
: DataSavingGate(
|
||||
bypass: true,
|
||||
placeholder: fallback,
|
||||
content: () => UniversalImage(
|
||||
uri: '$serverUrl/drive/files/$id',
|
||||
fit: BoxFit.cover,
|
||||
child:
|
||||
id == null
|
||||
? fallback
|
||||
: DataSavingGate(
|
||||
bypass: true,
|
||||
placeholder: fallback,
|
||||
content:
|
||||
() => UniversalImage(
|
||||
uri: '$serverUrl/drive/files/$id',
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
@@ -37,7 +37,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
|
||||
String? uri;
|
||||
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
|
||||
if (inCacheInfo == null) {
|
||||
log('[MediaPlayer] Miss cache: $url');
|
||||
talker.info('[MediaPlayer] Miss cache: $url');
|
||||
final token = ref.watch(tokenProvider)?.token;
|
||||
DefaultCacheManager().downloadFile(
|
||||
url,
|
||||
@@ -46,7 +46,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
|
||||
uri = url;
|
||||
} else {
|
||||
uri = inCacheInfo.file.path;
|
||||
log('[MediaPlayer] Hit cache: $url');
|
||||
talker.info('[MediaPlayer] Hit cache: $url');
|
||||
}
|
||||
|
||||
_player!.open(Media(uri), play: widget.autoplay);
|
||||
|
@@ -1,14 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/pods/message.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/websocket.dart';
|
||||
import 'package:island/services/update_service.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/network_status_sheet.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
@@ -65,69 +70,112 @@ class DebugSheet extends HookConsumerWidget {
|
||||
return SheetScaffold(
|
||||
titleText: 'Debug',
|
||||
heightFactor: 0.6,
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.wifi),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('Connection Status'),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => NetworkStatusSheet(
|
||||
onReconnect: () => wsNotifier.connect(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.copy_all),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Copy access token'),
|
||||
onTap: () async {
|
||||
final tk = ref.watch(tokenProvider);
|
||||
Clipboard.setData(ClipboardData(text: tk!.token));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Set access token'),
|
||||
onTap: () async {
|
||||
await _showSetTokenDialog(context, ref);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.delete),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Reset database'),
|
||||
onTap: () async {
|
||||
resetDatabase(ref);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.clear),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Clear cache'),
|
||||
onTap: () async {
|
||||
DefaultCacheManager().emptyCache();
|
||||
},
|
||||
),
|
||||
],
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const Gap(4),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.update),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('Force Update'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () async {
|
||||
// Fetch latest release and show the unified sheet
|
||||
final svc = UpdateService();
|
||||
// Reuse service fetch + compare to decide content
|
||||
showLoadingModal(context);
|
||||
final release = await svc.fetchLatestRelease();
|
||||
if (!context.mounted) return;
|
||||
hideLoadingModal(context);
|
||||
if (release != null) {
|
||||
await svc.showUpdateSheet(context, release);
|
||||
} else {
|
||||
showInfoAlert(
|
||||
'Currently cannot get update from the GitHub.',
|
||||
'Unable to check for updates',
|
||||
);
|
||||
}
|
||||
}
|
||||
),
|
||||
const Divider(height: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.wifi),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('Connection Status'),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder:
|
||||
(context) => NetworkStatusSheet(
|
||||
onReconnect: () => wsNotifier.connect(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.bug_report),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('Logs'),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TalkerScreen(talker: talker),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(height: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.copy_all),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Copy access token'),
|
||||
onTap: () async {
|
||||
final tk = ref.watch(tokenProvider);
|
||||
Clipboard.setData(ClipboardData(text: tk!.token));
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.edit),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Set access token'),
|
||||
onTap: () async {
|
||||
await _showSetTokenDialog(context, ref);
|
||||
},
|
||||
),
|
||||
const Divider(height: 8),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.delete),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Reset database'),
|
||||
onTap: () async {
|
||||
resetDatabase(ref);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
minTileHeight: 48,
|
||||
leading: const Icon(Symbols.clear),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('Clear cache'),
|
||||
onTap: () async {
|
||||
DefaultCacheManager().emptyCache();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
895
lib/widgets/post/compose_card.dart
Normal file
895
lib/widgets/post/compose_card.dart
Normal file
@@ -0,0 +1,895 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/screens/creators/publishers.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/attachment_uploader.dart';
|
||||
import 'package:island/widgets/content/attachment_preview.dart';
|
||||
import 'package:island/widgets/content/cloud_files.dart';
|
||||
import 'package:island/widgets/post/compose_shared.dart';
|
||||
import 'package:island/widgets/post/post_item.dart';
|
||||
import 'package:island/widgets/post/publishers_modal.dart';
|
||||
import 'package:island/widgets/post/compose_settings_sheet.dart';
|
||||
import 'package:island/widgets/post/compose_toolbar.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
|
||||
/// A dialog-compatible card widget for post composition.
|
||||
/// This extracts the core compose functionality from PostComposeScreen
|
||||
/// and adapts it for use within dialogs or other constrained layouts.
|
||||
class PostComposeCard extends HookConsumerWidget {
|
||||
final SnPost? originalPost;
|
||||
final PostComposeInitialState? initialState;
|
||||
final VoidCallback? onCancel;
|
||||
final Function(SnPost)? onSubmit;
|
||||
final Function(ComposeState)? onStateChanged;
|
||||
|
||||
const PostComposeCard({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.initialState,
|
||||
this.onCancel,
|
||||
this.onSubmit,
|
||||
this.onStateChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final submitted = useState(false);
|
||||
|
||||
final repliedPost = initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
final forwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final publishers = ref.watch(publishersManagedProvider);
|
||||
|
||||
// Capture the notifier to avoid using ref after dispose
|
||||
final notifier = ref.read(composeStorageNotifierProvider.notifier);
|
||||
|
||||
// Create compose state
|
||||
final state = useMemoized(
|
||||
() => ComposeLogic.createState(
|
||||
originalPost: originalPost,
|
||||
forwardedPost: forwardedPost,
|
||||
repliedPost: repliedPost,
|
||||
postType: 0,
|
||||
),
|
||||
[originalPost, forwardedPost, repliedPost],
|
||||
);
|
||||
|
||||
// Add a listener to the entire state to trigger rebuilds
|
||||
final stateNotifier = useMemoized(
|
||||
() => Listenable.merge([
|
||||
state.titleController,
|
||||
state.descriptionController,
|
||||
state.contentController,
|
||||
state.visibility,
|
||||
state.attachments,
|
||||
state.attachmentProgress,
|
||||
state.currentPublisher,
|
||||
state.submitting,
|
||||
]),
|
||||
[state],
|
||||
);
|
||||
useListenable(stateNotifier);
|
||||
|
||||
// Notify parent of state changes
|
||||
useEffect(() {
|
||||
onStateChanged?.call(state);
|
||||
return null;
|
||||
}, [state]);
|
||||
|
||||
// Initialize publisher once when data is available
|
||||
useEffect(() {
|
||||
if (publishers.value?.isNotEmpty ?? false) {
|
||||
if (state.currentPublisher.value == null) {
|
||||
state.currentPublisher.value = publishers.value!.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [publishers]);
|
||||
|
||||
// Load initial state if provided
|
||||
useEffect(() {
|
||||
if (initialState != null) {
|
||||
state.titleController.text = initialState!.title ?? '';
|
||||
state.descriptionController.text = initialState!.description ?? '';
|
||||
state.contentController.text = initialState!.content ?? '';
|
||||
if (initialState!.visibility != null) {
|
||||
state.visibility.value = initialState!.visibility!;
|
||||
}
|
||||
if (initialState!.attachments.isNotEmpty) {
|
||||
state.attachments.value = List.from(initialState!.attachments);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [initialState]);
|
||||
|
||||
// Dispose state when widget is disposed
|
||||
useEffect(() {
|
||||
return () {
|
||||
if (!submitted.value &&
|
||||
originalPost == null &&
|
||||
state.currentPublisher.value != null) {
|
||||
final hasContent =
|
||||
state.titleController.text.trim().isNotEmpty ||
|
||||
state.descriptionController.text.trim().isNotEmpty ||
|
||||
state.contentController.text.trim().isNotEmpty;
|
||||
final hasAttachments = state.attachments.value.isNotEmpty;
|
||||
if (hasContent || hasAttachments) {
|
||||
final draft = SnPost(
|
||||
id: state.draftId,
|
||||
title: state.titleController.text,
|
||||
description: state.descriptionController.text,
|
||||
content: state.contentController.text,
|
||||
visibility: state.visibility.value,
|
||||
type: state.postType,
|
||||
attachments:
|
||||
state.attachments.value
|
||||
.where((e) => e.isOnCloud)
|
||||
.map((e) => e.data as SnCloudFile)
|
||||
.toList(),
|
||||
publisher: state.currentPublisher.value!,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
notifier
|
||||
.saveDraft(draft)
|
||||
.catchError((e) => debugPrint('Failed to save draft: $e'));
|
||||
}
|
||||
}
|
||||
ComposeLogic.dispose(state);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Reset form to clean state for new composition
|
||||
void resetForm() {
|
||||
// Clear text fields
|
||||
state.titleController.clear();
|
||||
state.descriptionController.clear();
|
||||
state.contentController.clear();
|
||||
state.slugController.clear();
|
||||
|
||||
// Reset visibility to default (0 = public)
|
||||
state.visibility.value = 0;
|
||||
|
||||
// Clear attachments
|
||||
state.attachments.value = [];
|
||||
|
||||
// Clear attachment progress
|
||||
state.attachmentProgress.value = {};
|
||||
|
||||
// Clear tags
|
||||
state.tagsController.clearTags();
|
||||
|
||||
// Clear categories
|
||||
state.categories.value = [];
|
||||
|
||||
// Clear embed view
|
||||
state.embedView.value = null;
|
||||
|
||||
// Clear poll
|
||||
state.pollId.value = null;
|
||||
|
||||
// Clear realm
|
||||
state.realm.value = null;
|
||||
|
||||
// Generate new draft ID for fresh composition
|
||||
// Note: We don't recreate the entire state, just reset the fields
|
||||
// The existing state object is reused for continuity
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
void showSettingsSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => ComposeSettingsSheet(state: state),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performSubmit() async {
|
||||
if (state.submitting.value) return;
|
||||
|
||||
// Don't submit empty posts (no content and no attachments)
|
||||
final hasContent =
|
||||
state.titleController.text.trim().isNotEmpty ||
|
||||
state.descriptionController.text.trim().isNotEmpty ||
|
||||
state.contentController.text.trim().isNotEmpty;
|
||||
final hasAttachments = state.attachments.value.isNotEmpty;
|
||||
|
||||
if (!hasContent && !hasAttachments) {
|
||||
// Show error message if context is mounted
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('postContentEmpty'.tr())));
|
||||
}
|
||||
return; // Don't submit empty posts
|
||||
}
|
||||
|
||||
try {
|
||||
state.submitting.value = true;
|
||||
|
||||
// Upload any local attachments first
|
||||
await Future.wait(
|
||||
state.attachments.value
|
||||
.asMap()
|
||||
.entries
|
||||
.where((entry) => entry.value.isOnDevice)
|
||||
.map(
|
||||
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
|
||||
),
|
||||
);
|
||||
|
||||
// Prepare API request
|
||||
final client = ref.read(apiClientProvider);
|
||||
final isNewPost = originalPost == null;
|
||||
final endpoint =
|
||||
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost!.id}'}';
|
||||
|
||||
// Create request payload
|
||||
final payload = {
|
||||
'title': state.titleController.text,
|
||||
'description': state.descriptionController.text,
|
||||
'content': state.contentController.text,
|
||||
if (state.slugController.text.isNotEmpty)
|
||||
'slug': state.slugController.text,
|
||||
'visibility': state.visibility.value,
|
||||
'attachments':
|
||||
state.attachments.value
|
||||
.where((e) => e.isOnCloud)
|
||||
.map((e) => e.data.id)
|
||||
.toList(),
|
||||
'type': state.postType,
|
||||
if (repliedPost != null) 'replied_post_id': repliedPost.id,
|
||||
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,
|
||||
'tags': state.tagsController.getTags,
|
||||
'categories': state.categories.value.map((e) => e.slug).toList(),
|
||||
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
|
||||
if (state.pollId.value != null) 'poll_id': state.pollId.value,
|
||||
if (state.embedView.value != null)
|
||||
'embed_view': state.embedView.value!.toJson(),
|
||||
};
|
||||
|
||||
// Send request
|
||||
final response = await client.request(
|
||||
endpoint,
|
||||
queryParameters: {'pub': state.currentPublisher.value?.name},
|
||||
data: payload,
|
||||
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
|
||||
);
|
||||
|
||||
// Create the post object from the response for the callback
|
||||
final post = SnPost.fromJson(response.data);
|
||||
|
||||
// Mark as submitted
|
||||
submitted.value = true;
|
||||
|
||||
// Delete draft after successful submission
|
||||
await ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.deleteDraft(state.draftId);
|
||||
|
||||
// Reset the form for new composition
|
||||
resetForm();
|
||||
|
||||
// Call the success callback with the created/updated post
|
||||
onSubmit?.call(post);
|
||||
} catch (err) {
|
||||
// Show error message if context is mounted
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Error: $err')));
|
||||
}
|
||||
rethrow;
|
||||
} finally {
|
||||
state.submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildWideAttachmentGrid() {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: state.attachments.value.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final progressMap = state.attachmentProgress.value;
|
||||
return AttachmentPreview(
|
||||
isCompact: true,
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload: () async {
|
||||
final config = await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete: () => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onUpdate:
|
||||
(value) => ComposeLogic.updateAttachment(state, value, idx),
|
||||
onMove: (delta) {
|
||||
state.attachments.value = ComposeLogic.moveAttachment(
|
||||
state.attachments.value,
|
||||
idx,
|
||||
delta,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildNarrowAttachmentList() {
|
||||
return Column(
|
||||
children: [
|
||||
for (var idx = 0; idx < state.attachments.value.length; idx++)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: () {
|
||||
final progressMap = state.attachmentProgress.value;
|
||||
return AttachmentPreview(
|
||||
item: state.attachments.value[idx],
|
||||
progress: progressMap[idx],
|
||||
onRequestUpload: () async {
|
||||
final config =
|
||||
await showModalBottomSheet<AttachmentUploadConfig>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => AttachmentUploaderSheet(
|
||||
ref: ref,
|
||||
state: state,
|
||||
index: idx,
|
||||
),
|
||||
);
|
||||
if (config != null) {
|
||||
await ComposeLogic.uploadAttachment(
|
||||
ref,
|
||||
state,
|
||||
idx,
|
||||
poolId: config.poolId,
|
||||
);
|
||||
}
|
||||
},
|
||||
onDelete:
|
||||
() => ComposeLogic.deleteAttachment(ref, state, idx),
|
||||
onUpdate:
|
||||
(value) =>
|
||||
ComposeLogic.updateAttachment(state, value, idx),
|
||||
onMove: (delta) {
|
||||
state.attachments.value = ComposeLogic.moveAttachment(
|
||||
state.attachments.value,
|
||||
idx,
|
||||
delta,
|
||||
);
|
||||
},
|
||||
);
|
||||
}(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 400),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header with actions
|
||||
Container(
|
||||
height: 65,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: theme.colorScheme.outline.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
originalPost != null
|
||||
? 'postEditing'.tr()
|
||||
: 'postCompose'.tr(),
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.settings),
|
||||
onPressed: showSettingsSheet,
|
||||
tooltip: 'postSettings'.tr(),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: state.submitting.value ? null : performSubmit,
|
||||
icon:
|
||||
state.submitting.value
|
||||
? SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
originalPost != null
|
||||
? Symbols.edit
|
||||
: Symbols.upload,
|
||||
),
|
||||
tooltip:
|
||||
originalPost != null
|
||||
? 'postUpdate'.tr()
|
||||
: 'postPublish'.tr(),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
if (onCancel != null)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.close),
|
||||
onPressed: onCancel,
|
||||
tooltip: 'cancel'.tr(),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Info banner (reply/forward)
|
||||
_buildInfoBanner(context),
|
||||
|
||||
// Main content area
|
||||
Expanded(
|
||||
child: KeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKeyEvent:
|
||||
(event) => ComposeLogic.handleKeyPress(
|
||||
event,
|
||||
state,
|
||||
ref,
|
||||
context,
|
||||
originalPost: originalPost,
|
||||
repliedPost: repliedPost,
|
||||
forwardedPost: forwardedPost,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Publisher profile picture
|
||||
GestureDetector(
|
||||
child: ProfilePictureWidget(
|
||||
fileId: state.currentPublisher.value?.picture?.id,
|
||||
radius: 20,
|
||||
fallbackIcon:
|
||||
state.currentPublisher.value == null
|
||||
? Symbols.question_mark
|
||||
: null,
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => const PublisherModal(),
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
state.currentPublisher.value = value;
|
||||
}
|
||||
});
|
||||
},
|
||||
).padding(top: 8),
|
||||
|
||||
// Post content form
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: state.titleController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postTitle'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.titleMedium,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
TextField(
|
||||
controller: state.descriptionController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'postDescription'.tr(),
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.fromLTRB(
|
||||
8,
|
||||
4,
|
||||
8,
|
||||
12,
|
||||
),
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
TextField(
|
||||
controller: state.contentController,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText: 'postContent'.tr(),
|
||||
isCollapsed: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 8,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
onTapOutside:
|
||||
(_) =>
|
||||
FocusManager.instance.primaryFocus
|
||||
?.unfocus(),
|
||||
),
|
||||
|
||||
const Gap(8),
|
||||
|
||||
// Attachments preview
|
||||
if (state.attachments.value.isNotEmpty)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = isWideScreen(context);
|
||||
return isWide
|
||||
? buildWideAttachmentGrid()
|
||||
: buildNarrowAttachmentList();
|
||||
},
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom toolbar
|
||||
SizedBox(
|
||||
height: 65,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(8),
|
||||
bottomRight: Radius.circular(8),
|
||||
),
|
||||
child: ComposeToolbar(
|
||||
state: state,
|
||||
originalPost: originalPost,
|
||||
isCompact: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBanner(BuildContext context) {
|
||||
final effectiveRepliedPost =
|
||||
initialState?.replyingTo ?? originalPost?.repliedPost;
|
||||
final effectiveForwardedPost =
|
||||
initialState?.forwardingTo ?? originalPost?.forwardedPost;
|
||||
|
||||
// Show editing banner when editing a post
|
||||
if (originalPost != null) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.edit,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'postEditing'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
),
|
||||
// Show reply/forward banners below editing banner if they exist
|
||||
if (effectiveRepliedPost != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.reply, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postReplyingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveRepliedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
),
|
||||
if (effectiveForwardedPost != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.forward, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postForwardingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveForwardedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Show banner for replies
|
||||
if (effectiveRepliedPost != null) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.reply, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postReplyingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveRepliedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
);
|
||||
}
|
||||
|
||||
// Show banner for forwards
|
||||
if (effectiveForwardedPost != null) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Symbols.forward, size: 16),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postForwardingTo'.tr(),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
_buildCompactReferencePost(context, effectiveForwardedPost),
|
||||
],
|
||||
).padding(all: 16),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
Widget _buildCompactReferencePost(BuildContext context, SnPost post) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder:
|
||||
(context) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
maxChildSize: 0.9,
|
||||
minChildSize: 0.5,
|
||||
builder:
|
||||
(context, scrollController) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: PostItem(item: post),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ProfilePictureWidget(
|
||||
fileId: post.publisher.picture?.id,
|
||||
radius: 16,
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
post.publisher.nick,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (post.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
post.title!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (post.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
post.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (post.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.attach_file,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'postHasAttachments'.plural(post.attachments.length),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Symbols.open_in_full,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
205
lib/widgets/post/compose_dialog.dart
Normal file
205
lib/widgets/post/compose_dialog.dart
Normal file
@@ -0,0 +1,205 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/models/post.dart';
|
||||
import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/services/responsive.dart';
|
||||
import 'package:island/widgets/post/compose_card.dart';
|
||||
|
||||
/// A dialog that wraps PostComposeCard for easy use in dialogs.
|
||||
/// This provides a convenient way to show the compose interface in a modal dialog.
|
||||
class PostComposeDialog extends HookConsumerWidget {
|
||||
final SnPost? originalPost;
|
||||
final PostComposeInitialState? initialState;
|
||||
final bool isBottomSheet;
|
||||
|
||||
const PostComposeDialog({
|
||||
super.key,
|
||||
this.originalPost,
|
||||
this.initialState,
|
||||
this.isBottomSheet = false,
|
||||
});
|
||||
|
||||
static Future<SnPost?> show(
|
||||
BuildContext context, {
|
||||
SnPost? originalPost,
|
||||
PostComposeInitialState? initialState,
|
||||
}) {
|
||||
return showDialog<SnPost>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: PostComposeDialog(
|
||||
originalPost: originalPost,
|
||||
initialState: initialState,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final drafts = ref.watch(composeStorageNotifierProvider);
|
||||
final restoredInitialState = useState<PostComposeInitialState?>(null);
|
||||
final prompted = useState(false);
|
||||
final isWide = isWideScreen(context);
|
||||
|
||||
useEffect(() {
|
||||
if (!prompted.value && originalPost == null && drafts.isNotEmpty) {
|
||||
prompted.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showRestoreDialog(ref, restoredInitialState);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [drafts, prompted.value]);
|
||||
|
||||
return Dialog(
|
||||
insetPadding: isWide ? const EdgeInsets.all(16) : EdgeInsets.zero,
|
||||
child: ConstrainedBox(
|
||||
constraints:
|
||||
isWide
|
||||
? const BoxConstraints(maxWidth: 600)
|
||||
: const BoxConstraints.expand(),
|
||||
child: PostComposeCard(
|
||||
originalPost: originalPost,
|
||||
initialState: restoredInitialState.value ?? initialState,
|
||||
onCancel: () => Navigator.of(context).pop(),
|
||||
onSubmit: (post) => Navigator.of(context).pop(post),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRestoreDialog(
|
||||
WidgetRef ref,
|
||||
ValueNotifier<PostComposeInitialState?> restoredInitialState,
|
||||
) async {
|
||||
final drafts = ref.read(composeStorageNotifierProvider);
|
||||
if (drafts.isNotEmpty) {
|
||||
final latestDraft = drafts.values.last;
|
||||
|
||||
final restore = await showDialog<bool>(
|
||||
context: ref.context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: const Text('Restore Draft'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('A draft was found. Do you want to restore it?'),
|
||||
const SizedBox(height: 16),
|
||||
_buildCompactDraftPreview(context, latestDraft),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (restore == true) {
|
||||
// Delete the old draft
|
||||
await ref
|
||||
.read(composeStorageNotifierProvider.notifier)
|
||||
.deleteDraft(latestDraft.id);
|
||||
restoredInitialState.value = PostComposeInitialState(
|
||||
title: latestDraft.title,
|
||||
description: latestDraft.description,
|
||||
content: latestDraft.content,
|
||||
visibility: latestDraft.visibility,
|
||||
attachments:
|
||||
latestDraft.attachments
|
||||
.map((e) => UniversalFile.fromAttachment(e))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCompactDraftPreview(BuildContext context, SnPost draft) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Draft',
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (draft.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.title!,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.content?.isNotEmpty ?? false)
|
||||
Text(
|
||||
draft.content!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (draft.attachments.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.attach_file,
|
||||
size: 12,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${draft.attachments.length} attachment${draft.attachments.length > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@@ -1,6 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@@ -9,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/services/time.dart';
|
||||
import 'package:island/talker.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -66,7 +65,7 @@ class ComposeRecorder extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
return () {
|
||||
// Called when widget is unmounted
|
||||
log('[Recorder] Clean up!');
|
||||
talker.info('[Recorder] Clean up!');
|
||||
originalAmplitude?.cancel();
|
||||
amplitudeStream.close();
|
||||
record.dispose();
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -12,9 +14,8 @@ import 'package:island/models/post.dart';
|
||||
import 'package:island/models/post_category.dart';
|
||||
import 'package:island/models/publisher.dart';
|
||||
import 'package:island/models/realm.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/post/compose_link_attachments.dart';
|
||||
@@ -23,8 +24,7 @@ import 'package:island/widgets/post/compose_recorder.dart';
|
||||
import 'package:island/pods/file_pool.dart';
|
||||
import 'package:pasteboard/pasteboard.dart';
|
||||
import 'package:textfield_tags/textfield_tags.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:island/talker.dart';
|
||||
|
||||
class ComposeState {
|
||||
final TextEditingController titleController;
|
||||
@@ -176,25 +176,14 @@ class ComposeLogic {
|
||||
|
||||
try {
|
||||
// Upload any local attachments first
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
|
||||
for (int i = 0; i < state.attachments.value.length; i++) {
|
||||
final attachment = state.attachments.value[i];
|
||||
if (attachment.data is! SnCloudFile) {
|
||||
try {
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: ref.read(apiClientProvider),
|
||||
fileData: attachment,
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
filename:
|
||||
attachment.data.name ??
|
||||
(state.postType == 1 ? 'Article media' : 'Post media'),
|
||||
mimetype:
|
||||
attachment.data.mimeType ??
|
||||
ComposeLogic.getMimeTypeFromFileType(attachment.type),
|
||||
).future;
|
||||
if (cloudFile != null) {
|
||||
// Update attachments list with cloud file
|
||||
@@ -203,7 +192,7 @@ class ComposeLogic {
|
||||
state.attachments.value = clone;
|
||||
}
|
||||
} catch (err) {
|
||||
log('[ComposeLogic] Failed to upload attachment: $err');
|
||||
talker.error('[ComposeLogic] Failed to upload attachment: $err');
|
||||
// Continue with other attachments even if one fails
|
||||
}
|
||||
}
|
||||
@@ -263,7 +252,7 @@ class ComposeLogic {
|
||||
|
||||
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
log('[ComposeLogic] Failed to save draft, error: $e');
|
||||
talker.error('[ComposeLogic] Failed to save draft, error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,7 +325,9 @@ class ComposeLogic {
|
||||
|
||||
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
|
||||
} catch (e) {
|
||||
log('[ComposeLogic] Failed to save draft without upload, error: $e');
|
||||
talker.error(
|
||||
'[ComposeLogic] Failed to save draft without upload, error: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,7 +343,7 @@ class ComposeLogic {
|
||||
showSnackBar('draftSaved'.tr());
|
||||
}
|
||||
} catch (e) {
|
||||
log('[ComposeLogic] Failed to save draft manually, error: $e');
|
||||
talker.error('[ComposeLogic] Failed to save draft manually, error: $e');
|
||||
if (context.mounted) {
|
||||
showSnackBar('draftSaveFailed'.tr());
|
||||
}
|
||||
@@ -506,15 +497,11 @@ class ComposeLogic {
|
||||
WidgetRef ref,
|
||||
ComposeState state,
|
||||
int index, {
|
||||
String? poolId, // For Unit Test
|
||||
String? poolId,
|
||||
}) async {
|
||||
final attachment = state.attachments.value[index];
|
||||
if (attachment.isOnCloud) return;
|
||||
|
||||
final baseUrl = ref.watch(serverUrlProvider);
|
||||
final token = await getToken(ref.watch(tokenProvider));
|
||||
if (token == null) throw ArgumentError('Token is null');
|
||||
|
||||
try {
|
||||
state.attachmentProgress.value = {
|
||||
...state.attachmentProgress.value,
|
||||
@@ -527,19 +514,10 @@ class ComposeLogic {
|
||||
final selectedPoolId = resolveDefaultPoolId(ref, pools);
|
||||
|
||||
cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: ref.read(apiClientProvider),
|
||||
fileData: attachment,
|
||||
atk: token,
|
||||
baseUrl: baseUrl,
|
||||
poolId: selectedPoolId,
|
||||
filename:
|
||||
attachment.data.name ??
|
||||
(attachment.type == UniversalFileType.file
|
||||
? 'General file'
|
||||
: 'Post media'),
|
||||
mimetype:
|
||||
attachment.data.mimeType ??
|
||||
getMimeTypeFromFileType(attachment.type),
|
||||
poolId: poolId ?? selectedPoolId,
|
||||
mode:
|
||||
attachment.type == UniversalFileType.file
|
||||
? FileUploadMode.generic
|
||||
@@ -560,7 +538,7 @@ class ComposeLogic {
|
||||
clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
|
||||
state.attachments.value = clone;
|
||||
} catch (err) {
|
||||
showErrorAlert(err.toString());
|
||||
showErrorAlert(err);
|
||||
} finally {
|
||||
state.attachmentProgress.value = {...state.attachmentProgress.value}
|
||||
..remove(index);
|
||||
|
@@ -12,8 +12,14 @@ import 'package:styled_widget/styled_widget.dart';
|
||||
class ComposeToolbar extends HookConsumerWidget {
|
||||
final ComposeState state;
|
||||
final SnPost? originalPost;
|
||||
final bool isCompact;
|
||||
|
||||
const ComposeToolbar({super.key, required this.state, this.originalPost});
|
||||
const ComposeToolbar({
|
||||
super.key,
|
||||
required this.state,
|
||||
this.originalPost,
|
||||
this.isCompact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -49,6 +55,7 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder: (context) => ComposeEmbedSheet(state: state),
|
||||
);
|
||||
}
|
||||
@@ -57,6 +64,7 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useRootNavigator: true,
|
||||
builder:
|
||||
(context) => DraftManagerSheet(
|
||||
onDraftSelected: (draftId) {
|
||||
@@ -74,6 +82,160 @@ class ComposeToolbar extends HookConsumerWidget {
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
if (isCompact) {
|
||||
return Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 560),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: pickPhotoMedia,
|
||||
tooltip: 'addPhoto'.tr(),
|
||||
icon: const Icon(Symbols.add_a_photo),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickVideoMedia,
|
||||
tooltip: 'addVideo'.tr(),
|
||||
icon: const Icon(Symbols.videocam),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: addAudio,
|
||||
tooltip: 'addAudio'.tr(),
|
||||
icon: const Icon(Symbols.mic),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: pickGeneralFile,
|
||||
tooltip: 'uploadFile'.tr(),
|
||||
icon: const Icon(Symbols.file_upload),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: linkAttachment,
|
||||
icon: const Icon(Symbols.attach_file),
|
||||
tooltip: 'linkAttachment'.tr(),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
),
|
||||
// Poll button with visual state when a poll is linked
|
||||
ListenableBuilder(
|
||||
listenable: state.pollId,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: pickPoll,
|
||||
icon: const Icon(Symbols.how_to_vote),
|
||||
tooltip: 'poll'.tr(),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.pollId.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Embed button with visual state when embed is present
|
||||
ListenableBuilder(
|
||||
listenable: state.embedView,
|
||||
builder: (context, _) {
|
||||
return IconButton(
|
||||
onPressed: showEmbedSheet,
|
||||
icon: const Icon(Symbols.iframe),
|
||||
tooltip: 'embedView'.tr(),
|
||||
color: colorScheme.primary,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -2,
|
||||
),
|
||||
style: ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
state.embedView.value != null
|
||||
? colorScheme.primary.withOpacity(0.15)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (originalPost == null && state.isEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.draft, size: 20),
|
||||
color: colorScheme.primary,
|
||||
onPressed: showDraftManager,
|
||||
tooltip: 'drafts'.tr(),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
)
|
||||
else if (originalPost == null)
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.save, size: 20),
|
||||
color: colorScheme.primary,
|
||||
onPressed: saveDraft,
|
||||
onLongPress: showDraftManager,
|
||||
tooltip: 'saveDraft'.tr(),
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -4,
|
||||
vertical: -4,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 8, vertical: 4),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
elevation: 4,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
|
@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/services/compose_storage_db.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@@ -118,25 +119,9 @@ class DraftManagerSheet extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder:
|
||||
(context) => AlertDialog(
|
||||
title: Text('clearAllDrafts'.tr()),
|
||||
content: Text('clearAllDraftsConfirm'.tr()),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => Navigator.of(context).pop(false),
|
||||
child: Text('cancel'.tr()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed:
|
||||
() => Navigator.of(context).pop(true),
|
||||
child: Text('confirm'.tr()),
|
||||
),
|
||||
],
|
||||
),
|
||||
final confirmed = await showConfirmAlert(
|
||||
'clearAllDraftsConfirm'.tr(),
|
||||
'clearAllDrafts'.tr(),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
|
@@ -102,72 +102,75 @@ class PostFeaturedList extends HookConsumerWidget {
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
const Icon(Symbols.highlight),
|
||||
const Text('highlightPost').tr(),
|
||||
Spacer(),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
pageViewController.animateToPage(
|
||||
pageViewCurrent.value - 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.arrow_left),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
pageViewController.animateToPage(
|
||||
pageViewCurrent.value + 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.arrow_right),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
debugPrint(
|
||||
'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}',
|
||||
);
|
||||
if (isCollapsed.value &&
|
||||
featuredPostsAsync.hasValue &&
|
||||
featuredPostsAsync.value!.isNotEmpty) {
|
||||
prefs.setString(
|
||||
kFeaturedPostsCollapsedId,
|
||||
featuredPostsAsync.value!.first.id,
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
const Icon(Symbols.highlight),
|
||||
const Text('highlightPost').tr(),
|
||||
Spacer(),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
pageViewController.animateToPage(
|
||||
pageViewCurrent.value - 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
|
||||
);
|
||||
} else {
|
||||
prefs.remove(kFeaturedPostsCollapsedId);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Removed stored collapsed ID.',
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
isCollapsed.value
|
||||
? Symbols.expand_more
|
||||
: Symbols.expand_less,
|
||||
},
|
||||
icon: const Icon(Symbols.arrow_left),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
pageViewController.animateToPage(
|
||||
pageViewCurrent.value + 1,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Symbols.arrow_right),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
debugPrint(
|
||||
'PostFeaturedList: Manual toggle. isCollapsed set to ${isCollapsed.value}',
|
||||
);
|
||||
if (isCollapsed.value &&
|
||||
featuredPostsAsync.hasValue &&
|
||||
featuredPostsAsync.value!.isNotEmpty) {
|
||||
prefs.setString(
|
||||
kFeaturedPostsCollapsedId,
|
||||
featuredPostsAsync.value!.first.id,
|
||||
);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}',
|
||||
);
|
||||
} else {
|
||||
prefs.remove(kFeaturedPostsCollapsedId);
|
||||
debugPrint(
|
||||
'PostFeaturedList: Removed stored collapsed ID.',
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
isCollapsed.value
|
||||
? Symbols.expand_more
|
||||
: Symbols.expand_less,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 16, vertical: 8),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
|
@@ -24,6 +24,7 @@ import 'package:island/widgets/post/post_shared.dart';
|
||||
import 'package:island/widgets/post/embed_view_renderer.dart';
|
||||
import 'package:island/widgets/safety/abuse_report_helper.dart';
|
||||
import 'package:island/widgets/share/share_sheet.dart';
|
||||
import 'package:island/widgets/post/compose_dialog.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
|
||||
import 'package:screenshot/screenshot.dart';
|
||||
@@ -174,14 +175,14 @@ class PostActionableItem extends HookConsumerWidget {
|
||||
MenuAction(
|
||||
title: 'edit'.tr(),
|
||||
image: MenuImage.icon(Symbols.edit),
|
||||
callback: () {
|
||||
context
|
||||
.pushNamed('postEdit', pathParameters: {'id': item.id})
|
||||
.then((value) {
|
||||
if (value != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
});
|
||||
callback: () async {
|
||||
final result = await PostComposeDialog.show(
|
||||
context,
|
||||
originalPost: item,
|
||||
);
|
||||
if (result != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isAuthor)
|
||||
@@ -221,21 +222,27 @@ class PostActionableItem extends HookConsumerWidget {
|
||||
MenuAction(
|
||||
title: 'reply'.tr(),
|
||||
image: MenuImage.icon(Symbols.reply),
|
||||
callback: () {
|
||||
context.pushNamed(
|
||||
'postCompose',
|
||||
extra: PostComposeInitialState(replyingTo: item),
|
||||
callback: () async {
|
||||
final result = await PostComposeDialog.show(
|
||||
context,
|
||||
initialState: PostComposeInitialState(replyingTo: item),
|
||||
);
|
||||
if (result != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
MenuAction(
|
||||
title: 'forward'.tr(),
|
||||
image: MenuImage.icon(Symbols.forward),
|
||||
callback: () {
|
||||
context.pushNamed(
|
||||
'postCompose',
|
||||
extra: PostComposeInitialState(forwardingTo: item),
|
||||
callback: () async {
|
||||
final result = await PostComposeDialog.show(
|
||||
context,
|
||||
initialState: PostComposeInitialState(forwardingTo: item),
|
||||
);
|
||||
if (result != null) {
|
||||
onRefresh?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (isAuthor && item.pinMode == null)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -80,9 +81,8 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
title: 'copyLink'.tr(),
|
||||
image: MenuImage.icon(Symbols.link),
|
||||
callback: () {
|
||||
context.pushNamed(
|
||||
'postDetail',
|
||||
pathParameters: {'id': item.id},
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: 'https://solian.app/posts/${item.id}'),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -95,7 +95,7 @@ class PostItemCreator extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
if (isOpenable) {
|
||||
context.goNamed('postDetail', pathParameters: {'id': item.id});
|
||||
context.pushNamed('postDetail', pathParameters: {'id': item.id});
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
|
@@ -1,8 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:island/services/file_uploader.dart';
|
||||
import 'package:island/widgets/alert.dart';
|
||||
import 'package:island/widgets/content/sheet.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -10,11 +12,7 @@ import 'package:island/screens/posts/compose.dart';
|
||||
import 'package:island/models/file.dart';
|
||||
import 'package:island/pods/link_preview.dart';
|
||||
import 'package:island/pods/network.dart';
|
||||
import 'package:island/pods/config.dart';
|
||||
import 'package:island/services/file.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:island/models/chat.dart';
|
||||
import 'package:island/screens/chat/chat.dart';
|
||||
@@ -192,7 +190,6 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final serverUrl = ref.read(serverUrlProvider);
|
||||
|
||||
String content = _messageController.text.trim();
|
||||
List<String> attachmentIds = [];
|
||||
@@ -216,11 +213,6 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
case ShareContentType.file:
|
||||
// Upload files to cloud storage
|
||||
if (widget.content.files?.isNotEmpty == true) {
|
||||
final token = ref.watch(tokenProvider)?.token;
|
||||
if (token == null) {
|
||||
throw Exception('Authentication required');
|
||||
}
|
||||
|
||||
final universalFiles =
|
||||
widget.content.files!.map((file) {
|
||||
UniversalFileType fileType;
|
||||
@@ -247,19 +239,9 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
|
||||
for (var idx = 0; idx < universalFiles.length; idx++) {
|
||||
final file = universalFiles[idx];
|
||||
final cloudFile =
|
||||
await putFileToCloud(
|
||||
await FileUploader.createCloudFile(
|
||||
client: apiClient,
|
||||
fileData: file,
|
||||
atk: token,
|
||||
baseUrl: serverUrl,
|
||||
filename: file.data.name ?? 'Shared file',
|
||||
mimetype:
|
||||
file.data.mimeType ??
|
||||
switch (file.type) {
|
||||
UniversalFileType.image => 'image/unknown',
|
||||
UniversalFileType.video => 'video/unknown',
|
||||
UniversalFileType.audio => 'audio/unknown',
|
||||
UniversalFileType.file => 'application/octet-stream',
|
||||
},
|
||||
onProgress: (progress, _) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user