Compare commits

..

45 Commits

Author SHA1 Message Date
53d51b8a0e Plain text rendering 2025-10-02 02:15:48 +08:00
337ae39e08 PDF rendering 2025-10-02 02:10:45 +08:00
8fe3a664a6 ♻️ Better file upload 2025-10-02 01:13:41 +08:00
3bfc0b8181 ♻️ Refactor the bottom nav display 2025-10-01 16:35:41 +08:00
ac2951479b App links 2025-10-01 14:55:04 +08:00
2bfd13d843 🐛 Fixes bugs and optimization
 Add app links
2025-10-01 12:41:48 +08:00
28db6f9f01 Better windows notification 2025-10-01 12:28:23 +08:00
a4f7b8415d 💄 Optimize the draft manager clear hint 2025-09-30 00:14:23 +08:00
2255d3d591 ♻️ Better draft, post saving and auto restore hint 2025-09-30 00:04:51 +08:00
97792ae734 💄 Localized language picker 2025-09-29 23:10:21 +08:00
a5d13250cc 🍱 Update translations 2025-09-29 23:10:10 +08:00
de9e235d0c ♻️ Dialog based editor for normal post 2025-09-29 01:16:32 +08:00
56fb5451cd 💄 Optimize explore compose region 2025-09-29 00:34:30 +08:00
870de961f5 💄 Optimize border radius 2025-09-28 23:09:59 +08:00
22bf6d1c33 💄 Redesign explore 2025-09-28 23:07:22 +08:00
5b62f89531 🐛 Fix web file upload 2025-09-28 01:53:55 +08:00
b1326d8f04 🐛 Dozens of bug fixes 2025-09-28 01:39:07 +08:00
fffca4a78c ♻️ Refactor logger system 2025-09-28 00:39:17 +08:00
42bd7f97cb Add talker and upgrade deps 2025-09-27 23:40:08 +08:00
6377856ae0 💄 Optimize online indicator 2025-09-27 23:01:42 +08:00
0f1c52b9e3 🐛 Fix subscribe 2025-09-27 23:00:04 +08:00
6ed6f60fbc 💄 New chat UI 2025-09-27 22:59:16 +08:00
e65a414065 🐛 Fixes and improvements in syncing 2025-09-27 22:47:31 +08:00
214d5c4a53 Online indicator in chat 2025-09-27 22:17:29 +08:00
fe33931304 Split force update and check for update 2025-09-27 21:49:13 +08:00
113309257e Proper windows update 2025-09-27 21:33:12 +08:00
b95a8b2ed2 Merge branch 'v3' of https://git.solsynth.dev/SolarNetwork/App into v3 2025-09-27 21:25:10 +08:00
LittleSheep
e922971a5e 🔀 Merge pull request #180 from liang-work/appchangetest
[Feature] Able to change theme mode not following System Theme
2025-09-27 21:17:39 +08:00
9d5b71bead 💄 Optimize chat indicator style 2025-09-27 21:17:14 +08:00
890efa2efb upload i18n update file. 2025-09-27 20:55:04 +08:00
674097e425 git commit
Upload code that can run
2025-09-27 20:26:37 +08:00
3379dcb7f3 Dynamic chat online counter basis 2025-09-27 19:25:24 +08:00
eb5a849e1f 💄 Optimize title bar for windows and linux 2025-09-27 18:45:58 +08:00
4981a23e8e Chat summary realtime updates 2025-09-27 17:07:19 +08:00
c64d4bacb6 📝 Update CODE_OF_CONDUCT 2025-09-27 17:05:21 +08:00
838d18013b 🐛 Fix message deletion 2025-09-27 16:54:23 +08:00
3f7902e463 🐛 Fix post detail award button 2025-09-27 16:34:50 +08:00
54560ad5d8 🐛 Fix some bugs in attachment upload sheet 2025-09-27 15:51:26 +08:00
0c729db639 🐛 Fix native window 2025-09-27 15:33:42 +08:00
1fbaac8d88 💄 Optimize chat input a step further 2025-09-27 15:31:57 +08:00
b9dc724f0b 🐛 Fix chat newline on desktop 2025-09-27 00:22:43 +08:00
a2cc55696f Transparent window on desktop 2025-09-27 00:04:04 +08:00
e79f857feb ♻️ Replace bitsdojo_window with window_manager 2025-09-26 23:26:40 +08:00
affba29c04 🐛 Fix IRC style display the message time wrong 2025-09-26 22:30:46 +08:00
756746b144 🍱 Update translation keys 2025-09-24 23:31:47 +08:00
112 changed files with 11744 additions and 4901 deletions

View File

@@ -14,13 +14,13 @@ The backend of the Solar Network is written in Go and is a microservices app. Th
## Commit Messages ## 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 All the commit message should follow `:[gitmoji]: <commit message>` syntax
## Translations & Localization ## 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 ## 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 ## 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! We appreciate every single commit you contributed. Let's work together and create a better Solar Network!

View File

@@ -51,6 +51,12 @@
<data android:scheme="http" android:host="solian.app" /> <data android:scheme="http" android:host="solian.app" />
<data android:scheme="https" /> <data android:scheme="https" />
</intent-filter> </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 --> <!-- Share Intent Filters -->
<intent-filter> <intent-filter>

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

@@ -116,16 +116,12 @@
}, },
"postHasAttachments": { "postHasAttachments": {
"one": "{} 個附件", "one": "{} 個附件",
"other": "{}個附件" "other": "{} 個附件"
}, },
"edited": "已編輯", "edited": "已編輯",
"addVideo": "添加視頻", "addVideo": "添加視頻",
"addPhoto": "添加照片", "addPhoto": "添加照片",
"addFile": "添加文件", "addFile": "添加文件",
"uploadFile": "上傳文件",
"settingsDefaultPool": "選擇文件池",
"settingsDefaultPoolHelper": "爲文件上傳選擇一個默認池",
"createDirectMessage": "創建新私人消息", "createDirectMessage": "創建新私人消息",
"gotoDirectMessage": "前往私信", "gotoDirectMessage": "前往私信",
"react": "反應", "react": "反應",
@@ -307,8 +303,7 @@
"notifications": "通知", "notifications": "通知",
"posts": "帖子", "posts": "帖子",
"settingsBackgroundImage": "背景圖片", "settingsBackgroundImage": "背景圖片",
"settingsBackgroundImageEnable": "顯示背景圖片", "settingsBackgroundImageClear": "清除背景圖片",
"settingsBackgroundImageClear": "清除背景圖片",
"settingsBackgroundGenerateColor": "從背景圖像生成主題色", "settingsBackgroundGenerateColor": "從背景圖像生成主題色",
"messageNone": "沒有內容可顯示", "messageNone": "沒有內容可顯示",
"unreadMessages": { "unreadMessages": {
@@ -319,8 +314,6 @@
"settingsRealmCompactView": "緊湊領域視圖", "settingsRealmCompactView": "緊湊領域視圖",
"settingsMixedFeed": "混合動態", "settingsMixedFeed": "混合動態",
"settingsAutoTranslate": "自動翻譯", "settingsAutoTranslate": "自動翻譯",
"settingsDataSavingMode": "低數據模式",
"dataSavingHint": "低數據模式",
"settingsHideBottomNav": "隱藏底部導航", "settingsHideBottomNav": "隱藏底部導航",
"settingsSoundEffects": "音效", "settingsSoundEffects": "音效",
"settingsAprilFoolFeatures": "愚人節功能", "settingsAprilFoolFeatures": "愚人節功能",
@@ -675,7 +668,6 @@
"publisherFeatureDevelopDescription": "為你的開發者解鎖包括應用套件API 及更多開發功能。", "publisherFeatureDevelopDescription": "為你的開發者解鎖包括應用套件API 及更多開發功能。",
"publisherFeatureDevelopHint": "目前該功能還在開發中,你需要邀請才可解鎖。", "publisherFeatureDevelopHint": "目前該功能還在開發中,你需要邀請才可解鎖。",
"learnMore": "瞭解更多", "learnMore": "瞭解更多",
"discoverWebArticles": "來自站外的文章",
"webArticlesStand": "文章亭", "webArticlesStand": "文章亭",
"about": "關於", "about": "關於",
"somethingWentWrong": "發生了一些錯誤", "somethingWentWrong": "發生了一些錯誤",
@@ -698,8 +690,6 @@
"sharePostPhoto": "通過圖片分享帖子", "sharePostPhoto": "通過圖片分享帖子",
"wouldYouLikeToNavigateToChat": "你想要前往聊天頁面嗎?", "wouldYouLikeToNavigateToChat": "你想要前往聊天頁面嗎?",
"abuseReports": "舉報", "abuseReports": "舉報",
"discoverRealms": "發現領域",
"discoverPublishers": "發現發佈者",
"membershipCancel": "取消會員訂閱", "membershipCancel": "取消會員訂閱",
"membershipCancelConfirm": "你確定要取消會員訂閱嗎?", "membershipCancelConfirm": "你確定要取消會員訂閱嗎?",
"membershipCancelHint": "你確定要取消會員訂閱嗎?你將不會再次被扣費。你的會員資格將在當前計費週期結束前保持有效。並且你將無法重新訂閱,直到當前訂閱結束。", "membershipCancelHint": "你確定要取消會員訂閱嗎?你將不會再次被扣費。你的會員資格將在當前計費週期結束前保持有效。並且你將無法重新訂閱,直到當前訂閱結束。",
@@ -819,6 +809,159 @@
"one": "+{} 個文件被摺疊", "one": "+{} 個文件被摺疊",
"other": "+{} 個文件被摺疊" "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": "應用程式詳情", "appDetails": "應用程式詳情",
"secrets": "密鑰", "secrets": "密鑰",
"appNotFound": "找不到應用程式。", "appNotFound": "找不到應用程式。",
@@ -830,5 +973,107 @@
"newSecretGenerated": "已產生新密鑰", "newSecretGenerated": "已產生新密鑰",
"copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。", "copySecretHint": "請複製此密鑰並將其存放在安全的地方。您將無法再次看到它。",
"expiresIn": "過期時間(秒)", "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": "上傳"
}

View File

@@ -1,5 +1,7 @@
PODS: PODS:
- Alamofire (5.10.2) - Alamofire (5.10.2)
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- croppy (0.0.1): - croppy (0.0.1):
@@ -293,6 +295,8 @@ PODS:
- super_native_extensions (0.0.1): - super_native_extensions (0.0.1):
- Flutter - Flutter
- SwiftyGif (5.4.5) - SwiftyGif (5.4.5)
- syncfusion_flutter_pdfviewer (0.0.1):
- Flutter
- url_launcher_ios (0.0.1): - url_launcher_ios (0.0.1):
- Flutter - Flutter
- volume_controller (0.0.1): - volume_controller (0.0.1):
@@ -303,6 +307,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- Alamofire - Alamofire
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`) - croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@@ -344,6 +349,7 @@ DEPENDENCIES:
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`) - 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`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
@@ -379,6 +385,8 @@ SPEC REPOS:
- WebRTC-SDK - WebRTC-SDK
EXTERNAL SOURCES: EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus: connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
croppy: croppy:
@@ -459,6 +467,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
super_native_extensions: super_native_extensions:
:path: ".symlinks/plugins/super_native_extensions/ios" :path: ".symlinks/plugins/super_native_extensions/ios"
syncfusion_flutter_pdfviewer:
:path: ".symlinks/plugins/syncfusion_flutter_pdfviewer/ios"
url_launcher_ios: url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios" :path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller: volume_controller:
@@ -468,6 +478,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496 Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30 croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@@ -533,6 +544,7 @@ SPEC CHECKSUMS:
sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1 sqlite3_flutter_libs: 83f8e9f5b6554077f1d93119fe20ebaa5f3a9ef1
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4 super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
syncfusion_flutter_pdfviewer: 90dc48305d2e33d4aa20681d1e98ddeda891bc14
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12
wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556

View File

@@ -36,6 +36,14 @@
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>CFBundleURLSchemes</key>
<array>
<string>solian</string>
</array>
</dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>

View File

@@ -1,6 +1,4 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection; import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:firebase_core/firebase_core.dart'; 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:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker_android/image_picker_android.dart'; import 'package:image_picker_android/image_picker_android.dart';
import 'package:island/talker.dart';
import 'package:island/firebase_options.dart'; import 'package:island/firebase_options.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/theme.dart'; import 'package:island/pods/theme.dart';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.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:shared_preferences/shared_preferences.dart';
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
import 'package:flutter_native_splash/flutter_native_splash.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:url_launcher/url_launcher_string.dart';
import 'package:window_manager/window_manager.dart';
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
log('Handling a background message: ${message.messageId}'); talker.info('Handling a background message: ${message.messageId}');
} }
void main() async { void main() async {
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
log( talker.info(
"[SplashScreen] Keeping the flash screen to loading other resources...", "[SplashScreen] Keeping the flash screen to loading other resources...",
); );
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
@@ -73,48 +73,59 @@ void main() async {
} }
} }
log("[SplashScreen] Firebase is ready!"); talker.info("[SplashScreen] Firebase is ready!");
} catch (err) { } catch (err) {
showErrorAlert(err); showErrorAlert(err);
} }
try { try {
log("[SplashScreen] Loading timezone database..."); talker.info("[SplashScreen] Loading timezone database...");
await initializeTzdb(); await initializeTzdb();
log("[SplashScreen] Time zone database was loaded!"); talker.info("[SplashScreen] Time zone database was loaded!");
} catch (err) { } catch (err) {
log("[SplashScreen] Failed to load timezone database... $err"); talker.error("[SplashScreen] Failed to load timezone database... $err");
} }
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) { if (!kIsWeb && (Platform.isMacOS || Platform.isLinux || Platform.isWindows)) {
doWhenWindowReady(() { await windowManager.ensureInitialized();
const defaultSize = Size(360, 640);
// Get saved window size from preferences const defaultSize = Size(360, 640);
final savedSizeString = prefs.getString(kAppWindowSize);
Size initialSize = defaultSize;
if (savedSizeString != null) { // Get saved window size from preferences
try { final savedSizeString = prefs.getString(kAppWindowSize);
final parts = savedSizeString.split(','); Size initialSize = defaultSize;
if (parts.length == 2) {
final width = double.parse(parts[0]); if (savedSizeString != null) {
final height = double.parse(parts[1]); try {
initialSize = Size(width, height); final parts = savedSizeString.split(',');
} if (parts.length == 2) {
} catch (e) { final width = double.parse(parts[0]);
log("[SplashScreen] Failed to parse saved window size: $e"); final height = double.parse(parts[1]);
initialSize = defaultSize; initialSize = Size(width, height);
} }
} catch (e) {
talker.error("[SplashScreen] Failed to parse saved window size: $e");
initialSize = defaultSize;
} }
}
appWindow.minSize = defaultSize; WindowOptions windowOptions = WindowOptions(
appWindow.size = initialSize; size: initialSize,
appWindow.alignment = Alignment.center; center: true,
appWindow.show(); backgroundColor: Colors.transparent,
log( 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}", "[SplashScreen] Desktop window is ready with size: ${initialSize.width}x${initialSize.height}",
); );
}); });
@@ -126,16 +137,27 @@ void main() async {
if (imagePickerImplementation is ImagePickerAndroid) { if (imagePickerImplementation is ImagePickerAndroid) {
imagePickerImplementation.useAndroidPhotoPicker = true; imagePickerImplementation.useAndroidPhotoPicker = true;
} }
log("[SplashScreen] Android image picker is ready!"); talker.info("[SplashScreen] Android image picker is ready!");
} }
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) { if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
FlutterNativeSplash.remove(); FlutterNativeSplash.remove();
log("[SplashScreen] Now hiding the splash screen..."); talker.info("[SplashScreen] Now hiding the splash screen...");
} }
runApp( runApp(
ProviderScope( ProviderScope(
observers: [
TalkerRiverpodObserver(
talker: talker,
settings: TalkerRiverpodLoggerSettings(
printProviderAdded: false,
printProviderDisposed: false,
printProviderUpdated: false,
printStateFullData: false,
),
),
],
overrides: [sharedPreferencesProvider.overrideWithValue(prefs)], overrides: [sharedPreferencesProvider.overrideWithValue(prefs)],
child: Directionality( child: Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
@@ -144,6 +166,10 @@ void main() async {
Locale('en', 'US'), Locale('en', 'US'),
Locale('zh', 'CN'), Locale('zh', 'CN'),
Locale('zh', 'TW'), Locale('zh', 'TW'),
Locale('zh', 'OG'),
Locale('ja', 'JP'),
Locale('ko', 'KR'),
Locale('es', 'ES'),
], ],
path: 'assets/i18n', path: 'assets/i18n',
fallbackLocale: Locale('en', 'US'), fallbackLocale: Locale('en', 'US'),
@@ -165,6 +191,21 @@ class IslandApp extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = ref.watch(themeProvider); 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) { void handleMessage(RemoteMessage notification) {
if (notification.data['meta']?['action_uri'] != null) { if (notification.data['meta']?['action_uri'] != null) {
@@ -201,7 +242,9 @@ class IslandApp extends HookConsumerWidget {
final onMessageSubscription = FirebaseMessaging.onMessage.listen(( final onMessageSubscription = FirebaseMessaging.onMessage.listen((
message, message,
) { ) {
log('Foreground message received: ${message.messageId}'); talker.info(
'[Notification] foreground message received: ${message.messageId}',
);
handleMessage(message); handleMessage(message);
}); });
@@ -215,7 +258,7 @@ class IslandApp extends HookConsumerWidget {
// Load userinfo // Load userinfo
final userNotifier = ref.read(userInfoProvider.notifier); final userNotifier = ref.read(userInfoProvider.notifier);
ref.listen(websocketStateProvider, (_, state) { ref.listen(websocketStateProvider, (_, state) {
log('[WebSocket] $state'); talker.info('[WebSocket] $state');
}); });
Future(() { Future(() {
userNotifier.fetchUser().then((_) { userNotifier.fetchUser().then((_) {
@@ -235,9 +278,10 @@ class IslandApp extends HookConsumerWidget {
final router = ref.watch(routerProvider); final router = ref.watch(routerProvider);
return MaterialApp.router( return MaterialApp.router(
color: Colors.transparent,
theme: theme?.light, theme: theme?.light,
darkTheme: theme?.dark, darkTheme: theme?.dark,
themeMode: ThemeMode.system, themeMode: getThemeMode(),
routerConfig: router, routerConfig: router,
supportedLocales: context.supportedLocales, supportedLocales: context.supportedLocales,
scrollBehavior: AppScrollBehavior(), scrollBehavior: AppScrollBehavior(),
@@ -252,9 +296,15 @@ class IslandApp extends HookConsumerWidget {
key: globalOverlay, key: globalOverlay,
initialEntries: [ initialEntries: [
OverlayEntry( OverlayEntry(
builder: builder: (_) {
(_) => return TalkerWrapper(
WindowScaffold(child: child ?? const SizedBox.shrink()), talker: talker,
options: const TalkerWrapperOptions(enableErrorAlerts: true),
child: WindowScaffold(
child: child ?? const SizedBox.shrink(),
),
);
},
), ),
], ],
); );

View File

@@ -14,6 +14,7 @@ sealed class UniversalFile with _$UniversalFile {
required dynamic data, required dynamic data,
required UniversalFileType type, required UniversalFileType type,
@Default(false) bool isLink, @Default(false) bool isLink,
String? displayName,
}) = _UniversalFile; }) = _UniversalFile;
factory UniversalFile.fromJson(Map<String, dynamic> json) => factory UniversalFile.fromJson(Map<String, dynamic> json) =>
@@ -31,6 +32,7 @@ sealed class UniversalFile with _$UniversalFile {
'video' => UniversalFileType.video, 'video' => UniversalFileType.video,
_ => UniversalFileType.file, _ => UniversalFileType.file,
}, },
displayName: attachment.name,
); );
} }
} }

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$UniversalFile { 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 /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -28,16 +28,16 @@ $UniversalFileCopyWith<UniversalFile> get copyWith => _$UniversalFileCopyWithImp
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory $UniversalFileCopyWith(UniversalFile value, $Res Function(UniversalFile) _then) = _$UniversalFileCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable 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 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 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) { switch (_that) {
case _UniversalFile() when $default != null: 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(); 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) { switch (_that) {
case _UniversalFile(): 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` /// 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) { switch (_that) {
case _UniversalFile() when $default != null: 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; return null;
} }
@@ -205,12 +206,13 @@ return $default(_that.data,_that.type,_that.isLink);case _:
@JsonSerializable() @JsonSerializable()
class _UniversalFile extends UniversalFile { 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); factory _UniversalFile.fromJson(Map<String, dynamic> json) => _$UniversalFileFromJson(json);
@override final dynamic data; @override final dynamic data;
@override final UniversalFileType type; @override final UniversalFileType type;
@override@JsonKey() final bool isLink; @override@JsonKey() final bool isLink;
@override final String? displayName;
/// Create a copy of UniversalFile /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -225,16 +227,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { 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) @JsonKey(includeFromJson: false, includeToJson: false)
@override @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 @override
String toString() { 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; factory _$UniversalFileCopyWith(_UniversalFile value, $Res Function(_UniversalFile) _then) = __$UniversalFileCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of UniversalFile
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_UniversalFile(
data: freezed == data ? _self.data : data // ignore: cast_nullable_to_non_nullable 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 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 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?,
)); ));
} }

View File

@@ -11,6 +11,7 @@ _UniversalFile _$UniversalFileFromJson(Map<String, dynamic> json) =>
data: json['data'], data: json['data'],
type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']), type: $enumDecode(_$UniversalFileTypeEnumMap, json['type']),
isLink: json['is_link'] as bool? ?? false, isLink: json['is_link'] as bool? ?? false,
displayName: json['display_name'] as String?,
); );
Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) => Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
@@ -18,6 +19,7 @@ Map<String, dynamic> _$UniversalFileToJson(_UniversalFile instance) =>
'data': instance.data, 'data': instance.data,
'type': _$UniversalFileTypeEnumMap[instance.type]!, 'type': _$UniversalFileTypeEnumMap[instance.type]!,
'is_link': instance.isLink, 'is_link': instance.isLink,
'display_name': instance.displayName,
}; };
const _$UniversalFileTypeEnumMap = { const _$UniversalFileTypeEnumMap = {

View File

@@ -1,11 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/account/status.dart'; import 'package:island/widgets/account/status.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf/shelf_io.dart' as shelf_io;
@@ -69,13 +69,13 @@ class ActivityRpcServer {
// Start WebSocket server // Start WebSocket server
while (port <= portRange[1]) { while (port <= portRange[1]) {
developer.log('Trying port $port', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Trying port $port');
try { try {
_httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port); _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 { shelf_io.serveRequests(_httpServer!, (Request request) async {
developer.log('New request', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] New request');
if (request.headers['upgrade']?.toLowerCase() == 'websocket') { if (request.headers['upgrade']?.toLowerCase() == 'websocket') {
final handler = webSocketHandler((WebSocketChannel channel, _) { final handler = webSocketHandler((WebSocketChannel channel, _) {
_wsSockets.add(channel); _wsSockets.add(channel);
@@ -83,19 +83,16 @@ class ActivityRpcServer {
}); });
return handler(request); return handler(request);
} }
developer.log( talker.log('New request disposed due to not websocket');
'New request disposed due to not websocket',
name: kRpcLogPrefix,
);
return Response.notFound('Not a WebSocket request'); return Response.notFound('Not a WebSocket request');
}); });
wsSuccess = true; wsSuccess = true;
break; break;
} catch (e) { } catch (e) {
if (e is SocketException && e.osError?.errorCode == 98) { if (e is SocketException && e.osError?.errorCode == 98) {
developer.log('$port in use!', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] $port in use!');
} else { } else {
developer.log('HTTP error: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] HTTP error: $e');
} }
port++; port++;
await Future.delayed(Duration(milliseconds: 100)); // Add delay await Future.delayed(Duration(milliseconds: 100)); // Add delay
@@ -121,13 +118,10 @@ class ActivityRpcServer {
await _ipcServer!.start(); await _ipcServer!.start();
} catch (e) { } catch (e) {
developer.log('IPC server error: $e', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC server error: $e');
} }
} else { } else {
developer.log( talker.log('IPC server disabled on macOS or web in production mode');
'IPC server disabled on macOS or web in production mode',
name: kRpcIpcLogPrefix,
);
} }
} }
@@ -138,7 +132,7 @@ class ActivityRpcServer {
try { try {
await socket.sink.close(); await socket.sink.close();
} catch (e) { } catch (e) {
developer.log('Error closing WebSocket: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Error closing WebSocket: $e');
} }
} }
_wsSockets.clear(); _wsSockets.clear();
@@ -147,7 +141,7 @@ class ActivityRpcServer {
// Stop IPC server // Stop IPC server
await _ipcServer?.stop(); await _ipcServer?.stop();
developer.log('Servers stopped', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Servers stopped');
} }
// Handle new WebSocket connection // Handle new WebSocket connection
@@ -159,10 +153,7 @@ class ActivityRpcServer {
final clientId = params['client_id'] ?? ''; final clientId = params['client_id'] ?? '';
final origin = request.headers['origin'] ?? ''; final origin = request.headers['origin'] ?? '';
developer.log( talker.log('New WS connection! origin: $origin, params: $params');
'New WS connection! origin: $origin, params: $params',
name: kRpcLogPrefix,
);
if (origin.isNotEmpty && if (origin.isNotEmpty &&
![ ![
@@ -170,22 +161,19 @@ class ActivityRpcServer {
'https://ptb.discord.com', 'https://ptb.discord.com',
'https://canary.discord.com', 'https://canary.discord.com',
].contains(origin)) { ].contains(origin)) {
developer.log('Disallowed origin: $origin', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Disallowed origin: $origin');
socket.sink.close(); socket.sink.close();
return; return;
} }
if (encoding != 'json') { if (encoding != 'json') {
developer.log( talker.log('Unsupported encoding requested: $encoding');
'Unsupported encoding requested: $encoding',
name: kRpcLogPrefix,
);
socket.sink.close(); socket.sink.close();
return; return;
} }
if (ver != 1) { if (ver != 1) {
developer.log('Unsupported version requested: $ver', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] Unsupported version requested: $ver');
socket.sink.close(); socket.sink.close();
return; return;
} }
@@ -195,10 +183,10 @@ class ActivityRpcServer {
socket.stream.listen( socket.stream.listen(
(data) => _onWsMessage(socketWithMeta, data), (data) => _onWsMessage(socketWithMeta, data),
onError: (e) { onError: (e) {
developer.log('WS socket error: $e', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS socket error: $e');
}, },
onDone: () { onDone: () {
developer.log('WS socket closed', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS socket closed');
handlers['close']?.call(socketWithMeta); handlers['close']?.call(socketWithMeta);
_wsSockets.remove(socket); _wsSockets.remove(socket);
}, },
@@ -210,25 +198,19 @@ class ActivityRpcServer {
// Handle incoming WebSocket message // Handle incoming WebSocket message
Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async { Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async {
if (data is! String) { if (data is! String) {
developer.log( talker.log('Invalid WebSocket message: not a string');
'Invalid WebSocket message: not a string',
name: kRpcLogPrefix,
);
return; return;
} }
try { try {
final jsonData = await compute(jsonDecode, data); final jsonData = await compute(jsonDecode, data);
if (jsonData is! Map<String, dynamic>) { if (jsonData is! Map<String, dynamic>) {
developer.log( talker.log('Invalid WebSocket message: not a JSON object');
'Invalid WebSocket message: not a JSON object',
name: kRpcLogPrefix,
);
return; return;
} }
developer.log('WS message: $jsonData', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS message: $jsonData');
handlers['message']?.call(socket, jsonData); handlers['message']?.call(socket, jsonData);
} catch (e) { } 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) { void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) {
switch (packet.type) { switch (packet.type) {
case IpcTypes.ping: case IpcTypes.ping:
developer.log('IPC ping received', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC ping received');
socket.sendPong(packet.data); socket.sendPong(packet.data);
break; break;
case IpcTypes.pong: case IpcTypes.pong:
developer.log('IPC pong received', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC pong received');
break; break;
case IpcTypes.handshake: case IpcTypes.handshake:
@@ -256,7 +238,7 @@ class ActivityRpcServer {
if (!socket.handshook) { if (!socket.handshook) {
throw Exception('Need to handshake first'); 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); handlers['message']?.call(socket, packet.data);
break; break;
@@ -271,22 +253,19 @@ class ActivityRpcServer {
// Handle IPC handshake // Handle IPC handshake
void _onIpcHandshake(IpcSocketWrapper socket, Map<String, dynamic> params) { 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 ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1;
final clientId = params['client_id']?.toString() ?? ''; final clientId = params['client_id']?.toString() ?? '';
if (ver != 1) { if (ver != 1) {
developer.log( talker.log('IPC unsupported version requested: $ver');
'IPC unsupported version requested: $ver',
name: kRpcIpcLogPrefix,
);
socket.closeWithCode(IpcErrorCodes.invalidVersion); socket.closeWithCode(IpcErrorCodes.invalidVersion);
return; return;
} }
if (clientId.isEmpty) { if (clientId.isEmpty) {
developer.log('IPC client ID required', name: kRpcIpcLogPrefix); talker.log('[$kRpcLogPrefix] IPC client ID required');
socket.closeWithCode(IpcErrorCodes.invalidClientId); socket.closeWithCode(IpcErrorCodes.invalidClientId);
return; return;
} }
@@ -306,7 +285,7 @@ class _WsSocketWrapper {
_WsSocketWrapper(this.channel, this.clientId, this.encoding); _WsSocketWrapper(this.channel, this.clientId, this.encoding);
void send(Map<String, dynamic> msg) { void send(Map<String, dynamic> msg) {
developer.log('WS sending: $msg', name: kRpcLogPrefix); talker.log('[$kRpcLogPrefix] WS sending: $msg');
channel.sink.add(jsonEncode(msg)); channel.sink.add(jsonEncode(msg));
} }
} }
@@ -398,12 +377,7 @@ final rpcServerStateProvider =
final appId = socket.clientId; final appId = socket.clientId;
final meta = data['args']['activity']; final meta = data['args']['activity'];
try { try {
await setRemoteActivityStatus( await setRemoteActivityStatus(ref, label, appId, meta);
ref,
label,
appId,
meta,
);
final now = DateTime.now(); final now = DateTime.now();
final status = SnAccountStatus( final status = SnAccountStatus(
id: 'local_$appId', id: 'local_$appId',
@@ -422,10 +396,7 @@ final rpcServerStateProvider =
); );
ref.read(currentAccountStatusProvider.notifier).setStatus(status); ref.read(currentAccountStatusProvider.notifier).setStatus(status);
} catch (e) { } catch (e) {
developer.log( talker.log('Failed to set remote activity status: $e');
'Failed to set remote activity status: $e',
name: kRpcLogPrefix,
);
} }
socket.send({ socket.send({
'cmd': 'SET_ACTIVITY', 'cmd': 'SET_ACTIVITY',
@@ -442,10 +413,7 @@ final rpcServerStateProvider =
await unsetRemoteActivityStatus(ref, appId); await unsetRemoteActivityStatus(ref, appId);
ref.read(currentAccountStatusProvider.notifier).clearStatus(); ref.read(currentAccountStatusProvider.notifier).clearStatus();
} catch (e) { } catch (e) {
developer.log( talker.log('Failed to unset remote activity status: $e');
'Failed to unset remote activity status: $e',
name: kRpcLogPrefix,
);
} }
}, },
}); });

View File

@@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io'; import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:dart_ipc/dart_ipc.dart'; import 'package:dart_ipc/dart_ipc.dart';
import 'package:island/talker.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
const String kRpcIpcLogPrefix = 'arRPC.ipc'; const String kRpcIpcLogPrefix = 'arRPC.ipc';
@@ -128,23 +128,18 @@ class MultiPlatformIpcServer extends IpcServer {
@override @override
Future<void> start() async { Future<void> start() async {
try { try {
final ipcPath = Platform.isWindows final ipcPath =
? r'\\.\pipe\discord-ipc-0' Platform.isWindows
: await _findAvailableUnixIpcPath(); ? r'\\.\pipe\discord-ipc-0'
: await _findAvailableUnixIpcPath();
final serverSocket = await bind(ipcPath); final serverSocket = await bind(ipcPath);
developer.log( talker.log('IPC listening at $ipcPath');
'IPC listening at $ipcPath',
name: kRpcIpcLogPrefix,
);
_serverSubscription = serverSocket.listen((socket) { _serverSubscription = serverSocket.listen((socket) {
final socketWrapper = MultiPlatformIpcSocketWrapper(socket); final socketWrapper = MultiPlatformIpcSocketWrapper(socket);
addSocket(socketWrapper); addSocket(socketWrapper);
developer.log( talker.log('New IPC connection!');
'New IPC connection!',
name: kRpcIpcLogPrefix,
);
_handleIpcData(socketWrapper); _handleIpcData(socketWrapper);
}); });
} catch (e) { } catch (e) {
@@ -158,7 +153,7 @@ class MultiPlatformIpcServer extends IpcServer {
try { try {
socket.close(); socket.close();
} catch (e) { } catch (e) {
developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix); talker.log('Error closing IPC socket: $e');
} }
} }
sockets.clear(); sockets.clear();
@@ -168,31 +163,30 @@ class MultiPlatformIpcServer extends IpcServer {
// Handle incoming IPC data // Handle incoming IPC data
void _handleIpcData(MultiPlatformIpcSocketWrapper socket) { void _handleIpcData(MultiPlatformIpcSocketWrapper socket) {
final startTime = DateTime.now(); final startTime = DateTime.now();
socket.socket.listen((data) { socket.socket.listen(
final readStart = DateTime.now(); (data) {
socket.addData(data); final readStart = DateTime.now();
final readDuration = DateTime.now().difference(readStart).inMicroseconds; socket.addData(data);
developer.log( final readDuration =
'Read data took $readDuration microseconds', DateTime.now().difference(readStart).inMicroseconds;
name: kRpcIpcLogPrefix, talker.log('Read data took $readDuration microseconds');
);
final packets = socket.readPackets(); final packets = socket.readPackets();
for (final packet in packets) { for (final packet in packets) {
handlePacket?.call(socket, packet, {}); handlePacket?.call(socket, packet, {});
} }
}, onDone: () { },
developer.log('IPC connection closed', name: kRpcIpcLogPrefix); onDone: () {
socket.close(); talker.log('IPC connection closed');
}, onError: (e) { socket.close();
developer.log('IPC data error: $e', name: kRpcIpcLogPrefix); },
socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString()); onError: (e) {
}); talker.log('IPC data error: $e');
final totalDuration = DateTime.now().difference(startTime).inMicroseconds; socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString());
developer.log( },
'_handleIpcData took $totalDuration microseconds',
name: kRpcIpcLogPrefix,
); );
final totalDuration = DateTime.now().difference(startTime).inMicroseconds;
talker.log('_handleIpcData took $totalDuration microseconds');
} }
Future<String> _getMacOsSystemTmpDir() async { Future<String> _getMacOsSystemTmpDir() async {
@@ -212,10 +206,7 @@ class MultiPlatformIpcServer extends IpcServer {
baseDirs.add(macTempDir); baseDirs.add(macTempDir);
} }
} catch (e) { } catch (e) {
developer.log( talker.log('Failed to get macOS system temp dir: $e');
'Failed to get macOS system temp dir: $e',
name: kRpcIpcLogPrefix,
);
} }
} }
@@ -241,17 +232,11 @@ class MultiPlatformIpcServer extends IpcServer {
try { try {
await File(socketPath).delete(); await File(socketPath).delete();
} catch (_) {} } catch (_) {}
developer.log( talker.log('IPC socket will be created at: $socketPath');
'IPC socket will be created at: $socketPath',
name: kRpcIpcLogPrefix,
);
return socketPath; return socketPath;
} catch (e) { } catch (e) {
if (i == 0) { if (i == 0) {
developer.log( talker.log('IPC path $socketPath not available: $e');
'IPC path $socketPath not available: $e',
name: kRpcIpcLogPrefix,
);
} }
continue; continue;
} }
@@ -271,7 +256,7 @@ class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper {
@override @override
void send(Map<String, dynamic> msg) { 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); final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg);
socket.add(packet); socket.add(packet);
} }

View File

@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_webrtc/flutter_webrtc.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/pods/network.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:wakelock_plus/wakelock_plus.dart'; import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:island/talker.dart';
part 'call.g.dart'; part 'call.g.dart';
part 'call.freezed.dart'; part 'call.freezed.dart';
@@ -212,7 +212,7 @@ class CallNotifier extends _$CallNotifier {
Future<void> joinRoom(String roomId) async { Future<void> joinRoom(String roomId) async {
if (_roomId == roomId && _room != null) { if (_roomId == roomId && _room != null) {
log('[Call] Call skipped. Already has data'); talker.info('[Call] Call skipped. Already has data');
return; return;
} else if (_room != null) { } else if (_room != null) {
if (!_room!.isDisposed && if (!_room!.isDisposed &&

View File

@@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'eb9bd41b97e9b5e9d54007c8327edb6567458846'; String _$callNotifierHash() => r'd374402e51d331cf40724e51fd86bce3c5504776';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

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

View 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

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

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

View 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

View File

@@ -1,6 +1,8 @@
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/pods/network.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'; part 'chat_summary.g.dart';
@@ -12,9 +14,27 @@ class ChatSummary extends _$ChatSummary {
final resp = await client.get('/sphere/chat/summary'); final resp = await client.get('/sphere/chat/summary');
final Map<String, dynamic> data = resp.data; final Map<String, dynamic> data = resp.data;
return data.map( final summaries = data.map(
(key, value) => MapEntry(key, SnChatSummary.fromJson(value)), (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 { Future<void> clearUnreadCount(String chatId) async {
@@ -36,10 +56,12 @@ class ChatSummary extends _$ChatSummary {
state.whenData((summaries) { state.whenData((summaries) {
final summary = summaries[chatId]; final summary = summaries[chatId];
if (summary != null) { if (summary != null) {
final currentSubscribed = ref.read(currentSubscribedChatIdProvider);
final increment = (chatId != currentSubscribed) ? 1 : 0;
state = AsyncData({ state = AsyncData({
...summaries, ...summaries,
chatId: SnChatSummary( chatId: SnChatSummary(
unreadCount: summary.unreadCount + 1, unreadCount: summary.unreadCount + increment,
lastMessage: message, 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,
),
});
}
});
}
} }

View File

@@ -6,7 +6,7 @@ part of 'chat_summary.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$chatSummaryHash() => r'87a10e4cefa37dc5fa8eadb175ef1b2bed6070bf'; String _$chatSummaryHash() => r'33815a3bd81d20902b7063e8194fe336930df9b4';
/// See also [ChatSummary]. /// See also [ChatSummary].
@ProviderFor(ChatSummary) @ProviderFor(ChatSummary)

View File

@@ -1,5 +1,4 @@
import "dart:async"; import "dart:async";
import "dart:developer" as developer;
import "package:dio/dio.dart"; import "package:dio/dio.dart";
import "package:drift/drift.dart" show Variable; import "package:drift/drift.dart" show Variable;
import "package:easy_localization/easy_localization.dart"; 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/database/message.dart";
import "package:island/models/chat.dart"; import "package:island/models/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/pods/config.dart";
import "package:island/pods/database.dart"; import "package:island/pods/database.dart";
import "package:island/pods/lifecycle.dart";
import "package:island/pods/network.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:island/widgets/alert.dart";
import "package:riverpod_annotation/riverpod_annotation.dart"; import "package:riverpod_annotation/riverpod_annotation.dart";
import "package:uuid/uuid.dart"; import "package:uuid/uuid.dart";
import "package:island/screens/chat/chat.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'; part 'messages_notifier.g.dart';
@@ -39,6 +39,7 @@ class MessagesNotifier extends _$MessagesNotifier {
bool _hasMore = true; bool _hasMore = true;
bool _isSyncing = false; bool _isSyncing = false;
bool _isJumping = false; bool _isJumping = false;
DateTime? _lastPauseTime;
@override @override
FutureOr<List<LocalChatMessage>> build(String roomId) async { FutureOr<List<LocalChatMessage>> build(String roomId) async {
@@ -58,21 +59,27 @@ class MessagesNotifier extends _$MessagesNotifier {
_identity = identity; _identity = identity;
} }
developer.log( talker.log('MessagesNotifier built for room $roomId');
'MessagesNotifier built for room $roomId',
name: 'MessagesNotifier',
);
// Only setup sync and lifecycle listeners if user is a member // Only setup sync and lifecycle listeners if user is a member
if (identity != null) { if (identity != null) {
ref.listen(appLifecycleStateProvider, (_, next) { ref.listen(appLifecycleStateProvider, (_, next) {
if (next.hasValue && next.value == AppLifecycleState.resumed) { next.whenData((state) {
developer.log( if (state == AppLifecycleState.paused) {
'App resumed, syncing messages', _lastPauseTime = DateTime.now();
name: 'MessagesNotifier', talker.log('App paused, recording time');
); } else if (state == AppLifecycleState.resumed) {
syncMessages(); 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 offset = 0,
int take = 20, int take = 20,
}) async { }) async {
developer.log( talker.log('Getting cached messages from offset $offset, take $take');
'Getting cached messages from offset $offset, take $take',
name: 'MessagesNotifier',
);
final List<LocalChatMessage> dbMessages; final List<LocalChatMessage> dbMessages;
if (_searchQuery != null && _searchQuery!.isNotEmpty) { if (_searchQuery != null && _searchQuery!.isNotEmpty) {
dbMessages = await _database.searchMessages( dbMessages = await _database.searchMessages(
@@ -154,10 +158,7 @@ class MessagesNotifier extends _$MessagesNotifier {
int offset = 0, int offset = 0,
int take = 20, int take = 20,
}) async { }) async {
developer.log( talker.log('Fetching messages from API, offset $offset, take $take');
'Fetching messages from API, offset $offset, take $take',
name: 'MessagesNotifier',
);
if (_totalCount == null) { if (_totalCount == null) {
final response = await _apiClient.get( final response = await _apiClient.get(
'/sphere/chat/$_roomId/messages', '/sphere/chat/$_roomId/messages',
@@ -201,15 +202,12 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> syncMessages() async { Future<void> syncMessages() async {
if (_isSyncing) { if (_isSyncing) {
developer.log( talker.log('Sync already in progress, skipping.');
'Sync already in progress, skipping.',
name: 'MessagesNotifier',
);
return; return;
} }
_isSyncing = true; _isSyncing = true;
developer.log('Starting message sync', name: 'MessagesNotifier'); talker.log('Starting message sync');
Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true); Future.microtask(() => ref.read(isSyncingProvider.notifier).state = true);
try { try {
final dbMessages = await _database.getMessagesForRoom( final dbMessages = await _database.getMessagesForRoom(
@@ -223,10 +221,7 @@ class MessagesNotifier extends _$MessagesNotifier {
: _database.companionToMessage(dbMessages.first); : _database.companionToMessage(dbMessages.first);
if (lastMessage == null) { if (lastMessage == null) {
developer.log( talker.log('No local messages, fetching from network');
'No local messages, fetching from network',
name: 'MessagesNotifier',
);
final newMessages = await _fetchAndCacheMessages( final newMessages = await _fetchAndCacheMessages(
offset: 0, offset: 0,
take: _pageSize, take: _pageSize,
@@ -244,10 +239,7 @@ class MessagesNotifier extends _$MessagesNotifier {
); );
final response = MessageSyncResponse.fromJson(resp.data); final response = MessageSyncResponse.fromJson(resp.data);
developer.log( talker.log('Sync response: ${response.messages.length} changes');
'Sync response: ${response.messages.length} changes',
name: 'MessagesNotifier',
);
for (final message in response.messages) { for (final message in response.messages) {
switch (message.type) { switch (message.type) {
case "messages.update": case "messages.update":
@@ -262,15 +254,14 @@ class MessagesNotifier extends _$MessagesNotifier {
await receiveMessage(message); await receiveMessage(message);
} }
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log( talker.log(
'Error syncing messages', 'Error syncing messages',
name: 'MessagesNotifier', exception: err,
error: err,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
} finally { } finally {
developer.log('Finished message sync', name: 'MessagesNotifier'); talker.log('Finished message sync');
Future.microtask( Future.microtask(
() => ref.read(isSyncingProvider.notifier).state = false, () => ref.read(isSyncingProvider.notifier).state = false,
); );
@@ -320,7 +311,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> loadInitial() async { Future<void> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier'); talker.log('Loading initial messages');
if (_searchQuery == null || _searchQuery!.isEmpty) { if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages(); syncMessages();
} }
@@ -334,7 +325,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> loadMore() async { Future<void> loadMore() async {
if (!_hasMore || state is AsyncLoading) return; if (!_hasMore || state is AsyncLoading) return;
developer.log('Loading more messages', name: 'MessagesNotifier'); talker.log('Loading more messages');
try { try {
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
@@ -350,10 +341,10 @@ class MessagesNotifier extends _$MessagesNotifier {
_sortMessages([...currentMessages, ...newMessages]), _sortMessages([...currentMessages, ...newMessages]),
); );
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log( talker.log(
'Error loading more messages', 'Error loading more messages',
name: 'MessagesNotifier',
error: err, exception: err,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
@@ -369,13 +360,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Function(String, Map<int, double>)? onProgress, Function(String, Map<int, double>)? onProgress,
}) async { }) async {
final nonce = const Uuid().v4(); final nonce = const Uuid().v4();
developer.log( talker.log('Sending message with nonce $nonce');
'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');
final mockMessage = SnChatMessage( final mockMessage = SnChatMessage(
id: 'pending_$nonce', id: 'pending_$nonce',
@@ -404,19 +389,9 @@ class MessagesNotifier extends _$MessagesNotifier {
var cloudAttachments = List.empty(growable: true); var cloudAttachments = List.empty(growable: true);
for (var idx = 0; idx < attachments.length; idx++) { for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: attachments[idx], fileData: attachments[idx],
atk: token, client: ref.read(apiClientProvider),
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',
},
onProgress: (progress, _) { onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress; _fileUploadProgress[localMessage.id]?[idx] = progress;
onProgress?.call( onProgress?.call(
@@ -476,15 +451,12 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
} }
developer.log( talker.log('Message with nonce $nonce sent successfully');
'Message with nonce $nonce sent successfully',
name: 'MessagesNotifier',
);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log( talker.log(
'Failed to send message with nonce $nonce', 'Failed to send message with nonce $nonce',
name: 'MessagesNotifier',
error: e, exception: e,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
localMessage.status = MessageStatus.failed; localMessage.status = MessageStatus.failed;
@@ -506,10 +478,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> retryMessage(String pendingMessageId) async { Future<void> retryMessage(String pendingMessageId) async {
developer.log( talker.log('Retrying message $pendingMessageId');
'Retrying message $pendingMessageId',
name: 'MessagesNotifier',
);
final message = await fetchMessageById(pendingMessageId); final message = await fetchMessageById(pendingMessageId);
if (message == null) { if (message == null) {
throw Exception('Message not found'); throw Exception('Message not found');
@@ -553,10 +522,10 @@ class MessagesNotifier extends _$MessagesNotifier {
}).toList(); }).toList();
state = AsyncValue.data(newMessages); state = AsyncValue.data(newMessages);
} catch (e, stackTrace) { } catch (e, stackTrace) {
developer.log( talker.log(
'Failed to retry message $pendingMessageId', 'Failed to retry message $pendingMessageId',
name: 'MessagesNotifier',
error: e, exception: e,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
message.status = MessageStatus.failed; message.status = MessageStatus.failed;
@@ -579,10 +548,7 @@ class MessagesNotifier extends _$MessagesNotifier {
Future<void> receiveMessage(SnChatMessage remoteMessage) async { Future<void> receiveMessage(SnChatMessage remoteMessage) async {
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log( talker.log('Received new message ${remoteMessage.id}');
'Received new message ${remoteMessage.id}',
name: 'MessagesNotifier',
);
final localMessage = LocalChatMessage.fromRemoteMessage( final localMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage,
@@ -613,17 +579,28 @@ class MessagesNotifier extends _$MessagesNotifier {
_sortMessages([localMessage, ...currentMessages]), _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 { Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
if (remoteMessage.chatRoomId != _roomId) return; if (remoteMessage.chatRoomId != _roomId) return;
developer.log( talker.log('Received message update ${remoteMessage.id}');
'Received message update ${remoteMessage.id}',
name: 'MessagesNotifier',
);
final targetId = remoteMessage.meta['message_id'] ?? remoteMessage.id;
final updatedMessage = LocalChatMessage.fromRemoteMessage( final updatedMessage = LocalChatMessage.fromRemoteMessage(
remoteMessage, remoteMessage.copyWith(
id: targetId,
meta: Map.of(remoteMessage.meta)..remove('message_id'),
),
MessageStatus.sent, MessageStatus.sent,
); );
await _database.updateMessage(_database.messageToCompanion(updatedMessage)); await _database.updateMessage(_database.messageToCompanion(updatedMessage));
@@ -639,10 +616,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> receiveMessageDeletion(String messageId) async { Future<void> receiveMessageDeletion(String messageId) async {
developer.log( talker.log('Received message deletion $messageId');
'Received message deletion $messageId',
name: 'MessagesNotifier',
);
_pendingMessages.remove(messageId); _pendingMessages.remove(messageId);
final currentMessages = state.value ?? []; final currentMessages = state.value ?? [];
@@ -679,15 +653,15 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<void> deleteMessage(String messageId) async { Future<void> deleteMessage(String messageId) async {
developer.log('Deleting message $messageId', name: 'MessagesNotifier'); talker.log('Deleting message $messageId');
try { try {
await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId'); await _apiClient.delete('/sphere/chat/$_roomId/messages/$messageId');
await receiveMessageDeletion(messageId); await receiveMessageDeletion(messageId);
} catch (err, stackTrace) { } catch (err, stackTrace) {
developer.log( talker.log(
'Error deleting message $messageId', 'Error deleting message $messageId',
name: 'MessagesNotifier',
error: err, exception: err,
stackTrace: stackTrace, stackTrace: stackTrace,
); );
showErrorAlert(err); showErrorAlert(err);
@@ -709,10 +683,7 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<LocalChatMessage?> fetchMessageById(String messageId) async { Future<LocalChatMessage?> fetchMessageById(String messageId) async {
developer.log( talker.log('Fetching message by id $messageId');
'Fetching message by id $messageId',
name: 'MessagesNotifier',
);
try { try {
final localMessage = final localMessage =
await (_database.select(_database.chatMessages) await (_database.select(_database.chatMessages)
@@ -739,24 +710,18 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
Future<int> jumpToMessage(String messageId) async { Future<int> jumpToMessage(String messageId) async {
developer.log( talker.log('Starting jump to message $messageId');
'Starting jump to message $messageId',
name: 'MessagesNotifier',
);
if (_isJumping) { if (_isJumping) {
developer.log( talker.log('Jump already in progress, skipping');
'Jump already in progress, skipping',
name: 'MessagesNotifier',
);
return -1; return -1;
} }
_isJumping = true; _isJumping = true;
try { try {
developer.log('Fetching message $messageId', name: 'MessagesNotifier'); talker.log('Fetching message $messageId');
final message = await fetchMessageById(messageId); final message = await fetchMessageById(messageId);
if (message == null) { if (message == null) {
developer.log('Message $messageId not found', name: 'MessagesNotifier'); talker.log('Message $messageId not found');
showSnackBar('messageNotFound'.tr()); showSnackBar('messageNotFound'.tr());
return -1; return -1;
} }
@@ -767,16 +732,14 @@ class MessagesNotifier extends _$MessagesNotifier {
(m) => m.id == messageId, (m) => m.id == messageId,
); );
if (existingIndex >= 0) { if (existingIndex >= 0) {
developer.log( talker.log(
'Message $messageId already in current state at index $existingIndex, jumping directly', 'Message $messageId already in current state at index $existingIndex, jumping directly',
name: 'MessagesNotifier',
); );
return existingIndex; return existingIndex;
} }
developer.log( talker.log(
'Message $messageId not in current state, loading messages around it', 'Message $messageId not in current state, loading messages around it',
name: 'MessagesNotifier',
); );
// Count messages newer than this one // Count messages newer than this one
@@ -794,10 +757,7 @@ class MessagesNotifier extends _$MessagesNotifier {
// Load messages around this position // Load messages around this position
final offset = final offset =
(newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt(); (newerCount - _pageSize ~/ 2).clamp(0, double.infinity).toInt();
developer.log( talker.log('Loading messages with offset $offset, take $_pageSize');
'Loading messages with offset $offset, take $_pageSize',
name: 'MessagesNotifier',
);
final loadedMessages = await _getCachedMessages( final loadedMessages = await _getCachedMessages(
offset: offset, offset: offset,
take: _pageSize, take: _pageSize,
@@ -807,9 +767,8 @@ class MessagesNotifier extends _$MessagesNotifier {
final currentIds = currentMessages.map((m) => m.id).toSet(); final currentIds = currentMessages.map((m) => m.id).toSet();
final newMessages = final newMessages =
loadedMessages.where((m) => !currentIds.contains(m.id)).toList(); loadedMessages.where((m) => !currentIds.contains(m.id)).toList();
developer.log( talker.log(
'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new', 'Loaded ${loadedMessages.length} messages, ${newMessages.length} are new',
name: 'MessagesNotifier',
); );
if (newMessages.isNotEmpty) { if (newMessages.isNotEmpty) {
@@ -824,19 +783,15 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
_sortMessages(uniqueMessages); _sortMessages(uniqueMessages);
state = AsyncValue.data(uniqueMessages); state = AsyncValue.data(uniqueMessages);
developer.log( talker.log(
'Updated state with ${uniqueMessages.length} total messages', 'Updated state with ${uniqueMessages.length} total messages',
name: 'MessagesNotifier',
); );
} }
final finalIndex = (state.value ?? []).indexWhere( final finalIndex = (state.value ?? []).indexWhere(
(m) => m.id == messageId, (m) => m.id == messageId,
); );
developer.log( talker.log('Final index for message $messageId is $finalIndex');
'Final index for message $messageId is $finalIndex',
name: 'MessagesNotifier',
);
return finalIndex; return finalIndex;
} finally { } finally {
_isJumping = false; _isJumping = false;

View File

@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$messagesNotifierHash() => r'37bab723531a5c5248471ef810b74cf57b3dc237'; String _$messagesNotifierHash() => r'b0cff44cea9f15a2684b602c48b32cd3d78875ab';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/pods/theme.dart'; import 'package:island/pods/theme.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
part 'config.freezed.dart'; part 'config.freezed.dart';
part 'config.g.dart'; part 'config.g.dart';
@@ -24,9 +25,11 @@ const kAppDataSavingMode = 'app_data_saving_mode';
const kAppSoundEffects = 'app_sound_effects'; const kAppSoundEffects = 'app_sound_effects';
const kAppAprilFoolFeatures = 'app_april_fool_features'; const kAppAprilFoolFeatures = 'app_april_fool_features';
const kAppWindowSize = 'app_window_size'; const kAppWindowSize = 'app_window_size';
const kAppWindowOpacity = 'app_window_opacity';
const kAppEnterToSend = 'app_enter_to_send'; const kAppEnterToSend = 'app_enter_to_send';
const kAppDefaultPoolId = 'app_default_pool_id'; const kAppDefaultPoolId = 'app_default_pool_id';
const kAppMessageDisplayStyle = 'app_message_display_style'; const kAppMessageDisplayStyle = 'app_message_display_style';
const kAppThemeMode = 'app_theme_mode';
const kFeaturedPostsCollapsedId = const kFeaturedPostsCollapsedId =
'featured_posts_collapsed_id'; // Key for storing the ID of the collapsed featured post '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 String? customFonts,
required int? appColorScheme, // The color stored via the int type required int? appColorScheme, // The color stored via the int type
required Size? windowSize, // The window size for desktop platforms required Size? windowSize, // The window size for desktop platforms
required double windowOpacity, // The window opacity for desktop platforms
required String? defaultPoolId, required String? defaultPoolId,
required String messageDisplayStyle, required String messageDisplayStyle,
required String? themeMode,
}) = _AppSettings; }) = _AppSettings;
} }
@@ -88,8 +93,10 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
customFonts: prefs.getString(kAppCustomFonts), customFonts: prefs.getString(kAppCustomFonts),
appColorScheme: prefs.getInt(kAppColorSchemeStoreKey), appColorScheme: prefs.getInt(kAppColorSchemeStoreKey),
windowSize: _getWindowSizeFromPrefs(prefs), windowSize: _getWindowSizeFromPrefs(prefs),
windowOpacity: prefs.getDouble(kAppWindowOpacity) ?? 1.0,
defaultPoolId: prefs.getString(kAppDefaultPoolId), defaultPoolId: prefs.getString(kAppDefaultPoolId),
messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble', messageDisplayStyle: prefs.getString(kAppMessageDisplayStyle) ?? 'bubble',
themeMode: prefs.getString(kAppThemeMode) ?? 'system',
); );
} }
@@ -196,6 +203,19 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
prefs.setString(kAppMessageDisplayStyle, value); prefs.setString(kAppMessageDisplayStyle, value);
state = state.copyWith(messageDisplayStyle: 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 = final updateInfoProvider =

View File

@@ -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 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 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 /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -27,16 +28,16 @@ $AppSettingsCopyWith<AppSettings> get copyWith => _$AppSettingsCopyWithImpl<AppS
@override @override
bool operator ==(Object other) { 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 @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 @override
String toString() { 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; factory $AppSettingsCopyWith(AppSettings value, $Res Function(AppSettings) _then) = _$AppSettingsCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable 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 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 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 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 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?,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) { switch (_that) {
case _AppSettings() when $default != null: 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(); 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) { switch (_that) {
case _AppSettings(): 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` /// 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) { switch (_that) {
case _AppSettings() when $default != null: 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; return null;
} }
@@ -213,7 +216,7 @@ return $default(_that.autoTranslate,_that.dataSavingMode,_that.soundEffects,_tha
class _AppSettings implements AppSettings { 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; @override final bool autoTranslate;
@@ -228,8 +231,11 @@ class _AppSettings implements AppSettings {
// The color stored via the int type // The color stored via the int type
@override final Size? windowSize; @override final Size? windowSize;
// The window size for desktop platforms // The window size for desktop platforms
@override final double windowOpacity;
// The window opacity for desktop platforms
@override final String? defaultPoolId; @override final String? defaultPoolId;
@override final String messageDisplayStyle; @override final String messageDisplayStyle;
@override final String? themeMode;
/// Create a copy of AppSettings /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -241,16 +247,16 @@ _$AppSettingsCopyWith<_AppSettings> get copyWith => __$AppSettingsCopyWithImpl<_
@override @override
bool operator ==(Object other) { 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 @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 @override
String toString() { 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; factory _$AppSettingsCopyWith(_AppSettings value, $Res Function(_AppSettings) _then) = __$AppSettingsCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of AppSettings
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_AppSettings(
autoTranslate: null == autoTranslate ? _self.autoTranslate : autoTranslate // ignore: cast_nullable_to_non_nullable 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 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 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 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 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?,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?,
)); ));
} }

View File

@@ -7,7 +7,7 @@ part of 'config.dart';
// ************************************************************************** // **************************************************************************
String _$appSettingsNotifierHash() => String _$appSettingsNotifierHash() =>
r'9f0979f18b107e61185391e7c39bd81ac4b8ca50'; r'c3042f77067b5f36102f17277e174a756121ac74';
/// See also [AppSettingsNotifier]. /// See also [AppSettingsNotifier].
@ProviderFor(AppSettingsNotifier) @ProviderFor(AppSettingsNotifier)

View File

@@ -2,10 +2,6 @@ import "dart:async";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.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 appLifecycleStateProvider = StreamProvider<AppLifecycleState>((ref) {
final controller = StreamController<AppLifecycleState>(); final controller = StreamController<AppLifecycleState>();

View File

@@ -10,6 +10,8 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:island/models/auth.dart'; import 'package:island/models/auth.dart';
import 'package:shared_preferences/shared_preferences.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'; import 'config.dart';
@@ -75,7 +77,7 @@ final apiClientProvider = Provider<Dio>((ref) {
), ),
); );
dio.interceptors.add( dio.interceptors.addAll([
InterceptorsWrapper( InterceptorsWrapper(
onRequest: ( onRequest: (
RequestOptions options, RequestOptions options,
@@ -97,7 +99,17 @@ final apiClientProvider = Provider<Dio>((ref) {
return handler.next(options); return handler.next(options);
}, },
), ),
); TalkerDioLogger(
talker: talker,
settings: const TalkerDioLoggerSettings(
printRequestHeaders: false,
printResponseHeaders: false,
printResponseMessage: false,
printRequestData: false,
printResponseData: false,
),
),
]);
return dio; return dio;
}); });

View File

@@ -100,10 +100,6 @@ Future<ThemeData> createAppTheme(
opticalSize: 20, opticalSize: 20,
color: colorScheme.onSurface, color: colorScheme.onSurface,
), ),
snackBarTheme: SnackBarThemeData(
behavior: useM3 ? SnackBarBehavior.floating : SnackBarBehavior.fixed,
width: 480,
),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
centerTitle: true, centerTitle: true,
elevation: hasAppBarTransparent ? 0 : null, elevation: hasAppBarTransparent ? 0 : null,

View File

@@ -1,5 +1,4 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'dart:io' show Platform; import 'dart:io' show Platform;
import 'package:dio/dio.dart'; 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/models/account.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> { class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final Ref _ref; final Ref _ref;
@@ -21,7 +21,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
Future<void> fetchUser() async { Future<void> fetchUser() async {
final token = _ref.watch(tokenProvider); final token = _ref.watch(tokenProvider);
if (token == null) { if (token == null) {
log('[UserInfo] No token found, not going to fetch...'); talker.info('[UserInfo] No token found, not going to fetch...');
return; return;
} }
try { try {
@@ -75,11 +75,10 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
} }
}); });
} }
log( talker.error(
"[UserInfo] Failed to fetch user info...", "[UserInfo] Failed to fetch user info...",
name: 'UserInfoNotifier', error,
error: error, stackTrace,
stackTrace: stackTrace,
); );
state = AsyncValue.data(null); state = AsyncValue.data(null);
} }

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.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:island/pods/network.dart';
import 'package:web_socket_channel/io.dart'; import 'package:web_socket_channel/io.dart';
import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:island/talker.dart';
part 'websocket.freezed.dart'; part 'websocket.freezed.dart';
part 'websocket.g.dart'; part 'websocket.g.dart';
@@ -64,7 +64,7 @@ class WebSocketService {
final url = '$baseUrl/ws'.replaceFirst('http', 'ws'); final url = '$baseUrl/ws'.replaceFirst('http', 'ws');
log('[WebSocket] Trying connecting to $url'); talker.info('[WebSocket] Trying connecting to $url');
try { try {
if (kIsWeb) { if (kIsWeb) {
_channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token')); _channel = WebSocketChannel.connect(Uri.parse('$url?tk=$token'));
@@ -88,24 +88,24 @@ class WebSocketService {
return; return;
} }
_streamController.sink.add(packet); _streamController.sink.add(packet);
log( talker.info(
"[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}", "[WebSocket] Received packet: ${packet.type} ${packet.errorMessage}",
); );
if (packet.type == 'pong' && _heartbeatAt != null) { if (packet.type == 'pong' && _heartbeatAt != null) {
var now = DateTime.now(); var now = DateTime.now();
heartbeatDelay = now.difference(_heartbeatAt!); heartbeatDelay = now.difference(_heartbeatAt!);
log( talker.info(
"[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms", "[WebSocket] Server respond last heartbeat for ${heartbeatDelay!.inMilliseconds} ms",
); );
} }
}, },
onDone: () { onDone: () {
log('[WebSocket] Connection closed, attempting to reconnect...'); talker.info('[WebSocket] Connection closed, attempting to reconnect...');
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add(WebSocketState.disconnected()); _statusStreamController.sink.add(WebSocketState.disconnected());
}, },
onError: (error) { onError: (error) {
log('[WebSocket] Error occurred: $error, attempting to reconnect...'); talker.error('[WebSocket] Error occurred: $error, attempting to reconnect...');
_scheduleReconnect(); _scheduleReconnect();
_statusStreamController.sink.add( _statusStreamController.sink.add(
WebSocketState.error(error.toString()), WebSocketState.error(error.toString()),
@@ -113,7 +113,7 @@ class WebSocketService {
}, },
); );
} catch (err) { } catch (err) {
log('[WebSocket] Failed to connect: $err'); talker.error('[WebSocket] Failed to connect: $err');
_scheduleReconnect(); _scheduleReconnect();
} }
} }
@@ -135,7 +135,7 @@ class WebSocketService {
void _beatTheHeart() { void _beatTheHeart() {
_heartbeatAt = DateTime.now(); _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))); sendMessage(jsonEncode(WebSocketPacket(type: 'ping', data: null)));
} }

View File

@@ -64,7 +64,9 @@ import 'package:island/screens/account/event_calendar.dart';
import 'package:island/screens/discovery/realms.dart'; import 'package:island/screens/discovery/realms.dart';
import 'package:island/screens/reports/report_detail.dart'; import 'package:island/screens/reports/report_detail.dart';
import 'package:island/screens/reports/report_list.dart'; import 'package:island/screens/reports/report_list.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/post/post_shuffle.dart'; import 'package:island/widgets/post/post_shuffle.dart';
import 'package:talker_flutter/talker_flutter.dart';
// Shell route keys for nested navigation // Shell route keys for nested navigation
final rootNavigatorKey = GlobalKey<NavigatorState>(); final rootNavigatorKey = GlobalKey<NavigatorState>();
@@ -96,6 +98,7 @@ final routerProvider = Provider<GoRouter>((ref) {
observers: [ observers: [
if (_supportsAnalytics) if (_supportsAnalytics)
FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance), FirebaseAnalyticsObserver(analytics: FirebaseAnalytics.instance),
TalkerRouteObserver(talker),
], ],
routes: [ routes: [
ShellRoute( ShellRoute(
@@ -132,6 +135,11 @@ final routerProvider = Provider<GoRouter>((ref) {
return CallScreen(roomId: id); return CallScreen(roomId: id);
}, },
), ),
GoRoute(
name: 'logs',
path: '/logs',
builder: (context, state) => TalkerScreen(talker: talker),
),
GoRoute( GoRoute(
name: 'accountCalendar', name: 'accountCalendar',
path: '/account/:name/calendar', path: '/account/:name/calendar',

View File

@@ -211,21 +211,11 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
icon: Symbols.system_update, icon: Symbols.system_update,
title: 'Check for updates', title: 'Check for updates',
onTap: () async { onTap: () async {
// Fetch latest release and show the unified sheet
final svc = UpdateService(); final svc = UpdateService();
// Reuse service fetch + compare to decide content
showLoadingModal(context); showLoadingModal(context);
final release = await svc.fetchLatestRelease(); svc.checkForUpdates(context);
if (!context.mounted) return; if (!context.mounted) return;
hideLoadingModal(context); 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( _buildListTile(

View File

@@ -66,7 +66,6 @@ class AccountScreen extends HookConsumerWidget {
isNoBackground: isWide, isNoBackground: isWide,
appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0), appBar: AppBar(backgroundColor: Colors.transparent, toolbarHeight: 0),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: getTabbedPadding(context),
child: Column( child: Column(
spacing: 4, spacing: 4,
children: <Widget>[ children: <Widget>[

View File

@@ -10,7 +10,6 @@ import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/account/credits.dart'; import 'package:island/screens/account/credits.dart';
import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:island/widgets/account/leveling_progress.dart'; import 'package:island/widgets/account/leveling_progress.dart';
import 'package:island/widgets/account/restore_purchase_sheet.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); final stellarSubscription = ref.watch(accountStellarSubscriptionProvider);
return SingleChildScrollView( return SingleChildScrollView(
padding: getTabbedPadding(context, horizontal: 20, vertical: 20), padding: EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [

View File

@@ -9,10 +9,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/services/timezone.dart'; import 'package:island/services/timezone.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -62,19 +62,13 @@ class UpdateProfileScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -168,7 +168,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
scopes: [AppleIDAuthorizationScopes.email], scopes: [AppleIDAuthorizationScopes.email],
webAuthenticationOptions: WebAuthenticationOptions( webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'dev.solsynth.solarpass', clientId: 'dev.solsynth.solarpass',
redirectUri: Uri.parse('https://id.solian.app/auth/callback'), redirectUri: Uri.parse('https://solian.app/auth/callback'),
), ),
); );

View File

@@ -98,7 +98,7 @@ class _AccountBasicInfo extends StatelessWidget {
onPressed: () { onPressed: () {
SharePlus.instance.share( SharePlus.instance.share(
ShareParams( ShareParams(
uri: Uri.parse('https://id.solian.app/@${data.name}'), uri: Uri.parse('https://solian.app/@${data.name}'),
), ),
); );
}, },

View File

@@ -1,11 +1,10 @@
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart' hide ConnectionState; import 'package:flutter/material.dart' hide ConnectionState;
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/app_scaffold.dart';
import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_button.dart';
import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_overlay.dart';
@@ -26,14 +25,14 @@ class CallScreen extends HookConsumerWidget {
final callNotifier = ref.watch(callNotifierProvider.notifier); final callNotifier = ref.watch(callNotifierProvider.notifier);
useEffect(() { useEffect(() {
log('[Call] Joining the call...'); talker.info('[Call] Joining the call...');
callNotifier.joinRoom(roomId).catchError((_) { callNotifier.joinRoom(roomId).catchError((_) {
showConfirmAlert( showConfirmAlert(
'Seems there already has a call connected, do you want override it?', 'Seems there already has a call connected, do you want override it?',
'Call already connected', 'Call already connected',
).then((value) { ).then((value) {
if (value != true) return; if (value != true) return;
log('[Call] Joining the call... with overrides'); talker.info('[Call] Joining the call... with overrides');
callNotifier.disconnect(); callNotifier.disconnect();
callNotifier.dispose(); callNotifier.dispose();
callNotifier.joinRoom(roomId); callNotifier.joinRoom(roomId);

View File

@@ -11,12 +11,12 @@ import 'package:image_picker/image_picker.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/chat/call.dart';
import 'package:island/pods/chat_summary.dart'; import 'package:island/pods/chat/chat_summary.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_picker.dart'; import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.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/content/sheet.dart';
import 'package:island/widgets/realm/realm_selection_dropdown.dart'; import 'package:island/widgets/realm/realm_selection_dropdown.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:island/screens/tabs.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -178,6 +177,101 @@ Future<List<SnChatRoom>> chatroomsJoined(Ref ref) async {
.toList(); .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 { class ChatShellScreen extends HookConsumerWidget {
final Widget child; final Widget child;
const ChatShellScreen({super.key, required this.child}); const ChatShellScreen({super.key, required this.child});
@@ -191,9 +285,23 @@ class ChatShellScreen extends HookConsumerWidget {
isRoot: true, isRoot: true,
child: Row( child: Row(
children: [ children: [
Flexible(flex: 2, child: ChatListScreen(isAside: true)), Flexible(
const VerticalDivider(width: 1), flex: 2,
Flexible(flex: 4, child: child), 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 { class ChatListScreen extends HookConsumerWidget {
final bool isAside; final bool isAside;
const ChatListScreen({super.key, this.isAside = false}); final bool isFloating;
const ChatListScreen({
super.key,
this.isAside = false,
this.isFloating = false,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
if (isWide && !isAside) {
return const EmptyPageHolder();
}
final chats = ref.watch(chatroomsJoinedProvider);
final chatInvites = ref.watch(chatroomInvitesProvider); final chatInvites = ref.watch(chatroomInvitesProvider);
final tabController = useTabController(initialLength: 3); final tabController = useTabController(initialLength: 3);
final selectedTab = useState( final selectedTab = useState(
0, 0,
); // 0 for All, 1 for Direct Messages, 2 for Group Chats ); // 0 for All, 1 for Direct Messages, 2 for Group Chats
final callState = ref.watch(callNotifierProvider);
useEffect(() { useEffect(() {
tabController.addListener(() { tabController.addListener(() {
selectedTab.value = tabController.index; 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( return AppScaffold(
extendBody: false, // Prevent conflicts with tabs navigation extendBody: false, // Prevent conflicts with tabs navigation
appBar: AppBar( appBar: AppBar(
@@ -352,82 +529,10 @@ class ChatListScreen extends HookConsumerWidget {
}, },
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
floatingActionButtonLocation: TabbedFabLocation(context), body: ChatListBodyWidget(
body: Column( isFloating: false,
children: [ tabController: tabController,
Consumer( selectedTab: selectedTab,
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);
},
),
),
),
],
), ),
); );
} }
@@ -539,19 +644,13 @@ class EditChatScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -17,7 +17,7 @@ import "package:island/widgets/chat/message_item.dart";
import "package:island/widgets/response.dart"; import "package:island/widgets/response.dart";
import "package:island/pods/network.dart"; import "package:island/pods/network.dart";
import "package:island/services/responsive.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 { class PublicRoomPreview extends HookConsumerWidget {
final String id; final String id;

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/app_scaffold.dart';
import 'package:island/widgets/chat/message_list_tile.dart'; import 'package:island/widgets/chat/message_list_tile.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';

View File

@@ -10,12 +10,11 @@ import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.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/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -95,19 +94,13 @@ class EditPublisherScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token, client: ref.read(apiClientProvider),
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');
@@ -191,7 +184,7 @@ class EditPublisherScreen extends HookConsumerWidget {
leading: const PageBackButton(), leading: const PageBackButton(),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: getTabbedPadding(context, bottom: 16), padding: EdgeInsets.only(bottom: 16),
child: Column( child: Column(
children: [ children: [
RealmSelectionDropdown( RealmSelectionDropdown(

View File

@@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/custom_app.dart'; import 'package:island/models/custom_app.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/developers/apps.dart'; import 'package:island/screens/developers/apps.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -137,19 +137,13 @@ class EditAppScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -7,9 +7,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/bot.dart'; import 'package:island/models/bot.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -123,19 +123,13 @@ class EditBotScreen extends HookConsumerWidget {
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token, client: ref.read(apiClientProvider),
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -13,13 +13,13 @@ import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.dart'; import 'package:island/screens/notification.dart';
import 'package:island/services/responsive.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/widgets/app_scaffold.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/widgets/check_in.dart'; import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/post/post_featured.dart'; import 'package:island/widgets/post/post_featured.dart';
import 'package:island/widgets/post/post_item.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:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -42,6 +42,7 @@ Widget notificationIndicatorWidget(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
minTileHeight: 48,
leading: const Icon(Symbols.notifications), leading: const Icon(Symbols.notifications),
title: Row( title: Row(
children: [ children: [
@@ -51,7 +52,6 @@ Widget notificationIndicatorWidget(
], ],
), ),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
minTileHeight: 40,
contentPadding: EdgeInsets.only(left: 16, right: 15), contentPadding: EdgeInsets.only(left: 16, right: 15),
onTap: () { onTap: () {
GoRouter.of(context).pushNamed('notifications'); GoRouter.of(context).pushNamed('notifications');
@@ -99,10 +99,6 @@ class ExploreScreen extends HookConsumerWidget {
final events = ref.watch(eventCalendarProvider(query.value)); final events = ref.watch(eventCalendarProvider(query.value));
final selectedDay = useState(now); 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); final user = ref.watch(userInfoProvider);
@@ -110,251 +106,143 @@ class ExploreScreen extends HookConsumerWidget {
notificationUnreadCountNotifierProvider, notificationUnreadCountNotifierProvider,
); );
return AppScaffold( final isWide = isWideScreen(context);
isNoBackground: false,
appBar: AppBar( final filterBar = Card(
toolbarHeight: 0, margin: EdgeInsets.only(top: MediaQuery.of(context).padding.top),
bottom: PreferredSize( child: Row(
preferredSize: const Size.fromHeight(48), children: [
child: Row( Expanded(
children: [ child: TabBar(
Expanded( controller: tabController,
child: TabBar( tabAlignment: TabAlignment.start,
controller: tabController, isScrollable: true,
tabAlignment: TabAlignment.start, dividerColor: Colors.transparent,
isScrollable: true, tabs: [
dividerColor: Colors.transparent, Tab(
tabs: [ icon: Tooltip(
Tab( message: 'explore'.tr(),
icon: Tooltip( child: Icon(Symbols.explore),
message: 'explore'.tr(), ),
child: Icon( ),
Symbols.explore, Tab(
color: icon: Tooltip(
Theme.of( message: 'exploreFilterSubscriptions'.tr(),
context, child: Icon(Symbols.subscriptions),
).appBarTheme.foregroundColor!, ),
), ),
), Tab(
), icon: Tooltip(
Tab( message: 'exploreFilterFriends'.tr(),
icon: Tooltip( child: Icon(Symbols.people),
message: 'exploreFilterSubscriptions'.tr(), ),
child: Icon( ),
Symbols.subscriptions, ],
color: ),
Theme.of( ),
context, IconButton(
).appBarTheme.foregroundColor!, onPressed: () {
), context.pushNamed('articles');
), },
), icon: Icon(Symbols.auto_stories),
Tab( tooltip: 'webArticlesStand'.tr(),
icon: Tooltip( ),
message: 'exploreFilterFriends'.tr(), PopupMenuButton(
child: Icon( itemBuilder:
Symbols.people, (context) => [
color: PopupMenuItem(
Theme.of( child: Row(
context, children: [
).appBarTheme.foregroundColor!, const Icon(Symbols.category),
), const Gap(12),
), Text('categories').tr(),
),
], ],
), ),
), onTap: () {
IconButton( context.pushNamed('postCategories');
onPressed: () {
context.pushNamed('articles');
}, },
icon: Icon(
Symbols.auto_stories,
color: Theme.of(context).appBarTheme.foregroundColor!,
),
tooltip: 'webArticlesStand'.tr(),
), ),
PopupMenuButton( PopupMenuItem(
itemBuilder: child: Row(
(context) => [ children: [
PopupMenuItem( const Icon(Symbols.label),
child: Row( const Gap(12),
children: [ Text('tags').tr(),
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!,
), ),
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');
},
), ),
], ],
) icon: Icon(Symbols.action_key),
.padding(horizontal: 8) tooltip: 'search'.tr(),
.border( ),
bottom: 1 / MediaQuery.of(context).devicePixelRatio, ],
color: Theme.of(context).dividerColor, ).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),
),
), ),
), body:
), isWide
floatingActionButton: InkWell( ? _buildWideBody(
onLongPress: () { context,
context.pushNamed('postCompose', queryParameters: {'type': '1'}).then( ref,
(value) { filterBar,
if (value != null) { user,
activitiesNotifier.forceRefresh(); notificationCount,
} query,
}, events,
); selectedDay,
}, currentFilter.value,
child: FloatingActionButton( )
heroTag: Key("explore-page-fab"), : _buildNarrowBody(context, ref, filterBar, currentFilter.value),
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;
},
),
); );
} }
@@ -369,23 +257,167 @@ class ExploreScreen extends HookConsumerWidget {
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
return ExtendedRefreshIndicator( return PagingHelperSliverView(
onRefresh: () => Future.sync(activitiesNotifier.forceRefresh), provider: activityListNotifierProvider(filter),
child: PagingHelperView( futureRefreshable: activityListNotifierProvider(filter).future,
provider: activityListNotifierProvider(filter), notifierRefreshable: activityListNotifierProvider(filter).notifier,
futureRefreshable: activityListNotifierProvider(filter).future, contentBuilder:
notifierRefreshable: activityListNotifierProvider(filter).notifier, (data, widgetCount, endItemView) => _ActivityListView(
contentBuilder: data: data,
(data, widgetCount, endItemView) => Center( widgetCount: widgetCount,
child: _ActivityListView( endItemView: endItemView,
data: data, activitiesNotifier: activitiesNotifier,
widgetCount: widgetCount, isWide: isWide,
endItemView: endItemView, ),
activitiesNotifier: activitiesNotifier, );
contentOnly: isWide || filter != null, }
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( return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: EdgeInsets.zero,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -505,92 +537,60 @@ class _ActivityListView extends HookConsumerWidget {
final CursorPagingData<SnActivity> data; final CursorPagingData<SnActivity> data;
final int widgetCount; final int widgetCount;
final Widget endItemView; final Widget endItemView;
final bool contentOnly;
final ActivityListNotifier activitiesNotifier; final ActivityListNotifier activitiesNotifier;
final bool isWide;
const _ActivityListView({ const _ActivityListView({
required this.data, required this.data,
required this.widgetCount, required this.widgetCount,
required this.endItemView, required this.endItemView,
required this.activitiesNotifier, required this.activitiesNotifier,
this.contentOnly = false, required this.isWide,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { 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( final item = data.items[index];
notificationUnreadCountNotifierProvider, if (item.data == null) {
); return const SizedBox.shrink();
}
Widget itemWidget;
return CustomScrollView( switch (item.type) {
slivers: [ case 'posts.new':
SliverGap(12), case 'posts.new.replies':
if (user.value != null && !contentOnly) itemWidget = PostActionableItem(
SliverToBoxAdapter( borderRadius: 8,
child: CheckInWidget( item: SnPost.fromJson(item.data!),
margin: EdgeInsets.only(left: 8, right: 8, bottom: 4), onRefresh: () {
), activitiesNotifier.forceRefresh();
), },
if (!contentOnly) onUpdate: (post) {
SliverToBoxAdapter( activitiesNotifier.updateOne(
child: PostFeaturedList().padding(horizontal: 8, bottom: 4, top: 4), index,
), item.copyWith(data: post.toJson()),
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()),
);
},
); );
itemWidget = Card( },
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), );
child: itemWidget, itemWidget = Card(margin: EdgeInsets.zero, child: itemWidget);
); break;
break; case 'discovery':
case 'discovery': itemWidget = _DiscoveryActivityItem(data: item.data!);
itemWidget = _DiscoveryActivityItem(data: item.data!); break;
break; default:
default: itemWidget = const Placeholder();
itemWidget = const Placeholder(); }
}
return itemWidget; return itemWidget;
}, },
),
SliverGap(getTabbedPadding(context).bottom),
],
); );
} }
} }

View File

@@ -1,5 +1,3 @@
import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@@ -7,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/models/poll.dart'; import 'package:island/models/poll.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -92,10 +91,10 @@ class PollEditor extends Notifier<PollEditorState> {
questions: poll.questions, questions: poll.questions,
); );
} on DioException catch (e) { } 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. // Keep state with id set; UI may handle error display.
} catch (e) { } catch (e) {
log('Unexpected error loading poll $id: $e'); talker.error('Unexpected error loading poll $id: $e');
} finally { } finally {
if (context.mounted) hideLoadingModal(context); if (context.mounted) hideLoadingModal(context);
} }

View File

@@ -12,6 +12,7 @@ import 'package:island/screens/posts/compose.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.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_item.dart';
import 'package:island/widgets/post/post_award_history_sheet.dart'; import 'package:island/widgets/post/post_award_history_sheet.dart';
import 'package:island/widgets/post/post_pin_sheet.dart'; import 'package:island/widgets/post/post_pin_sheet.dart';
@@ -273,7 +274,14 @@ class PostActionButtons extends HookConsumerWidget {
actions.add( actions.add(
FilledButton.tonalIcon( FilledButton.tonalIcon(
onPressed: () {}, onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => PostAwardSheet(post: post),
);
},
onLongPress: () { onLongPress: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,

View File

@@ -9,16 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.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/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:island/screens/tabs.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -90,7 +88,6 @@ class RealmListScreen extends HookConsumerWidget {
}); });
}, },
), ),
floatingActionButtonLocation: TabbedFabLocation(context),
body: ExtendedRefreshIndicator( body: ExtendedRefreshIndicator(
child: realms.when( child: realms.when(
data: data:
@@ -100,7 +97,7 @@ class RealmListScreen extends HookConsumerWidget {
child: ListView.separated( child: ListView.separated(
padding: EdgeInsets.only( padding: EdgeInsets.only(
top: 8, top: 8,
bottom: getTabbedPadding(context).bottom + 8, bottom: MediaQuery.of(context).padding.bottom + 8,
), ),
itemCount: value.length, itemCount: value.length,
itemBuilder: (context, item) { itemBuilder: (context, item) {
@@ -207,19 +204,13 @@ class EditRealmScreen extends HookConsumerWidget {
showLoadingModal(context); showLoadingModal(context);
submitting.value = true; submitting.value = true;
try { try {
final baseUrl = ref.watch(serverUrlProvider);
final token = await getToken(ref.watch(tokenProvider));
if (token == null) throw ArgumentError('Access token is null');
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: UniversalFile( fileData: UniversalFile(
data: result, data: result,
type: UniversalFileType.image, type: UniversalFileType.image,
), ),
atk: token,
baseUrl: baseUrl,
filename: result.name,
mimetype: result.mimeType ?? 'image/jpeg',
).future; ).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');

View File

@@ -26,6 +26,27 @@ import 'package:island/pods/file_pool.dart';
class SettingsScreen extends HookConsumerWidget { class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key}); 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider); final serverUrl = ref.watch(serverUrlProvider);
@@ -64,9 +85,7 @@ class SettingsScreen extends HookConsumerWidget {
) { ) {
return DropdownMenuItem<Locale?>( return DropdownMenuItem<Locale?>(
value: ele, value: ele,
child: Text( child: Text(_getLanguageDisplayName(ele)).fontSize(14),
'${ele.languageCode}-${ele.countryCode}',
).fontSize(14),
); );
}), }),
DropdownMenuItem<Locale?>( 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 // Custom fonts settings
ListTile( ListTile(
isThreeLine: true, isThreeLine: true,
@@ -579,8 +640,33 @@ class SettingsScreen extends HookConsumerWidget {
]; ];
// Desktop-specific settings // Desktop-specific settings
// But nothing for now final desktopSettings =
final desktopSettings = !isDesktop ? <Widget>[] : <Widget>[]; !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 // Create a responsive layout based on screen width
Widget buildSettingsList() { Widget buildSettingsList() {

View File

@@ -68,68 +68,76 @@ class TabsScreen extends HookConsumerWidget {
final currentIndex = getCurrentIndex(); final currentIndex = getCurrentIndex();
if (isWideScreen(context)) { if (isWideScreen(context)) {
return Row( return Container(
children: [ color: Theme.of(context).colorScheme.surfaceContainer,
NavigationRail( child: Row(
destinations: children: [
destinations NavigationRail(
.map( backgroundColor: Colors.transparent,
(e) => NavigationRailDestination( destinations:
icon: e.icon, destinations
label: Text(e.label), .map(
), (e) => NavigationRailDestination(
) icon: e.icon,
.toList(), label: Text(e.label),
selectedIndex: currentIndex, ),
onDestinationSelected: onDestinationSelected, )
), .toList(),
const VerticalDivider(width: 1), selectedIndex: currentIndex,
Expanded(child: child ?? const SizedBox.shrink()), onDestinationSelected: onDestinationSelected,
], ),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
),
child: child ?? const SizedBox.shrink(),
),
),
],
),
); );
} }
return Stack( return Scaffold(
children: [ backgroundColor: Colors.transparent,
Positioned.fill(child: child ?? const SizedBox.shrink()), extendBody: true,
Positioned( body: ClipRRect(
left: 0, borderRadius: const BorderRadius.only(
right: 0, topLeft: Radius.circular(16),
bottom: 0, topRight: Radius.circular(16),
child: ConditionalBottomNav( ),
child: ClipRRect( child: child ?? const SizedBox.shrink(),
child: BackdropFilter( ),
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), bottomNavigationBar: ConditionalBottomNav(
child: Container( child: ClipRRect(
decoration: BoxDecoration( child: BackdropFilter(
color: Theme.of( filter: ImageFilter.blur(sigmaX: 1, sigmaY: 1),
context, child: Container(
).colorScheme.surface.withOpacity(0.8), decoration: BoxDecoration(
), color: Theme.of(context).colorScheme.surface.withOpacity(0.8),
child: MediaQuery.removePadding( ),
context: context, child: MediaQuery.removePadding(
removeTop: true, context: context,
child: NavigationBar( removeTop: true,
backgroundColor: Colors.transparent, child: NavigationBar(
shadowColor: Colors.transparent, backgroundColor: Colors.transparent,
overlayColor: const WidgetStatePropertyAll( shadowColor: Colors.transparent,
Colors.transparent, overlayColor: const WidgetStatePropertyAll(
), Colors.transparent,
surfaceTintColor: Colors.transparent,
height: 56,
labelBehavior:
NavigationDestinationLabelBehavior.alwaysHide,
selectedIndex: currentIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
),
), ),
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.floatingActionButtonSize.height -
scaffoldGeometry.bottomSheetSize.height - scaffoldGeometry.bottomSheetSize.height -
safeAreaPadding.bottom - safeAreaPadding.bottom -
(isWideScreen(context) ? 32 : 80) +
16; 16;
return Offset(fabX, fabY); return Offset(fabX, fabY);

View File

@@ -1,8 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';
class TrayService { class TrayService {
TrayService._(); TrayService._();
@@ -48,15 +47,10 @@ class TrayService {
void handleAction(MenuItem item) { void handleAction(MenuItem item) {
switch (item.key) { switch (item.key) {
case 'show_window': case 'show_window':
() async { windowManager.show();
appWindow.show();
appWindow.restore();
await Future.delayed(const Duration(milliseconds: 32));
appWindow.show();
}();
break; break;
case 'exit_app': case 'exit_app':
appWindow.close(); windowManager.destroy();
break; break;
} }
} }

View File

@@ -32,7 +32,6 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
Future<void> saveDraft(SnPost draft) async { Future<void> saveDraft(SnPost draft) async {
final updatedDraft = draft.copyWith(updatedAt: DateTime.now()); final updatedDraft = draft.copyWith(updatedAt: DateTime.now());
state = {...state, updatedDraft.id: updatedDraft};
try { try {
final database = ref.read(databaseProvider); final database = ref.read(databaseProvider);
@@ -48,11 +47,11 @@ class ComposeStorageNotifier extends _$ComposeStorageNotifier {
postData: Value(jsonEncode(updatedDraft.toJson())), 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) { } catch (e) {
// Revert state on error
final newState = Map<String, SnPost>.from(state);
newState.remove(updatedDraft.id);
state = newState;
rethrow; rethrow;
} }
} }

View File

@@ -7,7 +7,7 @@ part of 'compose_storage_db.dart';
// ************************************************************************** // **************************************************************************
String _$composeStorageNotifierHash() => String _$composeStorageNotifierHash() =>
r'8baf17aa06b6f69641c20645ba8a3dfe01c97f8c'; r'e78dbfd8dbaf728970985aaa2ac4df3575ddfcdf';
/// See also [ComposeStorageNotifier]. /// See also [ComposeStorageNotifier].
@ProviderFor(ComposeStorageNotifier) @ProviderFor(ComposeStorageNotifier)

View File

@@ -1,17 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:croppy/croppy.dart'; import 'package:croppy/croppy.dart';
import 'package:cross_file/cross_file.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: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( Future<XFile?> cropImage(
BuildContext context, { BuildContext context, {
@@ -19,10 +10,12 @@ Future<XFile?> cropImage(
List<CropAspectRatio?>? allowedAspectRatios, List<CropAspectRatio?>? allowedAspectRatios,
bool replacePath = true, bool replacePath = true,
}) async { }) async {
if (!context.mounted) return null;
final imageBytes = await image.readAsBytes();
if (!context.mounted) return null;
final result = await showMaterialImageCropper( final result = await showMaterialImageCropper(
context, context,
imageProvider: imageProvider: MemoryImage(imageBytes),
kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path)),
showLoadingIndicatorOnSubmit: true, showLoadingIndicatorOnSubmit: true,
allowedAspectRatios: allowedAspectRatios, allowedAspectRatios: allowedAspectRatios,
); );
@@ -41,210 +34,3 @@ Future<XFile?> cropImage(
mimeType: image.mimeType, 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;
}

View File

@@ -1,20 +1,22 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:cross_file/cross_file.dart';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:mime/mime.dart';
import 'package:native_exif/native_exif.dart';
class FileUploader { class FileUploader {
final Dio _dio; final Dio _client;
FileUploader(this._dio); FileUploader(this._client);
/// Calculates the MD5 hash of a file. /// 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 bytes = await file.readAsBytes();
final digest = md5.convert(bytes); final digest = md5.convert(bytes);
return digest.toString(); return digest.toString();
@@ -22,7 +24,7 @@ class FileUploader {
/// Creates an upload task for the given file. /// Creates an upload task for the given file.
Future<Map<String, dynamic>> createUploadTask({ Future<Map<String, dynamic>> createUploadTask({
required File file, required XFile file,
required String fileName, required String fileName,
required String contentType, required String contentType,
String? poolId, String? poolId,
@@ -34,7 +36,7 @@ class FileUploader {
final hash = await _calculateFileHash(file); final hash = await _calculateFileHash(file);
final fileSize = await file.length(); final fileSize = await file.length();
final response = await _dio.post( final response = await _client.post(
'/drive/files/upload/create', '/drive/files/upload/create',
data: { data: {
'hash': hash, 'hash': hash,
@@ -65,7 +67,7 @@ class FileUploader {
), ),
}); });
await _dio.post( await _client.post(
'/drive/files/upload/chunk/$taskId/$chunkIndex', '/drive/files/upload/chunk/$taskId/$chunkIndex',
data: formData, data: formData,
); );
@@ -73,14 +75,14 @@ class FileUploader {
/// Completes the upload and returns the CloudFile object. /// Completes the upload and returns the CloudFile object.
Future<SnCloudFile> completeUpload(String taskId) async { 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); return SnCloudFile.fromJson(response.data);
} }
/// Uploads a file in chunks using the multi-part API. /// Uploads a file in chunks using the multi-part API.
Future<SnCloudFile> uploadFile({ Future<SnCloudFile> uploadFile({
required File file, required XFile file,
required String fileName, required String fileName,
required String contentType, required String contentType,
String? poolId, String? poolId,
@@ -146,8 +148,155 @@ class FileUploader {
// Step 3: Complete upload // Step 3: Complete upload
return await completeUpload(taskId); 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 // Riverpod provider for the FileUploader service
final fileUploaderProvider = Provider<FileUploader>((ref) { final fileUploaderProvider = Provider<FileUploader>((ref) {
final dio = ref.watch(apiClientProvider); final dio = ref.watch(apiClientProvider);

View File

@@ -1,7 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -13,6 +11,7 @@ import 'package:island/main.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/app_notification.dart'; import 'package:island/widgets/app_notification.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
@@ -96,7 +95,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
final notification = SnNotification.fromJson(pkt.data!); final notification = SnNotification.fromJson(pkt.data!);
if (_appLifecycleState == AppLifecycleState.resumed) { if (_appLifecycleState == AppLifecycleState.resumed) {
// App is focused, show in-app notification // App is focused, show in-app notification
log( talker.info(
'[Notification] Showing in-app notification: ${notification.title}', '[Notification] Showing in-app notification: ${notification.title}',
); );
showTopSnackBar( showTopSnackBar(
@@ -142,7 +141,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
} else { } else {
// App is in background, show system notification (only on supported platforms) // App is in background, show system notification (only on supported platforms)
if (!kIsWeb && !Platform.isIOS) { if (!kIsWeb && !Platform.isIOS) {
log( talker.info(
'[Notification] Showing system notification: ${notification.title}', '[Notification] Showing system notification: ${notification.title}',
); );
@@ -167,7 +166,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
payload: notification.meta['action_uri'] as String?, payload: notification.meta['action_uri'] as String?,
); );
} else { } else {
log( talker.info(
'[Notification] Skipping system notification for unsupported platform: ${notification.title}', '[Notification] Skipping system notification for unsupported platform: ${notification.title}',
); );
} }
@@ -206,7 +205,7 @@ Future<void> subscribePushNotification(
_putTokenToRemote(apiClient, fcmToken, 1); _putTokenToRemote(apiClient, fcmToken, 1);
}) })
.onError((err) { .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) { if (deviceToken != null) {

View File

@@ -1,27 +1,27 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:island/main.dart'; import 'package:island/main.dart';
import 'package:island/pods/config.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/app_notification.dart'; import 'package:island/widgets/app_notification.dart';
import 'package:top_snackbar_flutter/top_snack_bar.dart'; import 'package:top_snackbar_flutter/top_snack_bar.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:windows_notification/windows_notification.dart' import 'package:flutter_cache_manager/flutter_cache_manager.dart';
as windows_notification; import 'package:windows_notification/windows_notification.dart' as winty;
import 'package:windows_notification/notification_message.dart'; import 'package:windows_notification/notification_message.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
// Windows notification instance // Windows notification instance
windows_notification.WindowsNotification? windowsNotification; winty.WindowsNotification? windowsNotification;
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
@@ -31,8 +31,8 @@ void _onAppLifecycleChanged(AppLifecycleState state) {
Future<void> initializeLocalNotifications() async { Future<void> initializeLocalNotifications() async {
// Initialize Windows notification for Windows platform // Initialize Windows notification for Windows platform
windowsNotification = windows_notification.WindowsNotification( windowsNotification = winty.WindowsNotification(
applicationId: 'dev.solsynth.solian', applicationId: "Solian",
); );
WidgetsBinding.instance.addObserver( WidgetsBinding.instance.addObserver(
@@ -61,7 +61,7 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
final notification = SnNotification.fromJson(pkt.data!); final notification = SnNotification.fromJson(pkt.data!);
if (_appLifecycleState == AppLifecycleState.resumed) { if (_appLifecycleState == AppLifecycleState.resumed) {
// App is focused, show in-app notification // App is focused, show in-app notification
log( talker.info(
'[Notification] Showing in-app notification: ${notification.title}', '[Notification] Showing in-app notification: ${notification.title}',
); );
showTopSnackBar( showTopSnackBar(
@@ -99,17 +99,51 @@ StreamSubscription<WebSocketPacket> setupNotificationListener(
); );
} else { } else {
// App is in background, show Windows system notification // App is in background, show Windows system notification
log( talker.info(
'[Notification] Showing Windows system notification: ${notification.title}', '[Notification] Showing Windows system notification: ${notification.title}',
); );
if (windowsNotification != null) { 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 // Use Windows notification for Windows platform
final notificationMessage = NotificationMessage.fromPluginTemplate( final notificationMessage = NotificationMessage.fromPluginTemplate(
DateTime.now().millisecondsSinceEpoch.toString(), // unique id notification.id, // unique id
notification.title, notification.title,
notification.content, [notification.subtitle, notification.content].where((e) => e.isNotEmpty).join('\n'),
launch: notification.meta['action_uri'] as String?, group: notification.topic,
image: imagePath,
largeImage: largeImagePath,
launch: actionUrl != null ? 'solian://$actionUrl' : null,
); );
await windowsNotification!.showNotificationPluginTemplate( await windowsNotification!.showNotificationPluginTemplate(
notificationMessage, notificationMessage,
@@ -150,7 +184,7 @@ Future<void> subscribePushNotification(
_putTokenToRemote(apiClient, fcmToken, 1); _putTokenToRemote(apiClient, fcmToken, 1);
}) })
.onError((err) { .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) { if (deviceToken != null) {

View File

@@ -15,32 +15,3 @@ bool isWiderScreen(BuildContext context) {
bool isWidestScreen(BuildContext context) { bool isWidestScreen(BuildContext context) {
return MediaQuery.of(context).size.width > kWidescreenWidth; 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,
);
}

View File

@@ -1,9 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:archive/archive.dart'; import 'package:archive/archive.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_app_update/azhon_app_update.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:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/talker.dart';
/// Data model for a GitHub release we care about /// Data model for a GitHub release we care about
class GithubReleaseInfo { class GithubReleaseInfo {
@@ -120,40 +121,40 @@ class UpdateService {
/// Checks GitHub for the latest release and compares against the current app version. /// 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. /// If update is available, shows a bottom sheet with changelog and an action to open release page.
Future<void> checkForUpdates(BuildContext context) async { Future<void> checkForUpdates(BuildContext context) async {
log('[Update] Checking for updates...'); talker.info('[Update] Checking for updates...');
try { try {
final release = await fetchLatestRelease(); final release = await fetchLatestRelease();
if (release == null) { 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; return;
} }
log('[Update] Fetched latest release: ${release.tagName}'); talker.info('[Update] Fetched latest release: ${release.tagName}');
final info = await PackageInfo.fromPlatform(); final info = await PackageInfo.fromPlatform();
final localVersionStr = '${info.version}+${info.buildNumber}'; 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 latest = _ParsedVersion.tryParse(release.tagName);
final local = _ParsedVersion.tryParse(localVersionStr); final local = _ParsedVersion.tryParse(localVersionStr);
if (latest == null || local == null) { if (latest == null || local == null) {
log( talker.info(
'[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr', '[Update] Failed to parse versions. Latest: ${release.tagName}, Local: $localVersionStr',
); );
// If parsing fails, do nothing silently // If parsing fails, do nothing silently
return; return;
} }
log('[Update] Parsed versions. Latest: $latest, Local: $local'); talker.info('[Update] Parsed versions. Latest: $latest, Local: $local');
final needsUpdate = latest.compareTo(local) > 0; final needsUpdate = latest.compareTo(local) > 0;
if (!needsUpdate) { if (!needsUpdate) {
log('[Update] App is up to date. No update needed.'); talker.info('[Update] App is up to date. No update needed.');
return; return;
} }
log('[Update] Update available! Latest: $latest, Local: $local'); talker.info('[Update] Update available! Latest: $latest, Local: $local');
if (!context.mounted) { if (!context.mounted) {
log('[Update] Context not mounted, cannot show update sheet.'); talker.info('[Update] Context not mounted, cannot show update sheet.');
return; return;
} }
@@ -162,10 +163,10 @@ class UpdateService {
if (context.mounted) { if (context.mounted) {
await showUpdateSheet(context, release); await showUpdateSheet(context, release);
log('[Update] Update sheet shown.'); talker.info('[Update] Update sheet shown.');
} }
} catch (e) { } catch (e) {
log('[Update] Error checking for updates: $e'); talker.error('[Update] Error checking for updates: $e');
// Ignore errors (network, api, etc.) // Ignore errors (network, api, etc.)
return; return;
} }
@@ -233,39 +234,240 @@ class UpdateService {
return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip'; return 'https://fs.solsynth.dev/d/official/solian/build-output-windows-installer.zip';
} }
/// Downloads the Windows installer ZIP file /// Performs automatic Windows update: download, extract, and install
Future<String?> _downloadWindowsInstaller(String url) async { 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 { 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 tempDir = await getTemporaryDirectory();
final fileName = final fileName =
'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip'; 'solian-installer-${DateTime.now().millisecondsSinceEpoch}.zip';
final filePath = path.join(tempDir.path, fileName); final filePath = path.join(tempDir.path, fileName);
final response = await _dio.download( final response = await Dio().download(
url, url,
filePath, filePath,
onReceiveProgress: (received, total) { onReceiveProgress: (received, total) {
if (total != -1) { if (total != -1) {
log( talker.info(
'[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%', '[Update] Download progress: ${(received / total * 100).toStringAsFixed(1)}%',
); );
} }
onProgress?.call(received, total);
}, },
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
log('[Update] Windows installer downloaded successfully to: $filePath'); talker.info('[Update] Windows installer downloaded successfully to: $filePath');
return filePath; return filePath;
} else { } else {
log( talker.error(
'[Update] Failed to download Windows installer. Status: ${response.statusCode}', '[Update] Failed to download Windows installer. Status: ${response.statusCode}',
); );
return null; return null;
} }
} catch (e) { } catch (e) {
log('[Update] Error downloading Windows installer: $e'); talker.error('[Update] Error downloading Windows installer: $e');
return null; return null;
} }
} }
@@ -273,7 +475,7 @@ class UpdateService {
/// Extracts the ZIP file to a temporary directory /// Extracts the ZIP file to a temporary directory
Future<String?> _extractWindowsInstaller(String zipPath) async { Future<String?> _extractWindowsInstaller(String zipPath) async {
try { try {
log('[Update] Extracting Windows installer from: $zipPath'); talker.info('[Update] Extracting Windows installer from: $zipPath');
final tempDir = await getTemporaryDirectory(); final tempDir = await getTemporaryDirectory();
final extractDir = path.join( 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; return extractDir;
} catch (e) { } catch (e) {
log('[Update] Error extracting Windows installer: $e'); talker.error('[Update] Error extracting Windows installer: $e');
return null; return null;
} }
} }
@@ -309,231 +511,42 @@ class UpdateService {
/// Runs the setup.exe file /// Runs the setup.exe file
Future<bool> _runWindowsInstaller(String extractDir) async { Future<bool> _runWindowsInstaller(String extractDir) async {
try { 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()) { if (exeFiles.isEmpty) {
log('[Update] setup.exe not found in extracted directory'); talker.info('[Update] No .exe file found in extracted directory');
return false; return false;
} }
final setupExePath = exeFiles.first.path;
talker.info('[Update] Found installer executable: $setupExePath');
final shell = Shell(); final shell = Shell();
final results = await shell.run(setupExePath); final results = await shell.run(setupExePath);
final result = results.first; final result = results.first;
if (result.exitCode == 0) { if (result.exitCode == 0) {
log('[Update] Windows installer completed successfully'); talker.info('[Update] Windows installer completed successfully');
return true; return true;
} else { } else {
log( talker.error(
'[Update] Windows installer failed with exit code: ${result.exitCode}', '[Update] Windows installer failed with exit code: ${result.exitCode}',
); );
log('[Update] Installer output: ${result.stdout}'); talker.error('[Update] Installer output: ${result.stdout}');
log('[Update] Installer errors: ${result.stderr}'); talker.error('[Update] Installer errors: ${result.stderr}');
return false; return false;
} }
} catch (e) { } catch (e) {
log('[Update] Error running Windows installer: $e'); talker.error('[Update] Error running Windows installer: $e');
return false; 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 { class _UpdateSheet extends StatefulWidget {
@@ -584,7 +597,7 @@ class _UpdateSheetState extends State<_UpdateSheet> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return SheetScaffold( return SheetScaffold(
titleText: 'Update available', titleText: 'updateAvailable'.tr(),
child: Padding( child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: 16 + MediaQuery.of(context).padding.bottom, bottom: 16 + MediaQuery.of(context).padding.bottom,
@@ -612,14 +625,14 @@ class _UpdateSheetState extends State<_UpdateSheet> {
child: MarkdownTextContent( child: MarkdownTextContent(
content: content:
widget.release.body.isEmpty widget.release.body.isEmpty
? 'No changelog provided.' ? 'noChangelogProvided'.tr()
: widget.release.body, : widget.release.body,
), ),
), ),
), ),
if (!kIsWeb && Platform.isAndroid) if (!kIsWeb && Platform.isAndroid)
SwitchListTile( SwitchListTile(
title: const Text('Use secondary source for download'), title: Text('useSecondarySourceForDownload'.tr()),
value: _useProxy, value: _useProxy,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
@@ -638,11 +651,11 @@ class _UpdateSheetState extends State<_UpdateSheet> {
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: () { onPressed: () {
log(widget.androidUpdateUrl!); talker.info(widget.androidUpdateUrl!);
_installUpdate(widget.androidUpdateUrl!); _installUpdate(widget.androidUpdateUrl!);
}, },
icon: const Icon(Symbols.update), icon: const Icon(Symbols.update),
label: const Text('Install update'), label: Text('installUpdate'.tr()),
), ),
), ),
if (!kIsWeb && if (!kIsWeb &&
@@ -655,20 +668,20 @@ class _UpdateSheetState extends State<_UpdateSheet> {
final updateService = UpdateService( final updateService = UpdateService(
useProxy: widget.useProxy, useProxy: widget.useProxy,
); );
updateService._performAutomaticWindowsUpdate( updateService.performAutomaticWindowsUpdate(
context, context,
widget.windowsUpdateUrl!, widget.windowsUpdateUrl!,
); );
}, },
icon: const Icon(Symbols.update), icon: const Icon(Symbols.update),
label: const Text('Install update'), label: Text('installUpdate'.tr()),
), ),
), ),
Expanded( Expanded(
child: FilledButton.icon( child: FilledButton.icon(
onPressed: widget.onOpen, onPressed: widget.onOpen,
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new),
label: const Text('Open release page'), label: Text('openReleasePage'.tr()),
), ),
), ),
], ],

4
lib/talker.dart Normal file
View File

@@ -0,0 +1,4 @@
import 'package:talker_flutter/talker_flutter.dart';
final talker = TalkerFlutter.init();

View File

@@ -120,7 +120,7 @@ class LevelingProgressCard extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Tooltip( child: Tooltip(
message: '${progress.toStringAsFixed(1)}%', message: '${(progress * 100).toStringAsFixed(1)}%',
child: LinearProgressIndicator( child: LinearProgressIndicator(
minHeight: progressHeight, minHeight: progressHeight,
value: progress, value: progress,

View File

@@ -4,7 +4,6 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/screens/account/me/settings_connections.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/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@@ -50,7 +49,6 @@ class RestorePurchaseSheet extends HookConsumerWidget {
return SheetScaffold( return SheetScaffold(
titleText: 'restorePurchase'.tr(), titleText: 'restorePurchase'.tr(),
child: SingleChildScrollView( child: SingleChildScrollView(
padding: getTabbedPadding(context),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [

View File

@@ -6,7 +6,7 @@ part of 'status.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$accountStatusHash() => r'c861a0565d6229fd35666bba7cb2f5c6b7298e46'; String _$accountStatusHash() => r'abc2f11f0fbaf637efc182cf85ab838936c4d875';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View File

@@ -1,6 +1,5 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui'; import 'dart:ui';
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/config.dart';
import 'package:island/pods/userinfo.dart'; import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/screens/tabs.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:window_manager/window_manager.dart';
class AppScrollBehavior extends MaterialScrollBehavior { class AppScrollBehavior extends MaterialScrollBehavior {
@override @override
@@ -31,16 +32,19 @@ class WindowScaffold extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final isMaximized = useState(false);
// Add window resize listener for desktop platforms // Add window resize listener for desktop platforms
useEffect(() { useEffect(() {
if (!kIsWeb && if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { (Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
void saveWindowSize() { void saveWindowSize() {
final size = appWindow.size; windowManager.getBounds().then((bounds) {
final settingsNotifier = ref.read( final settingsNotifier = ref.read(
appSettingsNotifierProvider.notifier, appSettingsNotifierProvider.notifier,
); );
settingsNotifier.setWindowSize(size); settingsNotifier.setWindowSize(bounds.size);
});
} }
// Save window size when app is about to close // Save window size when app is about to close
@@ -48,11 +52,16 @@ class WindowScaffold extends HookConsumerWidget {
_WindowSizeObserver(saveWindowSize), _WindowSizeObserver(saveWindowSize),
); );
final maximizeListener = _WindowMaximizeListener(isMaximized);
windowManager.addListener(maximizeListener);
windowManager.isMaximized().then((max) => isMaximized.value = max);
return () { return () {
// Cleanup observer when widget is disposed // Cleanup observer when widget is disposed
WidgetsBinding.instance.removeObserver( WidgetsBinding.instance.removeObserver(
_WindowSizeObserver(saveWindowSize), _WindowSizeObserver(saveWindowSize),
); );
windowManager.removeListener(maximizeListener);
}; };
} }
return null; return null;
@@ -60,60 +69,84 @@ class WindowScaffold extends HookConsumerWidget {
if (!kIsWeb && if (!kIsWeb &&
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) { (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( return Material(
color: Theme.of(context).colorScheme.surfaceContainer,
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Column( Column(
children: [ children: [
WindowTitleBarBox( DragToMoveArea(
child: Container( child: Row(
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.center,
border: Border( mainAxisAlignment:
bottom: BorderSide( Platform.isMacOS
color: Theme.of(context).dividerColor, ? MainAxisAlignment.center
width: 1 / devicePixelRatio, : MainAxisAlignment.start,
), children: [
), Expanded(
), child:
child: MoveWindow(
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment:
Platform.isMacOS Platform.isMacOS
? MainAxisAlignment.center ? Text(
: MainAxisAlignment.start, 'Solar Network',
children: [ textAlign: TextAlign.center,
Expanded( ).padding(horizontal: 12, vertical: 5)
child: Text( : Row(
'Solar Network', children: [
textAlign: Image.asset(
Platform.isMacOS Theme.of(context).brightness ==
? TextAlign.center Brightness.dark
: TextAlign.start, ? 'assets/icons/icon-dark.png'
).padding(horizontal: 12, vertical: 5), : 'assets/icons/icon.png',
), width: 20,
if (!Platform.isMacOS) height: 20,
MinimizeWindowButton(colors: windowButtonColor), ),
if (!Platform.isMacOS) const SizedBox(width: 8),
MaximizeWindowButton(colors: windowButtonColor), Text(
if (!Platform.isMacOS) 'Solar Network',
CloseWindowButton( textAlign: TextAlign.start,
colors: windowButtonColor, ),
onPressed: () => appWindow.hide(), ],
), ).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), Expanded(child: child),
@@ -162,6 +195,21 @@ class _WindowSizeObserver extends WidgetsBindingObserver {
int get hashCode => onSaveWindowSize.hashCode; 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>(); final rootScaffoldKey = GlobalKey<ScaffoldState>();
class AppScaffold extends HookConsumerWidget { class AppScaffold extends HookConsumerWidget {
@@ -245,7 +293,7 @@ class AppScaffold extends HookConsumerWidget {
endDrawer: endDrawer, endDrawer: endDrawer,
floatingActionButton: floatingActionButton, floatingActionButton: floatingActionButton,
floatingActionButtonAnimator: floatingActionButtonAnimator, floatingActionButtonAnimator: floatingActionButtonAnimator,
floatingActionButtonLocation: floatingActionButtonLocation, floatingActionButtonLocation: TabbedFabLocation(context),
onDrawerChanged: onDrawerChanged, onDrawerChanged: onDrawerChanged,
onEndDrawerChanged: onEndDrawerChanged, onEndDrawerChanged: onEndDrawerChanged,
), ),

View File

@@ -1,10 +1,11 @@
import 'dart:async'; 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/activity/activity_rpc.dart'; import 'package:island/pods/activity/activity_rpc.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/screens/tray_manager.dart'; import 'package:island/screens/tray_manager.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.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/content/network_status_sheet.dart';
import 'package:island/widgets/tour/tour.dart'; import 'package:island/widgets/tour/tour.dart';
import 'package:tray_manager/tray_manager.dart'; import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';
class AppWrapper extends HookConsumerWidget with TrayListener { class AppWrapper extends HookConsumerWidget with TrayListener {
final Widget child; final Widget child;
@@ -21,24 +23,37 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
useEffect(() { useEffect(() {
StreamSubscription? ntySubs; StreamSubscription? ntySubs;
Future(() { StreamSubscription? appLinksSubs;
Future(() async {
final appLinks = AppLinks();
if (context.mounted) ntySubs = setupNotificationListener(context, ref); 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 () { return () {
ref.read(rpcServerProvider).stop(); ref.read(rpcServerProvider).stop();
TrayService.instance.dispose(this); TrayService.instance.dispose(this);
sharingService.dispose();
ntySubs?.cancel(); ntySubs?.cancel();
appLinksSubs?.cancel();
}; };
}, const []); }, const []);
@@ -67,11 +82,7 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
} }
void _trayIconPrimaryAction() { void _trayIconPrimaryAction() {
if (appWindow.isVisible) { windowManager.show();
appWindow.restore();
} else {
appWindow.show();
}
} }
void _trayIconSecondaryAction() { void _trayIconSecondaryAction() {
@@ -92,4 +103,16 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
void onTrayMenuItemClick(MenuItem menuItem) { void onTrayMenuItemClick(MenuItem menuItem) {
TrayService.instance.handleAction(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);
}
} }

View File

@@ -26,15 +26,20 @@ class AttachmentUploadConfig {
class AttachmentUploaderSheet extends StatefulWidget { class AttachmentUploaderSheet extends StatefulWidget {
final WidgetRef ref; final WidgetRef ref;
final ComposeState state; final ComposeState? state;
final List<UniversalFile>? attachments;
final int index; final int index;
const AttachmentUploaderSheet({ const AttachmentUploaderSheet({
super.key, super.key,
required this.ref, required this.ref,
required this.state, this.state,
this.attachments,
required this.index, required this.index,
}); }) : assert(
state != null || attachments != null,
'Either state or attachments must be provided',
);
@override @override
State<AttachmentUploaderSheet> createState() => State<AttachmentUploaderSheet> createState() =>
@@ -46,7 +51,9 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
@override @override
Widget build(BuildContext context) { 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( return SheetScaffold(
titleText: 'uploadAttachment'.tr(), titleText: 'uploadAttachment'.tr(),
@@ -111,19 +118,18 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
// Check accepted types // Check accepted types
final acceptTypes = final acceptTypes =
selectedPool.policyConfig?['accept_types'] (selectedPool.policyConfig?['accept_types']
as List?; as List?)
?.cast<String>();
final mimeType = final mimeType =
attachment.data.mimeType ?? attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType( ComposeLogic.getMimeTypeFromFileType(
attachment.type, attachment.type,
); );
final typeAccepted = final typeAccepted = _isMimeTypeAccepted(
acceptTypes == null || mimeType,
acceptTypes.isEmpty || acceptTypes,
acceptTypes.any( );
(type) => mimeType.startsWith(type),
);
final hasIssues = fileSizeExceeded || !typeAccepted; final hasIssues = fileSizeExceeded || !typeAccepted;
@@ -279,7 +285,9 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
} }
Future<AttachmentUploadConfig?> _getUploadConfig() async { 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); final fileSize = await _getFileSize(attachment);
if (fileSize == null) return null; if (fileSize == null) return null;
@@ -292,14 +300,12 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?; final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize; 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 = final mimeType =
attachment.data.mimeType ?? attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type); ComposeLogic.getMimeTypeFromFileType(attachment.type);
final typeAccepted = final typeAccepted = _isMimeTypeAccepted(mimeType, acceptTypes);
acceptTypes == null ||
acceptTypes.isEmpty ||
acceptTypes.any((type) => mimeType.startsWith(type));
final hasConstraints = fileSizeExceeded || !typeAccepted; final hasConstraints = fileSizeExceeded || !typeAccepted;
@@ -360,4 +366,16 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round(); final quotaCost = ((fileSize / 1024 / 1024) * costMultiplier).round();
return _formatNumber(quotaCost); 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;
}
});
}
} }

View File

@@ -4,7 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/chat.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/pods/network.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/pods/network.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';

View File

@@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_popup_card/flutter_popup_card.dart'; import 'package:flutter_popup_card/flutter_popup_card.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.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:island/widgets/account/account_nameplate.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/screens/account/profile.dart';
import 'package:island/widgets/chat/call_participant_card.dart'; import 'package:island/widgets/chat/call_participant_card.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';

View File

@@ -1,7 +1,5 @@
import "dart:async"; import "dart:async";
import "dart:io";
import "package:easy_localization/easy_localization.dart"; import "package:easy_localization/easy_localization.dart";
import "package:flutter/foundation.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter/services.dart"; import "package:flutter/services.dart";
import "package:flutter_hooks/flutter_hooks.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/chat.dart";
import "package:island/models/file.dart"; import "package:island/models/file.dart";
import "package:island/pods/config.dart"; import "package:island/pods/config.dart";
import "package:island/services/responsive.dart";
import "package:island/widgets/content/attachment_preview.dart"; import "package:island/widgets/content/attachment_preview.dart";
import "package:material_symbols_icons/material_symbols_icons.dart"; import "package:material_symbols_icons/material_symbols_icons.dart";
import "package:pasteboard/pasteboard.dart"; import "package:pasteboard/pasteboard.dart";
import "package:styled_widget/styled_widget.dart"; import "package:styled_widget/styled_widget.dart";
import "package:material_symbols_icons/symbols.dart"; import "package:material_symbols_icons/symbols.dart";
import "package:island/widgets/stickers/picker.dart"; import "package:island/widgets/stickers/picker.dart";
import "package:island/pods/chat/chat_subscribe.dart";
class ChatInput extends HookConsumerWidget { class ChatInput extends HookConsumerWidget {
final TextEditingController messageController; final TextEditingController messageController;
@@ -55,10 +55,7 @@ class ChatInput extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode(); final inputFocusNode = useFocusNode();
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
final enterToSend = ref.watch(appSettingsNotifierProvider).enterToSend;
final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
void send() { void send() {
onSend.call(); 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 { Future<void> handlePaste() async {
final clipboard = await Pasteboard.image; final clipboard = await Pasteboard.image;
if (clipboard == null) return; if (clipboard == null) return;
@@ -80,213 +89,272 @@ class ChatInput extends HookConsumerWidget {
]); ]);
} }
void handleKeyPress( inputFocusNode.onKeyEvent = (node, event) {
BuildContext context, if (event is! KeyDownEvent) return KeyEventResult.ignored;
WidgetRef ref,
RawKeyEvent event,
) {
if (event is! RawKeyDownEvent) return;
final isPaste = event.logicalKey == LogicalKeyboardKey.keyV; final isPaste = event.logicalKey == LogicalKeyboardKey.keyV;
final isModifierPressed = event.isMetaPressed || event.isControlPressed; final isModifierPressed =
HardwareKeyboard.instance.isMetaPressed ||
HardwareKeyboard.instance.isControlPressed;
if (isPaste && isModifierPressed) { if (isPaste && isModifierPressed) {
handlePaste(); handlePaste();
return; return KeyEventResult.handled;
} }
final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend; final enterToSend = ref.read(appSettingsNotifierProvider).enterToSend;
final isEnter = event.logicalKey == LogicalKeyboardKey.enter; final isEnter = event.logicalKey == LogicalKeyboardKey.enter;
if (isEnter) { if (isEnter) {
if (enterToSend && !isModifierPressed) { if (isModifierPressed) {
send(); insertNewLine();
} else if (!enterToSend && isModifierPressed) { return KeyEventResult.handled;
} else if (enterToSend) {
send(); send();
return KeyEventResult.handled;
} }
} }
}
return Material( return KeyEventResult.ignored;
elevation: 8, };
color: Theme.of(context).colorScheme.surface,
child: Column( final double leftMargin = isWideScreen(context) ? 8 : 16;
children: [ final double rightMargin = isWideScreen(context) ? leftMargin + 8 : 16;
if (attachments.isNotEmpty) const double bottomMargin = 16;
SizedBox(
height: 280, return Container(
child: ListView.separated( margin: EdgeInsets.only(
padding: EdgeInsets.symmetric(horizontal: 12), left: leftMargin,
scrollDirection: Axis.horizontal, right: rightMargin,
itemCount: attachments.length, bottom: bottomMargin,
itemBuilder: (context, idx) { ),
return SizedBox( child: Material(
height: 280, elevation: 2,
width: 280, color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: AttachmentPreview( borderRadius: BorderRadius.circular(32),
item: attachments[idx], child: Padding(
progress: attachmentProgress['chat-upload']?[idx], padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
onRequestUpload: () => onUploadAttachment(idx), child: Column(
onDelete: () => onDeleteAttachment(idx), children: [
onUpdate: (value) { AnimatedSwitcher(
attachments[idx] = value; duration: const Duration(milliseconds: 150),
onAttachmentsChanged(attachments); switchInCurve: Curves.fastEaseInToSlowEaseOut,
}, switchOutCurve: Curves.fastEaseInToSlowEaseOut,
onMove: (delta) => onMoveAttachment(idx, delta), 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), child:
), chatSubscribe.isNotEmpty
).padding(top: 12), ? Container(
if (messageReplyingTo != null || key: const ValueKey('typing-indicator'),
messageForwardingTo != null || width: double.infinity,
messageEditingTo != null) padding: const EdgeInsets.symmetric(
Container( horizontal: 12,
padding: const EdgeInsets.symmetric(horizontal: 16), vertical: 4,
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,
), ),
onPick: (placeholder) { child: Row(
// Insert placeholder at current cursor position children: [
final text = messageController.text; const Icon(
final selection = messageController.selection; Symbols.more_horiz,
final start = size: 16,
selection.start >= 0 ).padding(horizontal: 8),
? selection.start const Gap(8),
: text.length; Expanded(
final end = child: Text(
selection.end >= 0 'typingHint'.plural(
? selection.end chatSubscribe.length,
: text.length; args: [
final newText = text.replaceRange( chatSubscribe
start, .map((x) => x.nick ?? x.account.nick)
end, .join(', '),
placeholder, ],
); ),
messageController.value = TextEditingValue( style: Theme.of(context).textTheme.bodySmall,
text: newText, ),
selection: TextSelection.collapsed(
offset: start + placeholder.length,
), ),
); ],
),
)
: 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);
}, },
); onMove: (delta) => onMoveAttachment(idx, delta),
}, ),
), );
PopupMenuButton( },
icon: const Icon(Symbols.photo_library), separatorBuilder: (_, _) => const Gap(8),
itemBuilder: ),
(context) => [ ).padding(vertical: 12),
PopupMenuItem( if (messageReplyingTo != null ||
onTap: () => onPickFile(true), messageForwardingTo != null ||
child: Row( messageEditingTo != null)
spacing: 12, Container(
children: [ padding: const EdgeInsets.symmetric(
const Icon(Symbols.photo), horizontal: 16,
Text('addPhoto').tr(), vertical: 4,
], ),
), decoration: BoxDecoration(
), color: Theme.of(context).colorScheme.surfaceContainerHigh,
PopupMenuItem( borderRadius: BorderRadius.circular(32),
onTap: () => onPickFile(false), ),
child: Row( margin: const EdgeInsets.only(
spacing: 12, left: 8,
children: [ right: 8,
const Icon(Symbols.video_call), top: 8,
Text('addVideo').tr(), 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( Row(
child: RawKeyboardListener( crossAxisAlignment: CrossAxisAlignment.start,
focusNode: FocusNode(), children: [
onKey: (event) => handleKeyPress(context, ref, event), 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( child: TextField(
focusNode: inputFocusNode, focusNode: inputFocusNode,
controller: messageController, controller: messageController,
onSubmitted: keyboardType: TextInputType.multiline,
(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;
}),
],
decoration: InputDecoration( decoration: InputDecoration(
hintMaxLines: 1,
hintText: hintText:
(chatRoom.type == 1 && chatRoom.name == null) (chatRoom.type == 1 && chatRoom.name == null)
? 'chatDirectMessageHint'.tr( ? 'chatDirectMessageHint'.tr(
@@ -301,7 +369,7 @@ class ChatInput extends HookConsumerWidget {
isDense: true, isDense: true,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 4, vertical: 12,
), ),
counterText: counterText:
messageController.text.length > 1024 messageController.text.length > 1024
@@ -314,16 +382,16 @@ class ChatInput extends HookConsumerWidget {
(_) => FocusManager.instance.primaryFocus?.unfocus(), (_) => FocusManager.instance.primaryFocus?.unfocus(),
), ),
), ),
), IconButton(
IconButton( icon: const Icon(Icons.send),
icon: const Icon(Icons.send), color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, onPressed: send,
onPressed: send, ),
), ],
], ),
).padding(bottom: MediaQuery.of(context).padding.bottom), ],
), ),
], ),
), ),
); );
} }

View File

@@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/models/chat.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:island/widgets/content/markdown.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:pretty_diff_text/pretty_diff_text.dart'; import 'package:pretty_diff_text/pretty_diff_text.dart';
@@ -56,7 +56,7 @@ class MessageContent extends StatelessWidget {
case 'messages.update.links': case 'messages.update.links':
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Icon(
Symbols.edit, Symbols.edit,
@@ -64,27 +64,29 @@ class MessageContent extends StatelessWidget {
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.6), ).colorScheme.onSurfaceVariant.withOpacity(0.6),
), ).padding(top: 2),
const Gap(4), const Gap(4),
if (item.meta['previous_content'] is String) if (item.meta['previous_content'] is String)
PrettyDiffText( Flexible(
oldText: item.meta['previous_content'], child: PrettyDiffText(
newText: item.content ?? 'Edited a message', oldText: item.meta['previous_content'],
defaultTextStyle: Theme.of( newText: item.content ?? 'Edited a message',
context, defaultTextStyle: Theme.of(
).textTheme.bodyMedium!.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
addedTextStyle: TextStyle(
backgroundColor: Theme.of(
context, context,
).colorScheme.primaryFixedDim.withOpacity(0.4), ).textTheme.bodyMedium!.copyWith(
), color: Theme.of(context).colorScheme.onSurfaceVariant,
deletedTextStyle: TextStyle( ),
decoration: TextDecoration.lineThrough, addedTextStyle: TextStyle(
color: Theme.of( backgroundColor: Theme.of(
context, context,
).colorScheme.onSurfaceVariant.withOpacity(0.7), ).colorScheme.primaryFixedDim.withOpacity(0.4),
),
deletedTextStyle: TextStyle(
decoration: TextDecoration.lineThrough,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withOpacity(0.7),
),
), ),
) )
else else
@@ -104,10 +106,12 @@ class MessageContent extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MarkdownTextContent( Flexible(
content: item.content ?? '*${item.type} has no content*', child: MarkdownTextContent(
isSelectable: true, content: item.content ?? '*${item.type} has no content*',
linesMargin: EdgeInsets.zero, isSelectable: true,
linesMargin: EdgeInsets.zero,
),
), ),
if (translatedText?.isNotEmpty ?? false) if (translatedText?.isNotEmpty ?? false)
...([ ...([

View File

@@ -11,10 +11,10 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/database/message.dart'; import 'package:island/database/message.dart';
import 'package:island/models/embed.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/translate.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/screens/chat/room.dart';
import 'package:island/utils/mapping.dart'; import 'package:island/utils/mapping.dart';
import 'package:island/widgets/account/account_pfc.dart'; import 'package:island/widgets/account/account_pfc.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -538,7 +538,7 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center, isMultiline ? CrossAxisAlignment.start : CrossAxisAlignment.center,
children: [ children: [
Text( Text(
DateFormat('HH:mm').format(message.createdAt), DateFormat('HH:mm').format(message.createdAt.toLocal()),
style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12), style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12),
).padding(top: isMultiline ? 2 : 0), ).padding(top: isMultiline ? 2 : 0),
AccountPfcGestureDetector( AccountPfcGestureDetector(

View File

@@ -6,7 +6,7 @@ import "package:gap/gap.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/message.dart"; import "package:island/database/message.dart";
import "package:island/models/chat.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/pods/network.dart";
import "package:island/services/responsive.dart"; import "package:island/services/responsive.dart";
import "package:island/widgets/alert.dart"; import "package:island/widgets/alert.dart";

View File

@@ -1,11 +1,9 @@
import 'dart:developer';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_platform_alert/flutter_platform_alert.dart'; import 'package:flutter_platform_alert/flutter_platform_alert.dart';
import 'package:island/talker.dart';
String _parseRemoteError(DioException err) { String _parseRemoteError(DioException err) {
log('${err.requestOptions.method} ${err.requestOptions.uri} ${err.message}');
String? message; String? message;
if (err.response?.data is String) { if (err.response?.data is String) {
message = err.response?.data; message = err.response?.data;
@@ -30,7 +28,7 @@ String _parseRemoteError(DioException err) {
void showErrorAlert(dynamic err) async { void showErrorAlert(dynamic err) async {
if (err is Error) { if (err is Error) {
log('${err.stackTrace}'); talker.error('Something went wrong...', err, err.stackTrace);
} }
final text = switch (err) { final text = switch (err) {
String _ => err, String _ => err,

View File

@@ -1,13 +1,11 @@
// ignore_for_file: avoid_web_libraries_in_flutter // ignore_for_file: avoid_web_libraries_in_flutter
import 'dart:developer';
import 'dart:js' as js; import 'dart:js' as js;
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
String _parseRemoteError(DioException err) { String _parseRemoteError(DioException err) {
log('${err.requestOptions.method} ${err.requestOptions.uri} ${err.message}');
String? message; String? message;
if (err.response?.data is String) { if (err.response?.data is String) {
message = err.response?.data; message = err.response?.data;

View File

@@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file.dart'; import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/utils/format.dart'; import 'package:island/utils/format.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
@@ -107,13 +108,23 @@ class AttachmentPreview extends HookConsumerWidget {
static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey = static final GlobalKey<SensitiveMarksSelectorState> _sensitiveSelectorKey =
GlobalKey<SensitiveMarksSelectorState>(); GlobalKey<SensitiveMarksSelectorState>();
Future<void> _showRenameDialog(BuildContext context, WidgetRef ref) async { String _getDisplayName() {
final nameController = TextEditingController(text: item.data.name); 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; String? errorMessage;
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true,
builder: builder:
(context) => SheetScaffold( (context) => SheetScaffold(
heightFactor: 0.6, heightFactor: 0.6,
@@ -152,22 +163,32 @@ class AttachmentPreview extends HookConsumerWidget {
return; return;
} }
try { if (item.isOnCloud) {
showLoadingModal(context); try {
final apiClient = ref.watch(apiClientProvider); showLoadingModal(context);
await apiClient.patch( final apiClient = ref.watch(apiClientProvider);
'/drive/files/${item.data.id}/name', await apiClient.patch(
data: jsonEncode(newName), '/drive/files/${item.data.id}/name',
); data: jsonEncode(newName),
final newData = item.data; );
newData.name = newName; final newData = item.data;
final updatedFile = item.copyWith(data: newData); newData.name = newName;
onUpdate?.call(item.copyWith(data: updatedFile)); 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); if (context.mounted) Navigator.pop(context);
} catch (err) {
showErrorAlert(err);
} finally {
if (context.mounted) hideLoadingModal(context);
} }
}, },
child: Text('rename'.tr()), child: Text('rename'.tr()),
@@ -292,6 +313,8 @@ class AttachmentPreview extends HookConsumerWidget {
_ => Symbols.insert_drive_file, _ => Symbols.insert_drive_file,
}; };
final mimeType = FileUploader.getMimeType(item);
if (item.isOnCloud) { if (item.isOnCloud) {
return CloudFileWidget(item: item.data); return CloudFileWidget(item: item.data);
} else if (item.data is XFile) { } else if (item.data is XFile) {
@@ -321,7 +344,12 @@ class AttachmentPreview extends HookConsumerWidget {
children: [ children: [
Icon(fallbackIcon), Icon(fallbackIcon),
const Gap(6), const Gap(6),
Text(file.name, textAlign: TextAlign.center), Text(
_getDisplayName(),
textAlign: TextAlign.center,
),
Text(mimeType, style: TextStyle(fontSize: 10)),
const Gap(1),
FutureBuilder( FutureBuilder(
future: file.length(), future: file.length(),
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -347,6 +375,8 @@ class AttachmentPreview extends HookConsumerWidget {
children: [ children: [
Icon(fallbackIcon), Icon(fallbackIcon),
const Gap(6), const Gap(6),
Text(mimeType, style: TextStyle(fontSize: 10)),
const Gap(1),
Text( Text(
formatFileSize(item.data.length), formatFileSize(item.data.length),
).fontSize(11), ).fontSize(11),
@@ -542,12 +572,20 @@ class AttachmentPreview extends HookConsumerWidget {
onUpdate?.call(item.copyWith(data: result)); 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) if (item.isOnCloud)
MenuAction( MenuAction(
title: 'rename'.tr(), title: 'rename'.tr(),
image: MenuImage.icon(Symbols.edit), image: MenuImage.icon(Symbols.edit),
callback: () async { callback: () async {
await _showRenameDialog(context, ref); await _showRenameSheet(context, ref);
}, },
), ),
if (item.isOnCloud) if (item.isOnCloud)

View File

@@ -1,11 +1,10 @@
import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:island/talker.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@@ -57,7 +56,7 @@ class _UniversalAudioState extends ConsumerState<UniversalAudio> {
String? uri; String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url); final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) { if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url'); talker.info('[MediaPlayer] Miss cache: $url');
final token = ref.watch(tokenProvider)?.token; final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile( DefaultCacheManager().downloadFile(
url, url,
@@ -66,7 +65,7 @@ class _UniversalAudioState extends ConsumerState<UniversalAudio> {
uri = url; uri = url;
} else { } else {
uri = inCacheInfo.file.path; uri = inCacheInfo.file.path;
log('[MediaPlayer] Hit cache: $url'); talker.info('[MediaPlayer] Hit cache: $url');
} }
_player!.open(Media(uri), play: widget.autoplay); _player!.open(Media(uri), play: widget.autoplay);

View File

@@ -6,9 +6,8 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.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/alert.dart';
import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/attachment_preview.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -42,10 +41,6 @@ class CloudFilePicker extends HookConsumerWidget {
Future<void> startUpload() async { Future<void> startUpload() async {
if (files.value.isEmpty) return; 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); List<SnCloudFile> result = List.empty(growable: true);
uploadProgress.value = 0; uploadProgress.value = 0;
@@ -55,19 +50,9 @@ class CloudFilePicker extends HookConsumerWidget {
uploadPosition.value = idx; uploadPosition.value = idx;
final file = files.value[idx]; final file = files.value[idx];
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
fileData: file, fileData: file,
atk: token, client: ref.read(apiClientProvider),
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',
},
onProgress: (progress, _) { onProgress: (progress, _) {
uploadProgress.value = progress; uploadProgress.value = progress;
}, },

View File

@@ -8,11 +8,13 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:island/utils/format.dart'; import 'package:island/utils/format.dart';
import 'package:island/widgets/content/audio.dart'; import 'package:island/widgets/content/audio.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.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:url_launcher/url_launcher_string.dart';
import 'package:island/widgets/data_saving_gate.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; var ratio = meta['ratio'] is num ? (meta['ratio'] as num).toDouble() : 1.0;
if (ratio == 0) ratio = 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 cloudVideo() => CloudVideoWidget(item: item);
Widget dataPlaceHolder(IconData icon) => _DataSavingPlaceholder( Widget dataPlaceHolder(IconData icon) => _DataSavingPlaceholder(
icon: icon, icon: icon,
onTap: () { onTap: () {
unlocked.value = true; 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) { var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' => AspectRatio( 'image' => AspectRatio(
aspectRatio: ratio, aspectRatio: ratio,
child: (useInternalGate && dataSaving && !unlocked.value) ? dataPlaceHolder(Symbols.image) : cloudImage(), child:
), (useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'video' => AspectRatio( 'video' => AspectRatio(
aspectRatio: ratio, aspectRatio: ratio,
child: (useInternalGate && dataSaving && !unlocked.value) ? dataPlaceHolder(Symbols.play_arrow) : cloudVideo(), child:
), (useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.play_arrow)
: cloudVideo(),
),
'audio' => Center( 'audio' => Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8), maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
), ),
child: UniversalAudio(uri: uri, filename: item.name),
), ),
),
_ => Column( _ => Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Symbols.insert_drive_file, Symbols.insert_drive_file,
size: 48, 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, color: Theme.of(context).colorScheme.onSurfaceVariant,
), ),
const Gap(8), ),
Text( Text(
item.name, formatFileSize(item.size),
maxLines: 1, style: TextStyle(
overflow: TextOverflow.ellipsis, fontSize: 12,
style: TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
), ),
Text( ),
formatFileSize(item.size), const Gap(8),
style: TextStyle( TextButton.icon(
fontSize: 12, onPressed: () {
color: Theme.of(context).colorScheme.onSurfaceVariant, launchUrlString(
), 'https://solian.app/files/${item.id}',
), mode: LaunchMode.externalApplication,
const Gap(8), );
TextButton.icon( },
onPressed: () { icon: const Icon(Symbols.launch),
launchUrlString( label: Text('openInBrowser').tr(),
'https://fs.solian.app/files/${item.id}', ),
mode: LaunchMode.externalApplication, ],
); ).padding(all: 8),
},
icon: const Icon(Symbols.launch),
label: Text('openInBrowser').tr(),
),
],
).padding(all: 8),
}; };
if (heroTag != null) { if (heroTag != null) {
@@ -140,8 +209,11 @@ class _DataSavingPlaceholder extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 36, Icon(
color: Theme.of(context).colorScheme.onSurfaceVariant), icon,
size: 36,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const Gap(8), const Gap(8),
Text( Text(
'dataSavingHint'.tr(), 'dataSavingHint'.tr(),
@@ -154,6 +226,7 @@ class _DataSavingPlaceholder extends StatelessWidget {
); );
} }
} }
class CloudVideoWidget extends HookConsumerWidget { class CloudVideoWidget extends HookConsumerWidget {
final SnCloudFile item; final SnCloudFile item;
const CloudVideoWidget({super.key, required this.item}); const CloudVideoWidget({super.key, required this.item});
@@ -352,35 +425,40 @@ class ProfilePictureWidget extends ConsumerWidget {
this.fallbackColor, this.fallbackColor,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider); final serverUrl = ref.watch(serverUrlProvider);
final String? id = file?.id ?? fileId; final String? id = file?.id ?? fileId;
final fallback = Icon( final fallback =
fallbackIcon ?? Symbols.account_circle, Icon(
size: radius, fallbackIcon ?? Symbols.account_circle,
color: fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer, size: radius,
).center(); color:
fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
).center();
return ClipRRect( return ClipRRect(
borderRadius: borderRadius == null borderRadius:
? BorderRadius.all(Radius.circular(radius)) borderRadius == null
: BorderRadius.all(Radius.circular(borderRadius!)), ? BorderRadius.all(Radius.circular(radius))
: BorderRadius.all(Radius.circular(borderRadius!)),
child: Container( child: Container(
width: radius * 2, width: radius * 2,
height: radius * 2, height: radius * 2,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: id == null child:
? fallback id == null
: DataSavingGate( ? fallback
bypass: true, : DataSavingGate(
placeholder: fallback, bypass: true,
content: () => UniversalImage( placeholder: fallback,
uri: '$serverUrl/drive/files/$id', content:
fit: BoxFit.cover, () => UniversalImage(
uri: '$serverUrl/drive/files/$id',
fit: BoxFit.cover,
),
), ),
),
), ),
); );
} }

View File

@@ -1,10 +1,10 @@
import 'dart:developer';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
@@ -37,7 +37,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
String? uri; String? uri;
final inCacheInfo = await DefaultCacheManager().getFileFromCache(url); final inCacheInfo = await DefaultCacheManager().getFileFromCache(url);
if (inCacheInfo == null) { if (inCacheInfo == null) {
log('[MediaPlayer] Miss cache: $url'); talker.info('[MediaPlayer] Miss cache: $url');
final token = ref.watch(tokenProvider)?.token; final token = ref.watch(tokenProvider)?.token;
DefaultCacheManager().downloadFile( DefaultCacheManager().downloadFile(
url, url,
@@ -46,7 +46,7 @@ class _UniversalVideoState extends ConsumerState<UniversalVideo> {
uri = url; uri = url;
} else { } else {
uri = inCacheInfo.file.path; uri = inCacheInfo.file.path;
log('[MediaPlayer] Hit cache: $url'); talker.info('[MediaPlayer] Hit cache: $url');
} }
_player!.open(Media(uri), play: widget.autoplay); _player!.open(Media(uri), play: widget.autoplay);

View File

@@ -1,14 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/message.dart'; import 'package:island/pods/message.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/websocket.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/network_status_sheet.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:island/pods/config.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 { Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
final TextEditingController controller = TextEditingController(); final TextEditingController controller = TextEditingController();
@@ -65,69 +70,112 @@ class DebugSheet extends HookConsumerWidget {
return SheetScaffold( return SheetScaffold(
titleText: 'Debug', titleText: 'Debug',
heightFactor: 0.6, heightFactor: 0.6,
child: Column( child: SingleChildScrollView(
children: [ child: Column(
ListTile( children: [
minTileHeight: 48, const Gap(4),
leading: const Icon(Symbols.wifi), ListTile(
trailing: const Icon(Symbols.chevron_right), minTileHeight: 48,
title: Text('Connection Status'), leading: const Icon(Symbols.update),
contentPadding: EdgeInsets.symmetric(horizontal: 24), trailing: const Icon(Symbols.chevron_right),
onTap: () { title: Text('Force Update'),
showModalBottomSheet( contentPadding: const EdgeInsets.symmetric(horizontal: 24),
context: context, onTap: () async {
isScrollControlled: true, // Fetch latest release and show the unified sheet
builder: final svc = UpdateService();
(context) => NetworkStatusSheet( // Reuse service fetch + compare to decide content
onReconnect: () => wsNotifier.connect(), showLoadingModal(context);
), final release = await svc.fetchLatestRelease();
); if (!context.mounted) return;
}, hideLoadingModal(context);
), if (release != null) {
const Divider(height: 1), await svc.showUpdateSheet(context, release);
ListTile( } else {
minTileHeight: 48, showInfoAlert(
leading: const Icon(Symbols.copy_all), 'Currently cannot get update from the GitHub.',
trailing: const Icon(Symbols.chevron_right), 'Unable to check for updates',
contentPadding: EdgeInsets.symmetric(horizontal: 24), );
title: Text('Copy access token'), }
onTap: () async { }
final tk = ref.watch(tokenProvider); ),
Clipboard.setData(ClipboardData(text: tk!.token)); const Divider(height: 8),
}, ListTile(
), minTileHeight: 48,
ListTile( leading: const Icon(Symbols.wifi),
minTileHeight: 48, trailing: const Icon(Symbols.chevron_right),
leading: const Icon(Symbols.edit), title: Text('Connection Status'),
trailing: const Icon(Symbols.chevron_right), contentPadding: EdgeInsets.symmetric(horizontal: 24),
contentPadding: EdgeInsets.symmetric(horizontal: 24), onTap: () {
title: Text('Set access token'), showModalBottomSheet(
onTap: () async { context: context,
await _showSetTokenDialog(context, ref); isScrollControlled: true,
}, builder:
), (context) => NetworkStatusSheet(
const Divider(height: 1), onReconnect: () => wsNotifier.connect(),
ListTile( ),
minTileHeight: 48, );
leading: const Icon(Symbols.delete), },
trailing: const Icon(Symbols.chevron_right), ),
contentPadding: EdgeInsets.symmetric(horizontal: 24), const Divider(height: 8),
title: Text('Reset database'), ListTile(
onTap: () async { minTileHeight: 48,
resetDatabase(ref); leading: const Icon(Symbols.bug_report),
}, trailing: const Icon(Symbols.chevron_right),
), title: Text('Logs'),
ListTile( contentPadding: EdgeInsets.symmetric(horizontal: 24),
minTileHeight: 48, onTap: () {
leading: const Icon(Symbols.clear), Navigator.of(context).push(
trailing: const Icon(Symbols.chevron_right), MaterialPageRoute(
contentPadding: EdgeInsets.symmetric(horizontal: 24), builder: (context) => TalkerScreen(talker: talker),
title: Text('Clear cache'), ),
onTap: () async { );
DefaultCacheManager().emptyCache(); },
}, ),
), 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();
},
),
],
),
), ),
); );
} }

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

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

View File

@@ -1,6 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -9,6 +7,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -66,7 +65,7 @@ class ComposeRecorder extends HookConsumerWidget {
useEffect(() { useEffect(() {
return () { return () {
// Called when widget is unmounted // Called when widget is unmounted
log('[Recorder] Clean up!'); talker.info('[Recorder] Clean up!');
originalAmplitude?.cancel(); originalAmplitude?.cancel();
amplitudeStream.close(); amplitudeStream.close();
record.dispose(); record.dispose();

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:dio/dio.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/post_category.dart';
import 'package:island/models/publisher.dart'; import 'package:island/models/publisher.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/config.dart';
import 'package:island/pods/network.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/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_link_attachments.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:island/pods/file_pool.dart';
import 'package:pasteboard/pasteboard.dart'; import 'package:pasteboard/pasteboard.dart';
import 'package:textfield_tags/textfield_tags.dart'; import 'package:textfield_tags/textfield_tags.dart';
import 'dart:async'; import 'package:island/talker.dart';
import 'dart:developer';
class ComposeState { class ComposeState {
final TextEditingController titleController; final TextEditingController titleController;
@@ -176,25 +176,14 @@ class ComposeLogic {
try { try {
// Upload any local attachments first // 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++) { for (int i = 0; i < state.attachments.value.length; i++) {
final attachment = state.attachments.value[i]; final attachment = state.attachments.value[i];
if (attachment.data is! SnCloudFile) { if (attachment.data is! SnCloudFile) {
try { try {
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: attachment, 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; ).future;
if (cloudFile != null) { if (cloudFile != null) {
// Update attachments list with cloud file // Update attachments list with cloud file
@@ -203,7 +192,7 @@ class ComposeLogic {
state.attachments.value = clone; state.attachments.value = clone;
} }
} catch (err) { } 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 // Continue with other attachments even if one fails
} }
} }
@@ -263,7 +252,7 @@ class ComposeLogic {
await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft); await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) { } 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); await ref.read(composeStorageNotifierProvider.notifier).saveDraft(draft);
} catch (e) { } 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()); showSnackBar('draftSaved'.tr());
} }
} catch (e) { } catch (e) {
log('[ComposeLogic] Failed to save draft manually, error: $e'); talker.error('[ComposeLogic] Failed to save draft manually, error: $e');
if (context.mounted) { if (context.mounted) {
showSnackBar('draftSaveFailed'.tr()); showSnackBar('draftSaveFailed'.tr());
} }
@@ -506,15 +497,11 @@ class ComposeLogic {
WidgetRef ref, WidgetRef ref,
ComposeState state, ComposeState state,
int index, { int index, {
String? poolId, // For Unit Test String? poolId,
}) async { }) async {
final attachment = state.attachments.value[index]; final attachment = state.attachments.value[index];
if (attachment.isOnCloud) return; 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 { try {
state.attachmentProgress.value = { state.attachmentProgress.value = {
...state.attachmentProgress.value, ...state.attachmentProgress.value,
@@ -527,19 +514,10 @@ class ComposeLogic {
final selectedPoolId = resolveDefaultPoolId(ref, pools); final selectedPoolId = resolveDefaultPoolId(ref, pools);
cloudFile = cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
fileData: attachment, fileData: attachment,
atk: token, poolId: poolId ?? selectedPoolId,
baseUrl: baseUrl,
poolId: selectedPoolId,
filename:
attachment.data.name ??
(attachment.type == UniversalFileType.file
? 'General file'
: 'Post media'),
mimetype:
attachment.data.mimeType ??
getMimeTypeFromFileType(attachment.type),
mode: mode:
attachment.type == UniversalFileType.file attachment.type == UniversalFileType.file
? FileUploadMode.generic ? FileUploadMode.generic
@@ -560,7 +538,7 @@ class ComposeLogic {
clone[index] = UniversalFile(data: cloudFile, type: attachment.type); clone[index] = UniversalFile(data: cloudFile, type: attachment.type);
state.attachments.value = clone; state.attachments.value = clone;
} catch (err) { } catch (err) {
showErrorAlert(err.toString()); showErrorAlert(err);
} finally { } finally {
state.attachmentProgress.value = {...state.attachmentProgress.value} state.attachmentProgress.value = {...state.attachmentProgress.value}
..remove(index); ..remove(index);

View File

@@ -12,8 +12,14 @@ import 'package:styled_widget/styled_widget.dart';
class ComposeToolbar extends HookConsumerWidget { class ComposeToolbar extends HookConsumerWidget {
final ComposeState state; final ComposeState state;
final SnPost? originalPost; 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 @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -49,6 +55,7 @@ class ComposeToolbar extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeEmbedSheet(state: state), builder: (context) => ComposeEmbedSheet(state: state),
); );
} }
@@ -57,6 +64,7 @@ class ComposeToolbar extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true,
builder: builder:
(context) => DraftManagerSheet( (context) => DraftManagerSheet(
onDraftSelected: (draftId) { onDraftSelected: (draftId) {
@@ -74,6 +82,160 @@ class ComposeToolbar extends HookConsumerWidget {
final colorScheme = Theme.of(context).colorScheme; 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( return Material(
elevation: 4, elevation: 4,
color: Theme.of(context).colorScheme.surfaceContainerLow, color: Theme.of(context).colorScheme.surfaceContainerLow,

View File

@@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -118,25 +119,9 @@ class DraftManagerSheet extends HookConsumerWidget {
Expanded( Expanded(
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () async { onPressed: () async {
final confirmed = await showDialog<bool>( final confirmed = await showConfirmAlert(
context: context, 'clearAllDraftsConfirm'.tr(),
builder: 'clearAllDrafts'.tr(),
(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()),
),
],
),
); );
if (confirmed == true) { if (confirmed == true) {

View File

@@ -102,72 +102,75 @@ class PostFeaturedList extends HookConsumerWidget {
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
child: Column( child: Column(
children: [ children: [
Row( SizedBox(
spacing: 8, height: 48,
children: [ child: Row(
const Icon(Symbols.highlight), spacing: 8,
const Text('highlightPost').tr(), children: [
Spacer(), const Icon(Symbols.highlight),
IconButton( const Text('highlightPost').tr(),
padding: EdgeInsets.zero, Spacer(),
visualDensity: VisualDensity.compact, IconButton(
constraints: const BoxConstraints(), padding: EdgeInsets.zero,
onPressed: () { visualDensity: VisualDensity.compact,
pageViewController.animateToPage( constraints: const BoxConstraints(),
pageViewCurrent.value - 1, onPressed: () {
duration: const Duration(milliseconds: 250), pageViewController.animateToPage(
curve: Curves.easeInOut, 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,
); );
debugPrint( },
'PostFeaturedList: Stored collapsed ID: ${featuredPostsAsync.value!.first.id}', icon: const Icon(Symbols.arrow_left),
);
} else {
prefs.remove(kFeaturedPostsCollapsedId);
debugPrint(
'PostFeaturedList: Removed stored collapsed ID.',
);
}
},
icon: Icon(
isCollapsed.value
? Symbols.expand_more
: Symbols.expand_less,
), ),
), IconButton(
], padding: EdgeInsets.zero,
).padding(horizontal: 16, vertical: 8), 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( AnimatedSize(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,

View File

@@ -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/post/embed_view_renderer.dart';
import 'package:island/widgets/safety/abuse_report_helper.dart'; import 'package:island/widgets/safety/abuse_report_helper.dart';
import 'package:island/widgets/share/share_sheet.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:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart' show getTemporaryDirectory; import 'package:path_provider/path_provider.dart' show getTemporaryDirectory;
import 'package:screenshot/screenshot.dart'; import 'package:screenshot/screenshot.dart';
@@ -174,14 +175,14 @@ class PostActionableItem extends HookConsumerWidget {
MenuAction( MenuAction(
title: 'edit'.tr(), title: 'edit'.tr(),
image: MenuImage.icon(Symbols.edit), image: MenuImage.icon(Symbols.edit),
callback: () { callback: () async {
context final result = await PostComposeDialog.show(
.pushNamed('postEdit', pathParameters: {'id': item.id}) context,
.then((value) { originalPost: item,
if (value != null) { );
onRefresh?.call(); if (result != null) {
} onRefresh?.call();
}); }
}, },
), ),
if (isAuthor) if (isAuthor)
@@ -221,21 +222,27 @@ class PostActionableItem extends HookConsumerWidget {
MenuAction( MenuAction(
title: 'reply'.tr(), title: 'reply'.tr(),
image: MenuImage.icon(Symbols.reply), image: MenuImage.icon(Symbols.reply),
callback: () { callback: () async {
context.pushNamed( final result = await PostComposeDialog.show(
'postCompose', context,
extra: PostComposeInitialState(replyingTo: item), initialState: PostComposeInitialState(replyingTo: item),
); );
if (result != null) {
onRefresh?.call();
}
}, },
), ),
MenuAction( MenuAction(
title: 'forward'.tr(), title: 'forward'.tr(),
image: MenuImage.icon(Symbols.forward), image: MenuImage.icon(Symbols.forward),
callback: () { callback: () async {
context.pushNamed( final result = await PostComposeDialog.show(
'postCompose', context,
extra: PostComposeInitialState(forwardingTo: item), initialState: PostComposeInitialState(forwardingTo: item),
); );
if (result != null) {
onRefresh?.call();
}
}, },
), ),
if (isAuthor && item.pinMode == null) if (isAuthor && item.pinMode == null)

View File

@@ -1,5 +1,6 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -80,9 +81,8 @@ class PostItemCreator extends HookConsumerWidget {
title: 'copyLink'.tr(), title: 'copyLink'.tr(),
image: MenuImage.icon(Symbols.link), image: MenuImage.icon(Symbols.link),
callback: () { callback: () {
context.pushNamed( Clipboard.setData(
'postDetail', ClipboardData(text: 'https://solian.app/posts/${item.id}'),
pathParameters: {'id': item.id},
); );
}, },
), ),
@@ -95,7 +95,7 @@ class PostItemCreator extends HookConsumerWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
onTap: () { onTap: () {
if (isOpenable) { if (isOpenable) {
context.goNamed('postDetail', pathParameters: {'id': item.id}); context.pushNamed('postDetail', pathParameters: {'id': item.id});
} }
}, },
child: Column( child: Column(

View File

@@ -1,8 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/alert.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.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/models/file.dart';
import 'package:island/pods/link_preview.dart'; import 'package:island/pods/link_preview.dart';
import 'package:island/pods/network.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 'package:mime/mime.dart';
import 'dart:io';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
@@ -192,7 +190,6 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
setState(() => _isLoading = true); setState(() => _isLoading = true);
try { try {
final apiClient = ref.read(apiClientProvider); final apiClient = ref.read(apiClientProvider);
final serverUrl = ref.read(serverUrlProvider);
String content = _messageController.text.trim(); String content = _messageController.text.trim();
List<String> attachmentIds = []; List<String> attachmentIds = [];
@@ -216,11 +213,6 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
case ShareContentType.file: case ShareContentType.file:
// Upload files to cloud storage // Upload files to cloud storage
if (widget.content.files?.isNotEmpty == true) { if (widget.content.files?.isNotEmpty == true) {
final token = ref.watch(tokenProvider)?.token;
if (token == null) {
throw Exception('Authentication required');
}
final universalFiles = final universalFiles =
widget.content.files!.map((file) { widget.content.files!.map((file) {
UniversalFileType fileType; UniversalFileType fileType;
@@ -247,19 +239,9 @@ class _ShareSheetState extends ConsumerState<ShareSheet> {
for (var idx = 0; idx < universalFiles.length; idx++) { for (var idx = 0; idx < universalFiles.length; idx++) {
final file = universalFiles[idx]; final file = universalFiles[idx];
final cloudFile = final cloudFile =
await putFileToCloud( await FileUploader.createCloudFile(
client: apiClient,
fileData: file, 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, _) { onProgress: (progress, _) {
if (mounted) { if (mounted) {
setState(() { setState(() {

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