Compare commits
107 Commits
2.3.2+70
...
e16bc80eea
| Author | SHA1 | Date | |
|---|---|---|---|
| e16bc80eea | |||
| a4f6e8af56 | |||
| 060a97f5ec | |||
| 92f7e92018 | |||
| 5c483bd3b8 | |||
| 1c510d63fe | |||
| 115cb4adc1 | |||
| 54c098c274 | |||
| 29731728cd | |||
| 9e8882c580 | |||
| 6042e57e7a | |||
| 6235e736b9 | |||
| e075804782 | |||
| d40a6ca1c4 | |||
| 5ac657e526 | |||
| 97ddc18b8e | |||
| b835c8edea | |||
| 288c0399f9 | |||
| 1478933cf1 | |||
| 93c6fa6e53 | |||
| ce6e9c185a | |||
| cdaa8cfe58 | |||
| 76d8cd943d | |||
| d6f3ffc655 | |||
| 5a6b841253 | |||
| cb2de52bee | |||
| 64e2644745 | |||
| 56711889ab | |||
| 4f47cd2c0c | |||
| 2b61c372f5 | |||
| 73777fe74e | |||
| 33a4bd7e71 | |||
| 17e6b81f76 | |||
| 22fde6b400 | |||
| 6e03a00280 | |||
| 72e6a6a1f6 | |||
| 66aef44281 | |||
| 7bb73c80b0 | |||
| d043ef2410 | |||
| 1d0e2f7591 | |||
| e9ef28d764 | |||
| 289aa17a7a | |||
| 93f41bb523 | |||
| 09ec9d4a0c | |||
| 1153fbdeee | |||
| e933058338 | |||
| ae9743c84f | |||
| 32bf834108 | |||
| 1b41c847a6 | |||
| b1af6c2c97 | |||
| 8e76ff3f84 | |||
| bd26602299 | |||
| 52ab1d0d10 | |||
| f746e06f65 | |||
| d11069a2be | |||
| d6dc487d9e | |||
| a07c7cdede | |||
| acbc125dec | |||
| ad0ee971c1 | |||
| 52d6bb083e | |||
| 2027eab49b | |||
| 566ebde1dd | |||
| 9e039cc532 | |||
| c4b95d7084 | |||
| a66129a9ba | |||
| 44e1a8bf67 | |||
| efcfd3f57d | |||
| 84759715a4 | |||
| fda09382dd | |||
| 2c5dd0563a | |||
| 5bdd8e94fa | |||
| 2a53031c9a | |||
| e8bc7261f3 | |||
| 997934f680 | |||
| 26e69d6264 | |||
| 153eabcbf2 | |||
| 6d0145c335 | |||
| 81a79f9476 | |||
| 537f404fe0 | |||
| eb29f76b9a | |||
| 56816dc060 | |||
| 899d5f3e5e | |||
| c8c455bb57 | |||
| 5468fc0748 | |||
| 78516abf2e | |||
| 0424f98eb5 | |||
| 2188b8b2e2 | |||
| 0bf614a75c | |||
| 9f21f744a4 | |||
| b94cda6205 | |||
| 3c0e4046a4 | |||
| 338c22a606 | |||
| 25dd895e0d | |||
| ea9ef9e82a | |||
| edd86eda77 | |||
| 671b857a79 | |||
| 408fd0f35e | |||
| 30184d08b1 | |||
|
|
95f257c47a | ||
|
|
41297c6712 | ||
| a8e0ade0c8 | |||
| 3338e699c4 | |||
| e07da3efa5 | |||
| 4f7f015250 | |||
| 2a4c15d0dc | |||
| 70ef894ec5 | |||
| bb9179d5f9 |
87
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
87
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us address issues you are facing
|
||||
title: "[Bug] "
|
||||
labels: [Bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to make us better!
|
||||
|
||||
- type: checkboxes
|
||||
id: duplication
|
||||
attributes:
|
||||
label: ⠀
|
||||
options:
|
||||
- label: This issue is not duplicated with any other open or closed issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: |
|
||||
Example:
|
||||
App crashes on startup every time after changing settings.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A clear and concise description of what you expected to happen
|
||||
placeholder: |
|
||||
Example:
|
||||
App started normally, everything worked fine.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Steps to reproduce the bug
|
||||
placeholder: |
|
||||
Example:
|
||||
1. Change "HyperNet Server" to "127.0.1" in "Network" settings
|
||||
2. Restart the app
|
||||
3. Crash
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Device information
|
||||
description: Provide details about your system environment
|
||||
placeholder: |
|
||||
Example:
|
||||
Device: Google Pixel 8 Pro
|
||||
System: Baklava (BP22.250124.009)
|
||||
Version*: 2.3.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: If applicable, add screenshots to help explain your problem
|
||||
placeholder: |
|
||||
Example:
|
||||
setting_items.jpg
|
||||
crash_screen.jpg
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here
|
||||
placeholder: |
|
||||
Crash report or other useful informations
|
||||
validations:
|
||||
required: false
|
||||
83
.github/ISSUE_TEMPLATE/bug_report_zh.yaml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/bug_report_zh.yaml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: 问题反馈
|
||||
description: 提交 Bug 或其它问题的反馈
|
||||
title: "[Bug] 标题"
|
||||
labels: [Bug]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
非常感谢,你将要提交的反馈会让我们变得更好!
|
||||
|
||||
- type: checkboxes
|
||||
id: duplication
|
||||
attributes:
|
||||
label: ⠀
|
||||
options:
|
||||
- label: 我已经搜索并确认此 issue 不与其它任何 issue 重复
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 清楚且详细地描述你遇到的 Bug 或问题
|
||||
placeholder: |
|
||||
发生了什么?生动地描述你所看到的一切
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望表现
|
||||
description: 清楚且详细地描述你期望发生的事
|
||||
placeholder: |
|
||||
什么功能应该正常运行,运行后会有什么结果
|
||||
什么界面应该正常显示,应该会显示什么内容
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 能够复现问题的每一步
|
||||
placeholder: |
|
||||
1. 尽可能详细地描述每一步
|
||||
2. 更改的设置、添加的好友...
|
||||
3. 这里也可以描述你看到的界面
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: 环境/版本
|
||||
description: 提供运行时的环境信息
|
||||
placeholder: |
|
||||
示例:
|
||||
设备型号: Google Pixel 8 Pro
|
||||
系统板本: Baklava (BP22.250124.009)
|
||||
程序版本: 2.3.2
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: screenshots
|
||||
attributes:
|
||||
label: 屏幕截图/录制
|
||||
description: 提供截屏或录屏来更好地描述问题
|
||||
placeholder: |
|
||||
错误显示的界面/崩溃时的界面、先前改动的设置
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 更多信息
|
||||
description: 任何与问题有关且有用的信息
|
||||
placeholder: |
|
||||
崩溃报告、日志,或是你的用户名
|
||||
validations:
|
||||
required: false
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Solsynth Releases
|
||||
url: https://files.solsynth.dev/production01/solian
|
||||
about: Another place to download released apps
|
||||
59
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
59
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
name: Feature request
|
||||
description: Suggest features you want to add or suggest to modify existing features
|
||||
title: "[Feature] "
|
||||
labels: [Feature]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to make us better!
|
||||
|
||||
- type: checkboxes
|
||||
id: duplication
|
||||
attributes:
|
||||
label: ⠀
|
||||
options:
|
||||
- label: This issue is not duplicated with any other open or closed issues
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Describe the feature
|
||||
description: A clear and concise description of what the feature is
|
||||
placeholder: |
|
||||
Example:
|
||||
A Quick Settings tile to start the service, long press to launch the app.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reasons
|
||||
attributes:
|
||||
label: Reason for adding
|
||||
description: Explain why this feature would be useful to you
|
||||
placeholder: |
|
||||
Example:
|
||||
Start the service quickly from the Quick Settings tile and save lots of time.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Example(s)
|
||||
description: Post screenshots/drawings/links/etc of the feature request, or proof-of-concept images about the feature
|
||||
placeholder: |
|
||||
Example:
|
||||
shazam_toggle.jpg
|
||||
nekobox_switch.jpg
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the feature here
|
||||
validations:
|
||||
required: false
|
||||
49
.github/ISSUE_TEMPLATE/feature_request_zh.yaml
vendored
Normal file
49
.github/ISSUE_TEMPLATE/feature_request_zh.yaml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
name: 功能建议
|
||||
description: 提出你想要添加或更改的功能
|
||||
title: "[Feature] 标题"
|
||||
labels: [Feature]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
非常感谢,你将要提交的请求会让我们变得更好!
|
||||
|
||||
- type: checkboxes
|
||||
id: duplication
|
||||
attributes:
|
||||
label: ⠀
|
||||
options:
|
||||
- label: 我已经搜索并确认此 issue 不与其它任何 issue 重复
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 功能描述
|
||||
description: 清楚且详细地描述要添加/更改后的功能
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reasons
|
||||
attributes:
|
||||
label: 添加/更改理由
|
||||
description: 解释为什么要这样做,对用户有什么好处
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: 功能示例
|
||||
description: 相似/已存在功能的截图,或画出大致的界面
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: 更多信息
|
||||
description: 任何与功能有关且有用的信息,或已存在功能的代码/仓库
|
||||
validations:
|
||||
required: false
|
||||
1
.github/workflows/nightly.yml
vendored
1
.github/workflows/nightly.yml
vendored
@@ -55,6 +55,7 @@ jobs:
|
||||
sudo apt-get install libmpv-dev mpv
|
||||
sudo apt-get install libayatana-appindicator3-dev
|
||||
sudo apt-get install keybinder-3.0
|
||||
sudo apt-get install libnotify-dev
|
||||
- run: flutter pub get
|
||||
- run: flutter build linux
|
||||
- name: Archive production artifacts
|
||||
|
||||
11
api/Nexus/Check Status.bru
Normal file
11
api/Nexus/Check Status.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Check Status
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{endpoint}}/directory/status
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
11
api/Nexus/List Services.bru
Normal file
11
api/Nexus/List Services.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: List Services
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{endpoint}}/directory/services
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -12,9 +12,9 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"alias": "BaLoading",
|
||||
"name": "BaLoading",
|
||||
"attachment_id": "2JCI2uh21mKkfk9P",
|
||||
"pack_id": 3
|
||||
"alias": "Deadge",
|
||||
"name": "Dead",
|
||||
"attachment_id": "pcbFd0u4zgdM39HM",
|
||||
"pack_id": 4
|
||||
}
|
||||
}
|
||||
|
||||
18
api/Passport/Deal Abuse Report.bru
Normal file
18
api/Passport/Deal Abuse Report.bru
Normal file
@@ -0,0 +1,18 @@
|
||||
meta {
|
||||
name: Deal Abuse Report
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
put {
|
||||
url: {{endpoint}}/cgi/id/reports/abuse/3/status
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"status": "processed",
|
||||
"message": "相关附件已经进行评级处理,未来会将该项权限下放到帖主以及社区成员。"
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{endpoint}}/cgi/id/dev/notify/122
|
||||
url: {{endpoint}}/cgi/id/dev/notify/328
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
@@ -15,9 +15,9 @@ body:json {
|
||||
"client_id": "{{third_client_id}}",
|
||||
"client_secret":"{{third_client_tk}}",
|
||||
"type": "general",
|
||||
"subject": "处理该帐号 @solian 的决定",
|
||||
"subtitle": "违反用户协议",
|
||||
"content": "您的帐号违反了我们用户协议中关于冒充我们官方的行为,至此做出停权的决定。还请见谅。该决定是最终决定,不接受上诉。",
|
||||
"subject": "处理该发布者 @vedal987 的决定",
|
||||
"subtitle": "一条来自 Solar Network 客户支持的信息",
|
||||
"content": "您的发布者违反了我们用户协议中的「禁止冒充他人」的相关条例,经管理决定,将相关内容隐藏。冒充他人的判定无论作者是否有主观意志,只要造成了误解我们就有责任处理。希望您能理解,本次决定未作出任何帐号相关的连带处罚。",
|
||||
"priority": 10
|
||||
}
|
||||
}
|
||||
|
||||
BIN
assets/fonts/Nunito-Bold.ttf
Executable file
BIN
assets/fonts/Nunito-Bold.ttf
Executable file
Binary file not shown.
BIN
assets/fonts/Nunito-Italic.ttf
Executable file
BIN
assets/fonts/Nunito-Italic.ttf
Executable file
Binary file not shown.
BIN
assets/fonts/Nunito-Regular.ttf
Executable file
BIN
assets/fonts/Nunito-Regular.ttf
Executable file
Binary file not shown.
@@ -153,6 +153,11 @@
|
||||
"publisherRunBy": "Run by {}",
|
||||
"fieldPublisherBelongToRealm": "Belongs to",
|
||||
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
|
||||
"writePost": "Compose",
|
||||
"postTypeStory": "Story",
|
||||
"postTypeArticle": "Article",
|
||||
"postTypeQuestion": "Question",
|
||||
"postTypeVideo": "Video",
|
||||
"writePostTypeStory": "Post a story",
|
||||
"writePostTypeArticle": "Write an article",
|
||||
"writePostTypeQuestion": "Ask a question",
|
||||
@@ -203,6 +208,11 @@
|
||||
"other": "{} comments"
|
||||
},
|
||||
"settingsAppearance": "Appearance",
|
||||
"settingsCustomFonts": "Custom Fonts",
|
||||
"settingsCustomFontsDescription": "Set custom fonts for the application.",
|
||||
"settingsCustomFontFamily": "Custom Font Family",
|
||||
"settingsCustomFontFamilyHint": "Use comma to separate fonts, higher priority comes first",
|
||||
"settingsCustomFontApplied": "Custom font has been applied.",
|
||||
"settingsDisplayLanguage": "Display Language",
|
||||
"settingsDisplayLanguageDescription": "Set the application language.",
|
||||
"settingsDisplayLanguageSystem": "Follow System",
|
||||
@@ -512,8 +522,13 @@
|
||||
"accountBirthday": "Born on {}",
|
||||
"accountBadge": "Badge",
|
||||
"accountCheckInNoRecords": "No check-in records",
|
||||
"badgeCompanyStaff": "Solsynth Staff",
|
||||
"badgeCompanyStaff": "Staff",
|
||||
"badgeSiteMigration": "Solar Network Native",
|
||||
"badgeCommunitySurvey": "Survey Participant",
|
||||
"badgeCommunityVerified": "Verified User",
|
||||
"badgeCommunityContributor": "Great Contributor",
|
||||
"badgeSiteAnniversary": "Anniversary",
|
||||
"badgeUserBirthday": "Birthday",
|
||||
"accountStatus": "Status",
|
||||
"accountStatusOnline": "Online",
|
||||
"accountStatusOffline": "Offline",
|
||||
@@ -548,6 +563,7 @@
|
||||
"termAcceptNextWithAgree": "By clicking the \"Next\", it means you agree to our terms and its updates.",
|
||||
"unauthorized": "Unauthorized",
|
||||
"unauthorizedDescription": "Login to explore the entire Solar Network.",
|
||||
"projectDetail": "Project Details",
|
||||
"serviceStatus": "Service Status",
|
||||
"termRelated": "Related Terms",
|
||||
"appDetails": "App Details",
|
||||
@@ -583,6 +599,7 @@
|
||||
"colorSchemeBlack": "Black",
|
||||
"colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
|
||||
"postFeaturedComment": "Featured Comment",
|
||||
"postCategory": "Category",
|
||||
"postCategoryTechnology": "Technology",
|
||||
"postCategoryGaming": "Gaming",
|
||||
"postCategoryLife": "Life",
|
||||
@@ -625,6 +642,7 @@
|
||||
"realmJoin": "Join Realm",
|
||||
"realmCommunityHint": "This realm is a community realm, you can freely join.",
|
||||
"realmCommunityPublicChannelsHint": "The public channels in this realm",
|
||||
"realmCommunityPublishersHint": "The publishers in this realm",
|
||||
"realmJoined": "Joined realm {}.",
|
||||
"join": "Join",
|
||||
"pollEditorNew": "New Poll",
|
||||
@@ -665,5 +683,91 @@
|
||||
"zero": "No views",
|
||||
"one": "{} view",
|
||||
"other": "{} views"
|
||||
}
|
||||
},
|
||||
"attachmentBillingUploaded": "Used space",
|
||||
"attachmentBillingDiscount": "Free space",
|
||||
"attachmentBillingRatio": "Usage",
|
||||
"attachmentBillingHint": "Sliding Window Pricing®\nFees will only apply if the size of the file uploaded within 24 hours exceeds the free space.",
|
||||
"postThumbnail": "Post Thumbnail",
|
||||
"accountRealms": "Realms",
|
||||
"postInGlobal": "Global",
|
||||
"postInGlobalDescription": "Do not link this post with any realm.",
|
||||
"postChannelGlobal": "Global",
|
||||
"postChannelFriends": "Friends",
|
||||
"postChannelFollowing": "Following",
|
||||
"postChannelRealm": "Realms",
|
||||
"postFilterReset": "Reset Filter",
|
||||
"postFilterResetDescription": "Clear filter and show all posts.",
|
||||
"postFilterWithCategory": "Viewing posts in {}",
|
||||
"databaseSize": "Database Size",
|
||||
"databaseDelete": "Delete Database",
|
||||
"databaseDeleteDescription": "Remove the database on your local disk, the content will be fetched from server again.",
|
||||
"databaseDeleted": "The local database has been deleted.",
|
||||
"settingsEnablePushNotifications": "Enable Push Notifications",
|
||||
"settingsEnablePushNotificationsDescription": "Re-enable and request permission to receive push notifications. Just in case it didn't run automatically.",
|
||||
"settingsEnabledPushNotifications": "Push notification has been enabled.",
|
||||
"screenStickers": "Stickers",
|
||||
"stickersDiscovery": "Discovery",
|
||||
"stickersOwned": "Owned",
|
||||
"stickersCreated": "Created",
|
||||
"stickersAdd": "Add Sticker Pack",
|
||||
"stickersAdded": "Sticker pack has been added.",
|
||||
"add": "Add",
|
||||
"stickersRemoved": "Sticker pack has been removed, you can add it again anytime.",
|
||||
"stickersReload": "Reload Stickers",
|
||||
"stickersReloadDescription": "Reload stickers from the server, update the sticker picker.",
|
||||
"stickersReloaded": "Sticker packs has been reloaded.",
|
||||
"stickersPackDelete": "Delete Pack {}",
|
||||
"stickersPackDeleteDescription": "Are you sure you want to delete this sticker pack? This operation is irreversible.",
|
||||
"stickersPackDeleted": "Sticker pack has been deleted.",
|
||||
"stickersDelete": "Delete Sticker {}",
|
||||
"stickersDeleteDescription": "Are you sure you want to delete this sticker? This operation is irreversible.",
|
||||
"stickersDeleted": "Sticker has been deleted.",
|
||||
"fieldStickerName": "Sticker Name",
|
||||
"fieldStickerAlias": "Sticker Alias",
|
||||
"fieldStickerAliasHint": "The unique sticker placeholder with the pack prefix.",
|
||||
"fieldStickerPackName": "Name",
|
||||
"fieldStickerPackDescription": "Description",
|
||||
"fieldStickerPackPrefix": "Prefix",
|
||||
"fieldStickerAttachment": "Attachment",
|
||||
"stickersNew": "New Sticker",
|
||||
"stickersNewDescription": "Create a new sticker belongs to this pack.",
|
||||
"stickersPackNew": "New Sticker Pack",
|
||||
"trayMenuShow": "Show",
|
||||
"trayMenuMuteNotification": "Do Not Disturb",
|
||||
"update": "Update",
|
||||
"forceUpdate": "Force Update",
|
||||
"forceUpdateDescription": "Force to show the application update popup, even the new version is not available.",
|
||||
"debugLogging": "Runtime Logs",
|
||||
"runtimeLogsOpen": "Open Logs",
|
||||
"runtimeLogsDescription": "Show the runtime logs to help debugging.",
|
||||
"signinResetPasswordHint": "Please enter the username / email address to help us to find your account and reset your password.",
|
||||
"cacheSize": "Cache Size",
|
||||
"cacheDelete": "Clean Cache",
|
||||
"cacheDeleteDescription": "Remove the cached images and other resources from your disk, the content will be downloaded from server again.",
|
||||
"cacheDeleted": "All cache has been cleaned up.",
|
||||
"userNoDescription": "No description.",
|
||||
"fieldTimeZone": "Time Zone",
|
||||
"fieldGender": "Gender",
|
||||
"fieldPronouns": "Pronouns",
|
||||
"fieldLocation": "Location",
|
||||
"fieldLinks": "Links",
|
||||
"fieldLinkName": "Name",
|
||||
"fieldLinkUrl": "URL",
|
||||
"screenAccountBadges": "Badges",
|
||||
"accountBadges": "Badges",
|
||||
"accountBadgesDescription": "View and manage your badges.",
|
||||
"badgeActivated": "Activated badge {}.",
|
||||
"viewDetailedAttachment": "Details",
|
||||
"screenKeyPairs": "Key Pairs",
|
||||
"accountKeyPairs": "Key Pairs",
|
||||
"accountKeyPairsDescription": "Manage the key pairs which used to encrypt messages.",
|
||||
"enrollNewKeyPair": "Enroll New One",
|
||||
"enrollNewKeyPairDescription": "Generate a new key pair.",
|
||||
"keyPairHasPrivateKey": "With private key",
|
||||
"decrypting": "Decrypting……",
|
||||
"decryptingKeyNotFound": "Key not found or exchange failed, the other party may not be online",
|
||||
"messageUnablePreview": "Unable preview",
|
||||
"messageUnablePreviewEncrypted": "Unable preview encrypted message",
|
||||
"postViewInGlobalDescription": "Do not view the post in the specific realm."
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
"publisherRunBy": "由 {} 管理",
|
||||
"fieldPublisherBelongToRealm": "所属领域",
|
||||
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
|
||||
"writePost": "撰写",
|
||||
"postTypeStory": "动态",
|
||||
"postTypeArticle": "文章",
|
||||
"postTypeQuestion": "问题",
|
||||
"postTypeVideo": "视频",
|
||||
"writePostTypeStory": "发动态",
|
||||
"writePostTypeArticle": "写文章",
|
||||
"writePostTypeQuestion": "提问题",
|
||||
@@ -201,6 +206,11 @@
|
||||
"other": "{} 条评论"
|
||||
},
|
||||
"settingsAppearance": "外观",
|
||||
"settingsCustomFonts": "自定义字体",
|
||||
"settingsCustomFontsDescription": "设置应用程序使用的字体。",
|
||||
"settingsCustomFontFamily": "应用字体",
|
||||
"settingsCustomFontFamilyHint": "使用英文逗号分割每一种字体,越前优先级越高",
|
||||
"settingsCustomFontApplied": "自定义字体已经应用。",
|
||||
"settingsDisplayLanguage": "显示语言",
|
||||
"settingsDisplayLanguageDescription": "设置应用程序使用的语言",
|
||||
"settingsDisplayLanguageSystem": "跟随系统",
|
||||
@@ -510,8 +520,13 @@
|
||||
"accountBirthday": "出生于 {}",
|
||||
"accountBadge": "徽章",
|
||||
"accountCheckInNoRecords": "暂无运势记录",
|
||||
"badgeCompanyStaff": "索尔辛茨士大夫 · 员工",
|
||||
"badgeCompanyStaff": "工作人员",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"badgeCommunitySurvey": "调研参与者",
|
||||
"badgeCommunityVerified": "认证用户",
|
||||
"badgeCommunityContributor": "优秀社区贡献者",
|
||||
"badgeSiteAnniversary": "周年纪念",
|
||||
"badgeUserBirthday": "生日纪念",
|
||||
"accountStatus": "状态",
|
||||
"accountStatusOnline": "在线",
|
||||
"accountStatusOffline": "离线",
|
||||
@@ -546,6 +561,7 @@
|
||||
"termAcceptNextWithAgree": "点击 “下一步”,即表示你同意我们的各项条款,包括其之后的更新。",
|
||||
"unauthorized": "未登陆",
|
||||
"unauthorizedDescription": "登陆以探索整个 Solar Network。",
|
||||
"projectDetail": "项目详情",
|
||||
"serviceStatus": "服务状态",
|
||||
"termRelated": "相关条款",
|
||||
"appDetails": "应用程序详情",
|
||||
@@ -581,6 +597,7 @@
|
||||
"colorSchemeBlack": "黑色",
|
||||
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
|
||||
"postFeaturedComment": "精选评论",
|
||||
"postCategory": "分类",
|
||||
"postCategoryTechnology": "技术",
|
||||
"postCategoryGaming": "游戏",
|
||||
"postCategoryLife": "生活",
|
||||
@@ -624,6 +641,7 @@
|
||||
"realmJoin": "加入领域",
|
||||
"realmCommunityHint": "该领域是一个社区领域,你可以自由加入。",
|
||||
"realmCommunityPublicChannelsHint": "该领域包含的公共频道",
|
||||
"realmCommunityPublishersHint": "该领域的发布者",
|
||||
"realmJoined": "已加入领域 {}。",
|
||||
"join": "加入",
|
||||
"pollEditorNew": "新投票",
|
||||
@@ -664,5 +682,90 @@
|
||||
"zero": "{} 次浏览",
|
||||
"one": "{} 次浏览",
|
||||
"other": "{} 次浏览"
|
||||
}
|
||||
},
|
||||
"attachmentBillingUploaded": "已占用的字节数",
|
||||
"attachmentBillingDiscount": "免费的字节数",
|
||||
"attachmentBillingHint": "滑动窗口计价®\n在24小时内上传的文件大小超出免费空间才会适用扣费。",
|
||||
"postThumbnail": "帖子缩略图",
|
||||
"accountRealms": "领域",
|
||||
"postInGlobal": "全站",
|
||||
"postInGlobalDescription": "不关联此帖子与任何领域。",
|
||||
"postChannelGlobal": "全站",
|
||||
"postChannelFriends": "好友",
|
||||
"postChannelFollowing": "关注",
|
||||
"postChannelRealm": "领域",
|
||||
"postFilterReset": "重置过滤器",
|
||||
"postFilterResetDescription": "清除过滤器并显示所有帖子。",
|
||||
"postFilterWithCategory": "查看{}区中的帖子",
|
||||
"databaseSize": "数据库大小",
|
||||
"databaseDelete": "删除数据库",
|
||||
"databaseDeleteDescription": "删除本地数据库,内容将从服务器重新获取。",
|
||||
"databaseDeleted": "本地数据库已被删除。",
|
||||
"settingsEnablePushNotifications": "启用推送数据",
|
||||
"settingsEnablePushNotificationsDescription": "重新启用并请求推送权限,以防自动激活失败。",
|
||||
"settingsEnabledPushNotifications": "推送通知已经注册。",
|
||||
"screenStickers": "贴图",
|
||||
"stickersDiscovery": "发现",
|
||||
"stickersOwned": "由我拥有",
|
||||
"stickersCreated": "由我发布",
|
||||
"stickersAdd": "添加贴图包",
|
||||
"stickersAdded": "贴图包已添加。",
|
||||
"add": "添加",
|
||||
"stickersRemoved": "贴图包已被移除,你可以随时再次添加回来。",
|
||||
"stickersReload": "重载贴图包",
|
||||
"stickersReloadDescription": "从服务器重新加载添加过的贴图,更新贴图选择器。",
|
||||
"stickersReloaded": "贴图包已重载。",
|
||||
"stickersPackDelete": "删除贴图包 {}",
|
||||
"stickersPackDeleteDescription": "你确定要删除这个贴图包吗?这个操作不可撤销。",
|
||||
"stickersPackDeleted": "贴图包已被删除。",
|
||||
"stickersDelete": "删除贴图 {}",
|
||||
"stickersDeleteDescription": "你确定要删除这个贴图吗?这个操作不可撤销。",
|
||||
"stickersDeleted": "贴图已被删除。",
|
||||
"fieldStickerName": "贴图名称",
|
||||
"fieldStickerAlias": "贴图别名",
|
||||
"fieldStickerAliasHint": "和贴图包前缀组合成为本贴图的唯一占位符。",
|
||||
"fieldStickerPackName": "名称",
|
||||
"fieldStickerPackDescription": "描述",
|
||||
"fieldStickerPackPrefix": "贴图包前缀",
|
||||
"fieldStickerAttachment": "附件",
|
||||
"stickersNew": "新建贴图",
|
||||
"stickersNewDescription": "创建一个新的贴图。",
|
||||
"stickersPackNew": "新建贴图包",
|
||||
"trayMenuShow": "显示",
|
||||
"trayMenuMuteNotification": "静音通知",
|
||||
"update": "更新",
|
||||
"forceUpdate": "强制更新",
|
||||
"forceUpdateDescription": "强制更新应用程序,即使有更新的版本可能不可用。",
|
||||
"runtimeLogs": "运行时日志",
|
||||
"runtimeLogsOpen": "打开日志文件",
|
||||
"runtimeLogsDescription": "显示运行时的日志记录。",
|
||||
"signinResetPasswordHint": "请输入用户名/电子邮箱地址以帮助我们找到您的帐户并重置密码。",
|
||||
"cacheSize": "缓存资源大小",
|
||||
"cacheDelete": "清除缓存",
|
||||
"cacheDeleteDescription": "从磁盘中移除缓存的图片和其他资源,内容将从服务器重新下载。",
|
||||
"cacheDeleted": "所有缓存已被清除。",
|
||||
"userNoDescription": "这个人很懒,没有留下什么……",
|
||||
"fieldTimeZone": "时区",
|
||||
"fieldGender": "性别",
|
||||
"fieldPronouns": "人称代词",
|
||||
"fieldLocation": "位置",
|
||||
"fieldLinks": "链接",
|
||||
"fieldLinkName": "名称",
|
||||
"fieldLinkUrl": "链接",
|
||||
"screenAccountBadges": "徽章",
|
||||
"accountBadges": "徽章",
|
||||
"accountBadgesDescription": "查看并管理你的徽章。",
|
||||
"badgeActivated": "已佩戴徽章 {}。",
|
||||
"viewDetailedAttachment": "查看附件详情",
|
||||
"screenKeyPairs": "密钥对",
|
||||
"accountKeyPairs": "密钥对",
|
||||
"accountKeyPairsDescription": "管理用于加密信息的密钥对。",
|
||||
"enrollNewKeyPair": "新建密钥对",
|
||||
"enrollNewKeyPairDescription": "生成一对新密钥对。",
|
||||
"keyPairHasPrivateKey": "有私钥",
|
||||
"decrypting": "解密中……",
|
||||
"decryptingKeyNotFound": "未找到密钥对或交换失败,对方可能不在线",
|
||||
"messageUnablePreview": "无法预览消息",
|
||||
"messageUnablePreviewEncrypted": "无法预览加密消息",
|
||||
"postViewInGlobalDescription": "不查看特定领域的帖子。"
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
"publisherRunBy": "由 {} 管理",
|
||||
"fieldPublisherBelongToRealm": "所屬領域",
|
||||
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
||||
"writePost": "撰寫",
|
||||
"postTypeStory": "動態",
|
||||
"postTypeArticle": "文章",
|
||||
"postTypeQuestion": "問題",
|
||||
"postTypeVideo": "視頻",
|
||||
"writePostTypeStory": "發動態",
|
||||
"writePostTypeArticle": "寫文章",
|
||||
"writePostTypeQuestion": "提問題",
|
||||
@@ -201,6 +206,11 @@
|
||||
"other": "{} 條評論"
|
||||
},
|
||||
"settingsAppearance": "外觀",
|
||||
"settingsCustomFonts": "自定義字體",
|
||||
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
|
||||
"settingsCustomFontFamily": "應用字體",
|
||||
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
|
||||
"settingsCustomFontApplied": "自定義字體已經應用。",
|
||||
"settingsDisplayLanguage": "顯示語言",
|
||||
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
|
||||
"settingsDisplayLanguageSystem": "跟隨系統",
|
||||
@@ -510,8 +520,13 @@
|
||||
"accountBirthday": "出生於 {}",
|
||||
"accountBadge": "徽章",
|
||||
"accountCheckInNoRecords": "暫無運勢記錄",
|
||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
||||
"badgeCompanyStaff": "工作人員",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"badgeCommunitySurvey": "調研參與者",
|
||||
"badgeCommunityVerified": "認證用户",
|
||||
"badgeCommunityContributor": "優秀社區貢獻者",
|
||||
"badgeSiteAnniversary": "週年紀念",
|
||||
"badgeUserBirthday": "生日紀念",
|
||||
"accountStatus": "狀態",
|
||||
"accountStatusOnline": "在線",
|
||||
"accountStatusOffline": "離線",
|
||||
@@ -546,6 +561,7 @@
|
||||
"termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
|
||||
"unauthorized": "未登陸",
|
||||
"unauthorizedDescription": "登陸以探索整個 Solar Network。",
|
||||
"projectDetail": "項目詳情",
|
||||
"serviceStatus": "服務狀態",
|
||||
"termRelated": "相關條款",
|
||||
"appDetails": "應用程序詳情",
|
||||
@@ -581,6 +597,7 @@
|
||||
"colorSchemeBlack": "黑色",
|
||||
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
|
||||
"postFeaturedComment": "精選評論",
|
||||
"postCategory": "分類",
|
||||
"postCategoryTechnology": "技術",
|
||||
"postCategoryGaming": "遊戲",
|
||||
"postCategoryLife": "生活",
|
||||
@@ -624,6 +641,7 @@
|
||||
"realmJoin": "加入領域",
|
||||
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
|
||||
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
|
||||
"realmCommunityPublishersHint": "該領域的發佈者",
|
||||
"realmJoined": "已加入領域 {}。",
|
||||
"join": "加入",
|
||||
"pollEditorNew": "新投票",
|
||||
@@ -664,5 +682,90 @@
|
||||
"zero": "{} 次瀏覽",
|
||||
"one": "{} 次瀏覽",
|
||||
"other": "{} 次瀏覽"
|
||||
}
|
||||
},
|
||||
"attachmentBillingUploaded": "已佔用的字節數",
|
||||
"attachmentBillingDiscount": "免費的字節數",
|
||||
"attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。",
|
||||
"postThumbnail": "帖子縮略圖",
|
||||
"accountRealms": "領域",
|
||||
"postInGlobal": "全站",
|
||||
"postInGlobalDescription": "不關聯此帖子與任何領域。",
|
||||
"postChannelGlobal": "全站",
|
||||
"postChannelFriends": "好友",
|
||||
"postChannelFollowing": "關注",
|
||||
"postChannelRealm": "領域",
|
||||
"postFilterReset": "重置過濾器",
|
||||
"postFilterResetDescription": "清除過濾器並顯示所有帖子。",
|
||||
"postFilterWithCategory": "查看{}區中的帖子",
|
||||
"databaseSize": "數據庫大小",
|
||||
"databaseDelete": "刪除數據庫",
|
||||
"databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
|
||||
"databaseDeleted": "本地數據庫已被刪除。",
|
||||
"settingsEnablePushNotifications": "啓用推送數據",
|
||||
"settingsEnablePushNotificationsDescription": "重新啓用並請求推送權限,以防自動激活失敗。",
|
||||
"settingsEnabledPushNotifications": "推送通知已經註冊。",
|
||||
"screenStickers": "貼圖",
|
||||
"stickersDiscovery": "發現",
|
||||
"stickersOwned": "由我擁有",
|
||||
"stickersCreated": "由我發佈",
|
||||
"stickersAdd": "添加貼圖包",
|
||||
"stickersAdded": "貼圖包已添加。",
|
||||
"add": "添加",
|
||||
"stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。",
|
||||
"stickersReload": "重載貼圖包",
|
||||
"stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。",
|
||||
"stickersReloaded": "貼圖包已重載。",
|
||||
"stickersPackDelete": "刪除貼圖包 {}",
|
||||
"stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。",
|
||||
"stickersPackDeleted": "貼圖包已被刪除。",
|
||||
"stickersDelete": "刪除貼圖 {}",
|
||||
"stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。",
|
||||
"stickersDeleted": "貼圖已被刪除。",
|
||||
"fieldStickerName": "貼圖名稱",
|
||||
"fieldStickerAlias": "貼圖別名",
|
||||
"fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。",
|
||||
"fieldStickerPackName": "名稱",
|
||||
"fieldStickerPackDescription": "描述",
|
||||
"fieldStickerPackPrefix": "貼圖包前綴",
|
||||
"fieldStickerAttachment": "附件",
|
||||
"stickersNew": "新建貼圖",
|
||||
"stickersNewDescription": "創建一個新的貼圖。",
|
||||
"stickersPackNew": "新建貼圖包",
|
||||
"trayMenuShow": "顯示",
|
||||
"trayMenuMuteNotification": "靜音通知",
|
||||
"update": "更新",
|
||||
"forceUpdate": "強制更新",
|
||||
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
|
||||
"runtimeLogs": "運行時日誌",
|
||||
"runtimeLogsOpen": "打開日誌文件",
|
||||
"runtimeLogsDescription": "顯示運行時的日誌記錄。",
|
||||
"signinResetPasswordHint": "請輸入用户名/電子郵箱地址以幫助我們找到您的帳户並重置密碼。",
|
||||
"cacheSize": "緩存資源大小",
|
||||
"cacheDelete": "清除緩存",
|
||||
"cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
|
||||
"cacheDeleted": "所有緩存已被清除。",
|
||||
"userNoDescription": "這個人很懶,沒有留下什麼……",
|
||||
"fieldTimeZone": "時區",
|
||||
"fieldGender": "性別",
|
||||
"fieldPronouns": "人稱代詞",
|
||||
"fieldLocation": "位置",
|
||||
"fieldLinks": "鏈接",
|
||||
"fieldLinkName": "名稱",
|
||||
"fieldLinkUrl": "鏈接",
|
||||
"screenAccountBadges": "徽章",
|
||||
"accountBadges": "徽章",
|
||||
"accountBadgesDescription": "查看並管理你的徽章。",
|
||||
"badgeActivated": "已佩戴徽章 {}。",
|
||||
"viewDetailedAttachment": "查看附件詳情",
|
||||
"screenKeyPairs": "密鑰對",
|
||||
"accountKeyPairs": "密鑰對",
|
||||
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
|
||||
"enrollNewKeyPair": "新建密鑰對",
|
||||
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
|
||||
"keyPairHasPrivateKey": "有私鑰",
|
||||
"decrypting": "解密中……",
|
||||
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
|
||||
"messageUnablePreview": "無法預覽消息",
|
||||
"messageUnablePreviewEncrypted": "無法預覽加密消息",
|
||||
"postViewInGlobalDescription": "不查看特定領域的帖子。"
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
"publisherRunBy": "由 {} 管理",
|
||||
"fieldPublisherBelongToRealm": "所屬領域",
|
||||
"fieldPublisherBelongToRealmUnset": "未設置發佈者所屬領域",
|
||||
"writePost": "撰寫",
|
||||
"postTypeStory": "動態",
|
||||
"postTypeArticle": "文章",
|
||||
"postTypeQuestion": "問題",
|
||||
"postTypeVideo": "視頻",
|
||||
"writePostTypeStory": "發動態",
|
||||
"writePostTypeArticle": "寫文章",
|
||||
"writePostTypeQuestion": "提問題",
|
||||
@@ -201,6 +206,11 @@
|
||||
"other": "{} 條評論"
|
||||
},
|
||||
"settingsAppearance": "外觀",
|
||||
"settingsCustomFonts": "自定義字體",
|
||||
"settingsCustomFontsDescription": "設置應用程序使用的字體。",
|
||||
"settingsCustomFontFamily": "應用字體",
|
||||
"settingsCustomFontFamilyHint": "使用英文逗號分割每一種字體,越前優先級越高",
|
||||
"settingsCustomFontApplied": "自定義字體已經應用。",
|
||||
"settingsDisplayLanguage": "顯示語言",
|
||||
"settingsDisplayLanguageDescription": "設置應用程序使用的語言",
|
||||
"settingsDisplayLanguageSystem": "跟隨系統",
|
||||
@@ -510,8 +520,13 @@
|
||||
"accountBirthday": "出生於 {}",
|
||||
"accountBadge": "徽章",
|
||||
"accountCheckInNoRecords": "暫無運勢記錄",
|
||||
"badgeCompanyStaff": "索爾辛茨士大夫 · 員工",
|
||||
"badgeCompanyStaff": "工作人員",
|
||||
"badgeSiteMigration": "Solar Network 原住民",
|
||||
"badgeCommunitySurvey": "調研參與者",
|
||||
"badgeCommunityVerified": "認證用戶",
|
||||
"badgeCommunityContributor": "優秀社區貢獻者",
|
||||
"badgeSiteAnniversary": "週年紀念",
|
||||
"badgeUserBirthday": "生日紀念",
|
||||
"accountStatus": "狀態",
|
||||
"accountStatusOnline": "在線",
|
||||
"accountStatusOffline": "離線",
|
||||
@@ -546,6 +561,7 @@
|
||||
"termAcceptNextWithAgree": "點擊 “下一步”,即表示你同意我們的各項條款,包括其之後的更新。",
|
||||
"unauthorized": "未登陸",
|
||||
"unauthorizedDescription": "登陸以探索整個 Solar Network。",
|
||||
"projectDetail": "項目詳情",
|
||||
"serviceStatus": "服務狀態",
|
||||
"termRelated": "相關條款",
|
||||
"appDetails": "應用程序詳情",
|
||||
@@ -581,6 +597,7 @@
|
||||
"colorSchemeBlack": "黑色",
|
||||
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
|
||||
"postFeaturedComment": "精選評論",
|
||||
"postCategory": "分類",
|
||||
"postCategoryTechnology": "技術",
|
||||
"postCategoryGaming": "遊戲",
|
||||
"postCategoryLife": "生活",
|
||||
@@ -624,6 +641,7 @@
|
||||
"realmJoin": "加入領域",
|
||||
"realmCommunityHint": "該領域是一個社區領域,你可以自由加入。",
|
||||
"realmCommunityPublicChannelsHint": "該領域包含的公共頻道",
|
||||
"realmCommunityPublishersHint": "該領域的發佈者",
|
||||
"realmJoined": "已加入領域 {}。",
|
||||
"join": "加入",
|
||||
"pollEditorNew": "新投票",
|
||||
@@ -664,5 +682,90 @@
|
||||
"zero": "{} 次瀏覽",
|
||||
"one": "{} 次瀏覽",
|
||||
"other": "{} 次瀏覽"
|
||||
}
|
||||
},
|
||||
"attachmentBillingUploaded": "已佔用的字節數",
|
||||
"attachmentBillingDiscount": "免費的字節數",
|
||||
"attachmentBillingHint": "滑動窗口計價®\n在24小時內上傳的文件大小超出免費空間才會適用扣費。",
|
||||
"postThumbnail": "帖子縮略圖",
|
||||
"accountRealms": "領域",
|
||||
"postInGlobal": "全站",
|
||||
"postInGlobalDescription": "不關聯此帖子與任何領域。",
|
||||
"postChannelGlobal": "全站",
|
||||
"postChannelFriends": "好友",
|
||||
"postChannelFollowing": "關注",
|
||||
"postChannelRealm": "領域",
|
||||
"postFilterReset": "重置過濾器",
|
||||
"postFilterResetDescription": "清除過濾器並顯示所有帖子。",
|
||||
"postFilterWithCategory": "查看{}區中的帖子",
|
||||
"databaseSize": "數據庫大小",
|
||||
"databaseDelete": "刪除數據庫",
|
||||
"databaseDeleteDescription": "刪除本地數據庫,內容將從服務器重新獲取。",
|
||||
"databaseDeleted": "本地數據庫已被刪除。",
|
||||
"settingsEnablePushNotifications": "啟用推送數據",
|
||||
"settingsEnablePushNotificationsDescription": "重新啟用並請求推送權限,以防自動激活失敗。",
|
||||
"settingsEnabledPushNotifications": "推送通知已經註冊。",
|
||||
"screenStickers": "貼圖",
|
||||
"stickersDiscovery": "發現",
|
||||
"stickersOwned": "由我擁有",
|
||||
"stickersCreated": "由我發佈",
|
||||
"stickersAdd": "添加貼圖包",
|
||||
"stickersAdded": "貼圖包已添加。",
|
||||
"add": "添加",
|
||||
"stickersRemoved": "貼圖包已被移除,你可以隨時再次添加回來。",
|
||||
"stickersReload": "重載貼圖包",
|
||||
"stickersReloadDescription": "從服務器重新加載添加過的貼圖,更新貼圖選擇器。",
|
||||
"stickersReloaded": "貼圖包已重載。",
|
||||
"stickersPackDelete": "刪除貼圖包 {}",
|
||||
"stickersPackDeleteDescription": "你確定要刪除這個貼圖包嗎?這個操作不可撤銷。",
|
||||
"stickersPackDeleted": "貼圖包已被刪除。",
|
||||
"stickersDelete": "刪除貼圖 {}",
|
||||
"stickersDeleteDescription": "你確定要刪除這個貼圖嗎?這個操作不可撤銷。",
|
||||
"stickersDeleted": "貼圖已被刪除。",
|
||||
"fieldStickerName": "貼圖名稱",
|
||||
"fieldStickerAlias": "貼圖別名",
|
||||
"fieldStickerAliasHint": "和貼圖包前綴組合成為本貼圖的唯一佔位符。",
|
||||
"fieldStickerPackName": "名稱",
|
||||
"fieldStickerPackDescription": "描述",
|
||||
"fieldStickerPackPrefix": "貼圖包前綴",
|
||||
"fieldStickerAttachment": "附件",
|
||||
"stickersNew": "新建貼圖",
|
||||
"stickersNewDescription": "創建一個新的貼圖。",
|
||||
"stickersPackNew": "新建貼圖包",
|
||||
"trayMenuShow": "顯示",
|
||||
"trayMenuMuteNotification": "靜音通知",
|
||||
"update": "更新",
|
||||
"forceUpdate": "強制更新",
|
||||
"forceUpdateDescription": "強制更新應用程序,即使有更新的版本可能不可用。",
|
||||
"runtimeLogs": "運行時日誌",
|
||||
"runtimeLogsOpen": "打開日誌文件",
|
||||
"runtimeLogsDescription": "顯示運行時的日誌記錄。",
|
||||
"signinResetPasswordHint": "請輸入用戶名/電子郵箱地址以幫助我們找到您的帳戶並重置密碼。",
|
||||
"cacheSize": "緩存資源大小",
|
||||
"cacheDelete": "清除緩存",
|
||||
"cacheDeleteDescription": "從磁盤中移除緩存的圖片和其他資源,內容將從服務器重新下載。",
|
||||
"cacheDeleted": "所有緩存已被清除。",
|
||||
"userNoDescription": "這個人很懶,沒有留下什麼……",
|
||||
"fieldTimeZone": "時區",
|
||||
"fieldGender": "性別",
|
||||
"fieldPronouns": "人稱代詞",
|
||||
"fieldLocation": "位置",
|
||||
"fieldLinks": "鏈接",
|
||||
"fieldLinkName": "名稱",
|
||||
"fieldLinkUrl": "鏈接",
|
||||
"screenAccountBadges": "徽章",
|
||||
"accountBadges": "徽章",
|
||||
"accountBadgesDescription": "查看並管理你的徽章。",
|
||||
"badgeActivated": "已佩戴徽章 {}。",
|
||||
"viewDetailedAttachment": "查看附件詳情",
|
||||
"screenKeyPairs": "密鑰對",
|
||||
"accountKeyPairs": "密鑰對",
|
||||
"accountKeyPairsDescription": "管理用於加密信息的密鑰對。",
|
||||
"enrollNewKeyPair": "新建密鑰對",
|
||||
"enrollNewKeyPairDescription": "生成一對新密鑰對。",
|
||||
"keyPairHasPrivateKey": "有私鑰",
|
||||
"decrypting": "解密中……",
|
||||
"decryptingKeyNotFound": "未找到密鑰對或交換失敗,對方可能不在線",
|
||||
"messageUnablePreview": "無法預覽消息",
|
||||
"messageUnablePreviewEncrypted": "無法預覽加密消息",
|
||||
"postViewInGlobalDescription": "不查看特定領域的帖子。"
|
||||
}
|
||||
|
||||
@@ -4,4 +4,8 @@ targets:
|
||||
json_serializable:
|
||||
options:
|
||||
explicit_to_json: true
|
||||
field_rename: snake
|
||||
field_rename: snake
|
||||
drift_dev:
|
||||
options:
|
||||
databases:
|
||||
my_database: lib/database/database.dart
|
||||
1
drift_schemas/my_database/drift_schema_v1.json
Normal file
1
drift_schemas/my_database/drift_schema_v1.json
Normal file
@@ -0,0 +1 @@
|
||||
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}}]}
|
||||
1
drift_schemas/my_database/drift_schema_v2.json
Normal file
1
drift_schemas/my_database/drift_schema_v2.json
Normal file
@@ -0,0 +1 @@
|
||||
{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"sn_local_chat_channel","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"alias","getter_name":"alias","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnChannelConverter()","dart_type_name":"SnChannel"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"sn_local_chat_message","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"channel_id","getter_name":"channelId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"content","getter_name":"content","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const SnMessageConverter()","dart_type_name":"SnChatMessage"}},{"name":"created_at","getter_name":"createdAt","moor_type":"dateTime","nullable":false,"customConstraints":null,"default_dart":"const CustomExpression('CAST(strftime(\\'%s\\', CURRENT_TIMESTAMP) AS INTEGER)')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":2,"references":[],"type":"table","data":{"name":"sn_local_key_pair","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"account_id","getter_name":"accountId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"public_key","getter_name":"publicKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"private_key","getter_name":"privateKey","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"is_active","getter_name":"isActive","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"is_active\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"is_active\" IN (0, 1))"},"default_dart":"const CustomExpression('0')","default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["id"]}}]}
|
||||
1
drift_schemas/my_database/drift_schema_v3.json
Normal file
1
drift_schemas/my_database/drift_schema_v3.json
Normal file
File diff suppressed because one or more lines are too long
139
ios/Podfile.lock
139
ios/Podfile.lock
@@ -37,63 +37,65 @@ PODS:
|
||||
- DKPhotoGallery/Resource (0.0.19):
|
||||
- SDWebImage
|
||||
- SwiftyGif
|
||||
- fast_rsa (0.6.0):
|
||||
- Flutter
|
||||
- file_picker (0.0.1):
|
||||
- DKImagePickerController/PhotoGallery
|
||||
- Flutter
|
||||
- file_saver (0.0.1):
|
||||
- Flutter
|
||||
- Firebase/Analytics (11.7.0):
|
||||
- Firebase/Analytics (11.8.0):
|
||||
- Firebase/Core
|
||||
- Firebase/Core (11.7.0):
|
||||
- Firebase/Core (11.8.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseAnalytics (~> 11.7.0)
|
||||
- Firebase/CoreOnly (11.7.0):
|
||||
- FirebaseCore (~> 11.7.0)
|
||||
- Firebase/Messaging (11.7.0):
|
||||
- FirebaseAnalytics (~> 11.8.0)
|
||||
- Firebase/CoreOnly (11.8.0):
|
||||
- FirebaseCore (~> 11.8.0)
|
||||
- Firebase/Messaging (11.8.0):
|
||||
- Firebase/CoreOnly
|
||||
- FirebaseMessaging (~> 11.7.0)
|
||||
- firebase_analytics (11.4.2):
|
||||
- Firebase/Analytics (= 11.7.0)
|
||||
- FirebaseMessaging (~> 11.8.0)
|
||||
- firebase_analytics (11.4.4):
|
||||
- Firebase/Analytics (= 11.8.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- firebase_core (3.11.0):
|
||||
- Firebase/CoreOnly (= 11.7.0)
|
||||
- firebase_core (3.12.1):
|
||||
- Firebase/CoreOnly (= 11.8.0)
|
||||
- Flutter
|
||||
- firebase_messaging (15.2.2):
|
||||
- Firebase/Messaging (= 11.7.0)
|
||||
- firebase_messaging (15.2.4):
|
||||
- Firebase/Messaging (= 11.8.0)
|
||||
- firebase_core
|
||||
- Flutter
|
||||
- FirebaseAnalytics (11.7.0):
|
||||
- FirebaseAnalytics/AdIdSupport (= 11.7.0)
|
||||
- FirebaseCore (~> 11.7.0)
|
||||
- FirebaseAnalytics (11.8.0):
|
||||
- FirebaseAnalytics/AdIdSupport (= 11.8.0)
|
||||
- FirebaseCore (~> 11.8.0)
|
||||
- FirebaseInstallations (~> 11.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseAnalytics/AdIdSupport (11.7.0):
|
||||
- FirebaseCore (~> 11.7.0)
|
||||
- FirebaseAnalytics/AdIdSupport (11.8.0):
|
||||
- FirebaseCore (~> 11.8.0)
|
||||
- FirebaseInstallations (~> 11.0)
|
||||
- GoogleAppMeasurement (= 11.7.0)
|
||||
- GoogleAppMeasurement (= 11.8.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- FirebaseCore (11.7.0):
|
||||
- FirebaseCoreInternal (~> 11.7.0)
|
||||
- FirebaseCore (11.8.1):
|
||||
- FirebaseCoreInternal (~> 11.8.0)
|
||||
- GoogleUtilities/Environment (~> 8.0)
|
||||
- GoogleUtilities/Logger (~> 8.0)
|
||||
- FirebaseCoreInternal (11.7.0):
|
||||
- FirebaseCoreInternal (11.8.0):
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- FirebaseInstallations (11.7.0):
|
||||
- FirebaseCore (~> 11.7.0)
|
||||
- FirebaseInstallations (11.8.0):
|
||||
- FirebaseCore (~> 11.8.0)
|
||||
- GoogleUtilities/Environment (~> 8.0)
|
||||
- GoogleUtilities/UserDefaults (~> 8.0)
|
||||
- PromisesObjC (~> 2.4)
|
||||
- FirebaseMessaging (11.7.0):
|
||||
- FirebaseCore (~> 11.7.0)
|
||||
- FirebaseMessaging (11.8.0):
|
||||
- FirebaseCore (~> 11.8.0)
|
||||
- FirebaseInstallations (~> 11.0)
|
||||
- GoogleDataTransport (~> 10.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
@@ -113,6 +115,8 @@ PODS:
|
||||
- OrderedSet (~> 6.0.3)
|
||||
- flutter_native_splash (2.4.3):
|
||||
- Flutter
|
||||
- flutter_timezone (0.0.1):
|
||||
- Flutter
|
||||
- flutter_udid (0.0.1):
|
||||
- Flutter
|
||||
- SAMKeychain
|
||||
@@ -122,21 +126,23 @@ PODS:
|
||||
- gal (1.0.0):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- GoogleAppMeasurement (11.7.0):
|
||||
- GoogleAppMeasurement/AdIdSupport (= 11.7.0)
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- GoogleAppMeasurement (11.8.0):
|
||||
- GoogleAppMeasurement/AdIdSupport (= 11.8.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/AdIdSupport (11.7.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.7.0)
|
||||
- GoogleAppMeasurement/AdIdSupport (11.8.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (= 11.8.0)
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
- "GoogleUtilities/NSData+zlib (~> 8.0)"
|
||||
- nanopb (~> 3.30910.0)
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (11.7.0):
|
||||
- GoogleAppMeasurement/WithoutAdIdSupport (11.8.0):
|
||||
- GoogleUtilities/AppDelegateSwizzler (~> 8.0)
|
||||
- GoogleUtilities/MethodSwizzler (~> 8.0)
|
||||
- GoogleUtilities/Network (~> 8.0)
|
||||
@@ -179,7 +185,7 @@ PODS:
|
||||
- in_app_review (2.0.0):
|
||||
- Flutter
|
||||
- Kingfisher (8.2.0)
|
||||
- livekit_client (2.3.6):
|
||||
- livekit_client (2.4.0):
|
||||
- Flutter
|
||||
- flutter_webrtc
|
||||
- WebRTC-SDK (= 125.6422.06)
|
||||
@@ -210,9 +216,9 @@ PODS:
|
||||
- SAMKeychain (1.5.3)
|
||||
- screen_brightness_ios (0.1.0):
|
||||
- Flutter
|
||||
- SDWebImage (5.20.0):
|
||||
- SDWebImage/Core (= 5.20.0)
|
||||
- SDWebImage/Core (5.20.0)
|
||||
- SDWebImage (5.20.1):
|
||||
- SDWebImage/Core (= 5.20.1)
|
||||
- SDWebImage/Core (5.20.1)
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -221,6 +227,25 @@ PODS:
|
||||
- sqflite_darwin (0.0.4):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (3.49.1):
|
||||
- sqlite3/common (= 3.49.1)
|
||||
- sqlite3/common (3.49.1)
|
||||
- sqlite3/dbstatvtab (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3/fts5 (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3/perf-threadsafe (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3/rtree (3.49.1):
|
||||
- sqlite3/common
|
||||
- sqlite3_flutter_libs (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqlite3 (~> 3.49.1)
|
||||
- sqlite3/dbstatvtab
|
||||
- sqlite3/fts5
|
||||
- sqlite3/perf-threadsafe
|
||||
- sqlite3/rtree
|
||||
- SwiftyGif (5.4.5)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
@@ -239,6 +264,7 @@ DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- fast_rsa (from `.symlinks/plugins/fast_rsa/ios`)
|
||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||
- file_saver (from `.symlinks/plugins/file_saver/ios`)
|
||||
- firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`)
|
||||
@@ -248,9 +274,11 @@ DEPENDENCIES:
|
||||
- flutter_app_update (from `.symlinks/plugins/flutter_app_update/ios`)
|
||||
- flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`)
|
||||
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||
- flutter_timezone (from `.symlinks/plugins/flutter_timezone/ios`)
|
||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||
- gal (from `.symlinks/plugins/gal/darwin`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
|
||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
|
||||
@@ -268,6 +296,7 @@ DEPENDENCIES:
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_compress (from `.symlinks/plugins/video_compress/ios`)
|
||||
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
|
||||
@@ -294,6 +323,7 @@ SPEC REPOS:
|
||||
- PromisesObjC
|
||||
- SAMKeychain
|
||||
- SDWebImage
|
||||
- sqlite3
|
||||
- SwiftyGif
|
||||
- WebRTC-SDK
|
||||
|
||||
@@ -304,6 +334,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/croppy/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
fast_rsa:
|
||||
:path: ".symlinks/plugins/fast_rsa/ios"
|
||||
file_picker:
|
||||
:path: ".symlinks/plugins/file_picker/ios"
|
||||
file_saver:
|
||||
@@ -322,12 +354,16 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview_ios/ios"
|
||||
flutter_native_splash:
|
||||
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||
flutter_timezone:
|
||||
:path: ".symlinks/plugins/flutter_timezone/ios"
|
||||
flutter_udid:
|
||||
:path: ".symlinks/plugins/flutter_udid/ios"
|
||||
flutter_webrtc:
|
||||
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
||||
gal:
|
||||
:path: ".symlinks/plugins/gal/darwin"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/ios"
|
||||
home_widget:
|
||||
:path: ".symlinks/plugins/home_widget/ios"
|
||||
image_picker_ios:
|
||||
@@ -360,6 +396,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite_darwin:
|
||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||
sqlite3_flutter_libs:
|
||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/darwin"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_compress:
|
||||
@@ -378,32 +416,35 @@ SPEC CHECKSUMS:
|
||||
device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
|
||||
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
|
||||
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
|
||||
fast_rsa: dc48fb05f26bb108863de122b2a9f5554e8e2591
|
||||
file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
Firebase: a64bf6a8546e6eab54f1c715cd6151f39d2329f4
|
||||
firebase_analytics: 7236e6115c1b4e62c2270faa29c052a317e31107
|
||||
firebase_core: aa979ae726f00b3ef4ccf59dfb96170af84efbd4
|
||||
firebase_messaging: 3af84b6a90aeac4d7a67fbf4c43a91e7083bea1f
|
||||
FirebaseAnalytics: bc9e565af9044ba8d6c6e4157e4edca8e5fdf7ec
|
||||
FirebaseCore: 3227e35f4197a924206fbcdc0349325baf4f5de4
|
||||
FirebaseCoreInternal: d6c17dafc8dc33614733a8b52df78fcb4394c881
|
||||
FirebaseInstallations: 9347e719c3d52d8d7b9074b2c32407dd027305e9
|
||||
FirebaseMessaging: 00ece041b71ddb52a2862ffdee73fb6e9824bd0c
|
||||
Firebase: d80354ed7f6df5f9aca55e9eb47cc4b634735eaf
|
||||
firebase_analytics: e3b6782e70e32b7fa18f7cd233e3201975dd86aa
|
||||
firebase_core: ac395f994af4e28f6a38b59e05a88ca57abeb874
|
||||
firebase_messaging: 7e223f4ee7ca053bf4ce43748e84a6d774ec9728
|
||||
FirebaseAnalytics: 4fd42def128146e24e480e89f310e3d8534ea42b
|
||||
FirebaseCore: 99fe0c4b44a39f37d99e6404e02009d2db5d718d
|
||||
FirebaseCoreInternal: df24ce5af28864660ecbd13596fc8dd3a8c34629
|
||||
FirebaseInstallations: 6c963bd2a86aca0481eef4f48f5a4df783ae5917
|
||||
FirebaseMessaging: 487b634ccdf6f7b7ff180fdcb2a9935490f764e8
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_app_update: 65f61da626cb111d1b24674abc4b01728d7723bc
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a
|
||||
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||
flutter_timezone: ac3da59ac941ff1c98a2e1f0293420e020120282
|
||||
flutter_udid: b2417673f287ee62817a1de3d1643f47b9f508ab
|
||||
flutter_webrtc: 90260f83024b1b96d239a575ea4e3708e79344d1
|
||||
gal: 6a522c75909f1244732d4596d11d6a2f86ff37a5
|
||||
GoogleAppMeasurement: 0471a5b5bff51f3a91b1e76df22c952d04c63967
|
||||
geolocator_apple: 9bcea1918ff7f0062d98345d238ae12718acfbc1
|
||||
GoogleAppMeasurement: fc0817122bd4d4189164f85374e06773b9561896
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_review: a31b5257259646ea78e0e35fc914979b0031d011
|
||||
Kingfisher: 323e5c4ec7983aaace12af655a7b51a7f88a599d
|
||||
livekit_client: 148b2cf67a09aaf475ba8e5bf1667fe10dc35f81
|
||||
livekit_client: 9819ebc8be8ef00ed0fae7d806bf8938ec689573
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
@@ -417,10 +458,12 @@ SPEC CHECKSUMS:
|
||||
receive_sharing_intent: 79c848f5b045674ad60b9fea3bafea59962ad2c1
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
||||
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
|
||||
SDWebImage: 33d0f23bddeb5d209ae959153883247be6703713
|
||||
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||
sqlite3: fc1400008a9b3525f5914ed715a5d1af0b8f4983
|
||||
sqlite3_flutter_libs: cc304edcb8e1d8c595d1b08c7aeb46a47691d9db
|
||||
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
video_compress: fce97e4fb1dfd88175aa07d2ffc8a2f297f87fbe
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
enableGPUValidationMode = "1"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
|
||||
@@ -2,11 +2,15 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/channel.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/keypair.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
@@ -16,13 +20,15 @@ import 'package:surface/types/websocket.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class ChatMessageController extends ChangeNotifier {
|
||||
static const kChatMessageBoxPrefix = 'nex_chat_messages_';
|
||||
static const kSingleBatchLoadLimit = 100;
|
||||
|
||||
late final SnNetworkProvider _sn;
|
||||
late final UserDirectoryProvider _ud;
|
||||
late final WebSocketProvider _ws;
|
||||
late final SnAttachmentProvider _attach;
|
||||
late final DatabaseProvider _dt;
|
||||
late final ChatChannelProvider _ct;
|
||||
late final KeyPairProvider _kp;
|
||||
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
@@ -31,16 +37,20 @@ class ChatMessageController extends ChangeNotifier {
|
||||
_ud = context.read<UserDirectoryProvider>();
|
||||
_ws = context.read<WebSocketProvider>();
|
||||
_attach = context.read<SnAttachmentProvider>();
|
||||
_ct = context.read<ChatChannelProvider>();
|
||||
_dt = context.read<DatabaseProvider>();
|
||||
_kp = context.read<KeyPairProvider>();
|
||||
}
|
||||
|
||||
bool isPending = true;
|
||||
bool isLoading = false;
|
||||
bool isAggressiveLoading = false;
|
||||
|
||||
int? messageTotal;
|
||||
|
||||
bool get isAllLoaded => messageTotal != null && messages.length >= messageTotal!;
|
||||
bool get isAllLoaded =>
|
||||
messageTotal != null && messages.length >= messageTotal!;
|
||||
|
||||
String? _boxKey;
|
||||
SnChannel? channel;
|
||||
SnChannelMember? profile;
|
||||
|
||||
@@ -51,25 +61,14 @@ class ChatMessageController extends ChangeNotifier {
|
||||
/// Stored as a list of nonce to provide the loading state
|
||||
final List<String> unconfirmedMessages = List.empty(growable: true);
|
||||
|
||||
Box<SnChatMessage>? get _box => (_boxKey == null || isPending) ? null : Hive.box<SnChatMessage>(_boxKey!);
|
||||
|
||||
final List<SnChannelMember> typingMembers = List.empty(growable: true);
|
||||
final Map<int, Timer> typingInactiveTimer = {};
|
||||
|
||||
Future<void> initialize(SnChannel chan) async {
|
||||
channel = chan;
|
||||
|
||||
// Initialize local data
|
||||
_boxKey = '$kChatMessageBoxPrefix${chan.id}';
|
||||
await Hive.openBox<SnChatMessage>(_boxKey!);
|
||||
|
||||
// Fetch channel profile
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/im/channels/${chan.keyPath}/me',
|
||||
);
|
||||
profile = SnChannelMember.fromJson(
|
||||
resp.data as Map<String, dynamic>,
|
||||
);
|
||||
profile = await _ct.getChannelProfile(channel!);
|
||||
|
||||
_wsSubscription = _ws.pk.stream.listen((event) {
|
||||
switch (event.method) {
|
||||
@@ -87,7 +86,8 @@ class ChatMessageController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
typingInactiveTimer[member.id]?.cancel();
|
||||
typingInactiveTimer[member.id] = Timer(const Duration(seconds: 3), () {
|
||||
typingInactiveTimer[member.id] =
|
||||
Timer(const Duration(seconds: 3), () {
|
||||
typingMembers.removeWhere((x) => x.id == member.id);
|
||||
typingInactiveTimer.remove(member.id);
|
||||
notifyListeners();
|
||||
@@ -129,10 +129,16 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _saveMessageToLocal(Iterable<SnChatMessage> messages) async {
|
||||
if (_box == null) return;
|
||||
await _box!.putAll({
|
||||
for (final message in messages) message.id: message,
|
||||
});
|
||||
await _dt.db.snLocalChatMessage.insertAll(
|
||||
messages.map(
|
||||
(ele) => SnLocalChatMessageCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
content: ele,
|
||||
channelId: channel!.id,
|
||||
createdAt: Value(ele.createdAt),
|
||||
),
|
||||
),
|
||||
onConflict: DoNothing());
|
||||
}
|
||||
|
||||
Future<void> _addUnconfirmedMessage(SnChatMessage message) async {
|
||||
@@ -181,11 +187,27 @@ class ChatMessageController extends ChangeNotifier {
|
||||
} else {
|
||||
messages.insert(0, message);
|
||||
}
|
||||
notifyListeners();
|
||||
await _applyMessage(message);
|
||||
notifyListeners();
|
||||
|
||||
if (_box == null) return;
|
||||
await _box!.put(message.id, message);
|
||||
if (isCheckedUpdate) {
|
||||
await _dt.db.snLocalChatMessage.insertOne(
|
||||
SnLocalChatMessageCompanion.insert(
|
||||
id: Value(message.id),
|
||||
content: message,
|
||||
channelId: channel!.id,
|
||||
createdAt: Value(message.createdAt),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SnLocalChatMessageCompanion.custom(
|
||||
content: Constant(jsonEncode(message.toJson())),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
incomeStrandedQueue.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyMessage(SnChatMessage message) async {
|
||||
@@ -194,29 +216,56 @@ class ChatMessageController extends ChangeNotifier {
|
||||
switch (message.type) {
|
||||
case 'messages.edit':
|
||||
if (message.relatedEventId != null) {
|
||||
final idx = messages.indexWhere((x) => x.id == message.relatedEventId);
|
||||
final idx =
|
||||
messages.indexWhere((x) => x.id == message.relatedEventId);
|
||||
if (idx != -1) {
|
||||
final newBody = message.body;
|
||||
final newBody = Map<String, dynamic>.from(message.body);
|
||||
newBody.remove('related_event');
|
||||
messages[idx] = messages[idx].copyWith(
|
||||
body: newBody,
|
||||
updatedAt: message.updatedAt,
|
||||
);
|
||||
if (_box!.containsKey(message.relatedEventId)) {
|
||||
await _box!.put(message.relatedEventId, messages[idx]);
|
||||
}
|
||||
}
|
||||
if (message.relatedEventId != null) {
|
||||
await (_dt.db.snLocalChatMessage.update()
|
||||
..where((e) => e.id.equals(message.relatedEventId!)))
|
||||
.write(
|
||||
SnLocalChatMessageCompanion.custom(
|
||||
content: Constant(jsonEncode(messages[idx].toJson())),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
case 'messages.delete':
|
||||
if (message.relatedEventId != null) {
|
||||
messages.removeWhere((x) => x.id == message.relatedEventId);
|
||||
if (_box!.containsKey(message.relatedEventId)) {
|
||||
await _box!.delete(message.relatedEventId);
|
||||
if (message.relatedEventId != null) {
|
||||
await (_dt.db.snLocalChatMessage.delete()
|
||||
..where((e) => e.id.equals(message.relatedEventId!)))
|
||||
.go();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _encodeMessageBody(
|
||||
String text,
|
||||
bool isEncrypted,
|
||||
) async {
|
||||
if (!isEncrypted || _kp.activeKp == null) {
|
||||
return {
|
||||
'text': text,
|
||||
'algorithm': 'plain',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
'text': await _kp.encryptText(text),
|
||||
'algorithm': 'rsa',
|
||||
'keypair_id': _kp.activeKp!.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(
|
||||
String type,
|
||||
String content, {
|
||||
@@ -224,36 +273,40 @@ class ChatMessageController extends ChangeNotifier {
|
||||
int? relatedId,
|
||||
List<String>? attachments,
|
||||
SnChatMessage? editingMessage,
|
||||
bool isEncrypted = false,
|
||||
}) async {
|
||||
if (channel == null) return;
|
||||
const uuid = Uuid();
|
||||
final nonce = uuid.v4();
|
||||
final body = {
|
||||
'text': content,
|
||||
'algorithm': 'plain',
|
||||
...(await _encodeMessageBody(content, isEncrypted)),
|
||||
if (quoteId != null) 'quote_event': quoteId,
|
||||
if (relatedId != null) 'related_event': relatedId,
|
||||
if (attachments != null && attachments.isNotEmpty) 'attachments': attachments,
|
||||
if (attachments != null && attachments.isNotEmpty)
|
||||
'attachments': attachments,
|
||||
};
|
||||
|
||||
// Mock the message locally
|
||||
final createdAt = DateTime.now();
|
||||
final message = SnChatMessage(
|
||||
id: 0,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
deletedAt: null,
|
||||
uuid: nonce,
|
||||
body: body,
|
||||
type: type,
|
||||
channel: channel!,
|
||||
channelId: channel!.id,
|
||||
sender: profile!,
|
||||
senderId: profile!.id,
|
||||
quoteEventId: quoteId,
|
||||
relatedEventId: relatedId,
|
||||
);
|
||||
_addUnconfirmedMessage(message);
|
||||
// Do not mock the editing message
|
||||
if (editingMessage == null) {
|
||||
final createdAt = DateTime.now();
|
||||
final message = SnChatMessage(
|
||||
id: 0,
|
||||
createdAt: createdAt,
|
||||
updatedAt: createdAt,
|
||||
deletedAt: null,
|
||||
uuid: nonce,
|
||||
body: body,
|
||||
type: type,
|
||||
channel: channel!,
|
||||
channelId: channel!.id,
|
||||
sender: profile!,
|
||||
senderId: profile!.id,
|
||||
quoteEventId: quoteId,
|
||||
relatedEventId: relatedId,
|
||||
);
|
||||
_addUnconfirmedMessage(message);
|
||||
}
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
@@ -287,20 +340,36 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
bool isCheckedUpdate = false;
|
||||
List<SnChatMessage> incomeStrandedQueue = List.empty(growable: true);
|
||||
|
||||
/// Check the local storage is up to date with the server.
|
||||
/// If the local storage is not up to date, it will be updated.
|
||||
Future<void> checkUpdate() async {
|
||||
if (_box == null) return;
|
||||
if (_box!.isEmpty) return;
|
||||
|
||||
isLoading = true;
|
||||
isAggressiveLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
final mostRecentMessage = await (_dt.db.snLocalChatMessage.select()
|
||||
..where((e) => e.channelId.equals(channel!.id))
|
||||
..limit(1)
|
||||
..orderBy([
|
||||
(e) =>
|
||||
OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
|
||||
]))
|
||||
.getSingleOrNull();
|
||||
if (mostRecentMessage == null) {
|
||||
// Initial load
|
||||
await loadMessages(take: 20);
|
||||
isAggressiveLoading = false;
|
||||
isCheckedUpdate = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/im/channels/${channel!.keyPath}/events/update',
|
||||
queryParameters: {
|
||||
'pivot': _box!.values.last.id,
|
||||
'pivot': mostRecentMessage.content.id,
|
||||
},
|
||||
);
|
||||
if (resp.data['up_to_date'] == true) return;
|
||||
@@ -309,13 +378,25 @@ class ChatMessageController extends ChangeNotifier {
|
||||
final countToFetch = math.min(resp.data['count'] as int, 100);
|
||||
|
||||
for (int idx = 0; idx < countToFetch; idx += kSingleBatchLoadLimit) {
|
||||
await getMessages(kSingleBatchLoadLimit, idx, forceRemote: true);
|
||||
final out = await getMessages(
|
||||
kSingleBatchLoadLimit,
|
||||
idx,
|
||||
forceRemote: true,
|
||||
);
|
||||
messages.insertAll(0, out);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (err) {
|
||||
rethrow;
|
||||
} finally {
|
||||
await loadMessages();
|
||||
isLoading = false;
|
||||
isAggressiveLoading = false;
|
||||
|
||||
isCheckedUpdate = true;
|
||||
_saveMessageToLocal(incomeStrandedQueue).then((_) {
|
||||
incomeStrandedQueue.clear();
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -324,13 +405,18 @@ class ChatMessageController extends ChangeNotifier {
|
||||
/// If it was not found in local storage we will look it up in remote
|
||||
Future<SnChatMessage?> getMessage(int id) async {
|
||||
SnChatMessage? out;
|
||||
if (_box != null && _box!.containsKey(id)) {
|
||||
out = _box!.get(id);
|
||||
final local = await (_dt.db.snLocalChatMessage.select()
|
||||
..limit(1)
|
||||
..where((e) => e.id.equals(id)))
|
||||
.getSingleOrNull();
|
||||
if (local != null) {
|
||||
out = local.content;
|
||||
}
|
||||
|
||||
if (out == null) {
|
||||
try {
|
||||
final resp = await _sn.client.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
|
||||
final resp = await _sn.client
|
||||
.get('/cgi/im/channels/${channel!.keyPath}/events/$id');
|
||||
out = SnChatMessage.fromJson(resp.data);
|
||||
_saveMessageToLocal([out]);
|
||||
} catch (_) {
|
||||
@@ -364,16 +450,21 @@ class ChatMessageController extends ChangeNotifier {
|
||||
bool forceLocal = false,
|
||||
bool forceRemote = false,
|
||||
}) async {
|
||||
final localTotal = await _dt.db.snLocalChatMessage
|
||||
.count(where: (e) => e.channelId.equals(channel!.id))
|
||||
.getSingle();
|
||||
|
||||
late List<SnChatMessage> out;
|
||||
if (_box != null && (_box!.length >= take + offset || forceLocal) && !forceRemote) {
|
||||
out = _box!.keys
|
||||
.toList()
|
||||
.cast<int>()
|
||||
.sorted((a, b) => b.compareTo(a))
|
||||
.skip(offset)
|
||||
.take(take)
|
||||
.map((key) => _box!.get(key)!)
|
||||
.toList();
|
||||
if ((localTotal >= take + offset || forceLocal) && !forceRemote) {
|
||||
final result = await (_dt.db.snLocalChatMessage.select()
|
||||
..where((e) => e.channelId.equals(channel!.id))
|
||||
..orderBy([
|
||||
(e) =>
|
||||
OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
|
||||
])
|
||||
..limit(take, offset: offset))
|
||||
.get();
|
||||
out = result.map((e) => e.content).toList();
|
||||
} else {
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/im/channels/${channel!.keyPath}/events',
|
||||
@@ -408,7 +499,8 @@ class ChatMessageController extends ChangeNotifier {
|
||||
quoteEvent: quoteEvent,
|
||||
attachments: attachments
|
||||
.where(
|
||||
(ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
(ele) =>
|
||||
out[i].body['attachments']?.contains(ele?.rid) ?? false,
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
@@ -416,7 +508,10 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
// Preload sender accounts
|
||||
final accountId = out.where((ele) => ele.sender.accountId >= 0).map((ele) => ele.sender.accountId).toSet();
|
||||
final accountId = out
|
||||
.where((ele) => ele.sender.accountId >= 0)
|
||||
.map((ele) => ele.sender.accountId)
|
||||
.toSet();
|
||||
await _ud.listAccount(accountId);
|
||||
|
||||
return out;
|
||||
@@ -441,10 +536,45 @@ class ChatMessageController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Timer? _readEventDebounce;
|
||||
int? _readEventAnchor;
|
||||
|
||||
void readEvent(int id) {
|
||||
if (_readEventAnchor != null) {
|
||||
_readEventAnchor = math.max(_readEventAnchor!, id);
|
||||
} else {
|
||||
_readEventAnchor = id;
|
||||
}
|
||||
if (_readEventDebounce?.isActive ?? false) {
|
||||
_readEventDebounce?.cancel();
|
||||
}
|
||||
|
||||
_readEventDebounce = Timer(const Duration(milliseconds: 500), () {
|
||||
_sendReadEvent();
|
||||
});
|
||||
}
|
||||
|
||||
void _sendReadEvent() {
|
||||
_ws.conn?.sink.add(jsonEncode(
|
||||
WebSocketPackage(
|
||||
method: 'events.read',
|
||||
endpoint: 'im',
|
||||
payload: {
|
||||
'channel_member_id': profile!.id,
|
||||
'event_id': _readEventAnchor,
|
||||
},
|
||||
).toJson(),
|
||||
));
|
||||
logging.debug('[Messaging] Send read event request: $_readEventAnchor');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_box?.close();
|
||||
_wsSubscription?.cancel();
|
||||
if (_readEventDebounce?.isActive ?? false) {
|
||||
_sendReadEvent();
|
||||
}
|
||||
_readEventDebounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/poll.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:video_compress/video_compress.dart';
|
||||
@@ -70,7 +71,8 @@ class PostWriteMedia {
|
||||
}
|
||||
}
|
||||
|
||||
PostWriteMedia.fromBytes(this.raw, this.name, this.type, {this.attachment, this.file});
|
||||
PostWriteMedia.fromBytes(this.raw, this.name, this.type,
|
||||
{this.attachment, this.file});
|
||||
|
||||
bool get isEmpty => attachment == null && file == null && raw == null;
|
||||
|
||||
@@ -104,7 +106,8 @@ class PostWriteMedia {
|
||||
}) {
|
||||
if (attachment != null) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ImageProvider provider = UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
||||
final ImageProvider provider =
|
||||
UniversalImage.provider(sn.getAttachmentUrl(attachment!.rid));
|
||||
if (width != null && height != null && !kIsWeb) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
@@ -115,7 +118,8 @@ class PostWriteMedia {
|
||||
}
|
||||
return provider;
|
||||
} else if (file != null) {
|
||||
final ImageProvider provider = kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
|
||||
final ImageProvider provider =
|
||||
kIsWeb ? NetworkImage(file!.path) : FileImage(File(file!.path));
|
||||
if (width != null && height != null) {
|
||||
return ResizeImage(
|
||||
provider,
|
||||
@@ -158,13 +162,17 @@ class PostWriteController extends ChangeNotifier {
|
||||
final TextEditingController aliasController = TextEditingController();
|
||||
final TextEditingController rewardController = TextEditingController();
|
||||
|
||||
ContentInsertionConfiguration get contentInsertionConfiguration => ContentInsertionConfiguration(
|
||||
onContentInserted: (KeyboardInsertedContent content) {
|
||||
if (content.hasData) {
|
||||
addAttachments([PostWriteMedia.fromBytes(content.data!, 'attachmentInsertedImage'.tr(), SnMediaType.image)]);
|
||||
}
|
||||
},
|
||||
);
|
||||
ContentInsertionConfiguration get contentInsertionConfiguration =>
|
||||
ContentInsertionConfiguration(
|
||||
onContentInserted: (KeyboardInsertedContent content) {
|
||||
if (content.hasData) {
|
||||
addAttachments([
|
||||
PostWriteMedia.fromBytes(content.data!,
|
||||
'attachmentInsertedImage'.tr(), SnMediaType.image)
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
bool _temporarySaveActive = false;
|
||||
|
||||
@@ -191,11 +199,13 @@ class PostWriteController extends ChangeNotifier {
|
||||
|
||||
String get description => descriptionController.text;
|
||||
|
||||
bool get isRelatedNull => ![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
|
||||
bool get isRelatedNull =>
|
||||
![editingPost, repostingPost, replyingPost].any((ele) => ele != null);
|
||||
|
||||
bool isLoading = false, isBusy = false;
|
||||
double? progress;
|
||||
|
||||
SnRealm? realm;
|
||||
SnPublisher? publisher;
|
||||
SnPost? editingPost, repostingPost, replyingPost;
|
||||
|
||||
@@ -234,16 +244,23 @@ class PostWriteController extends ChangeNotifier {
|
||||
publishedAt = post.publishedAt;
|
||||
publishedUntil = post.publishedUntil;
|
||||
visibleUsers = List.from(post.visibleUsersList ?? [], growable: true);
|
||||
invisibleUsers = List.from(post.invisibleUsersList ?? [], growable: true);
|
||||
invisibleUsers =
|
||||
List.from(post.invisibleUsersList ?? [], growable: true);
|
||||
visibility = post.visibility;
|
||||
tags = List.from(post.tags.map((ele) => ele.alias), growable: true);
|
||||
categories = List.from(post.categories.map((ele) => ele.alias), growable: true);
|
||||
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
|
||||
categories =
|
||||
List.from(post.categories.map((ele) => ele.alias), growable: true);
|
||||
attachments.addAll(
|
||||
post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
|
||||
poll = post.preload?.poll;
|
||||
|
||||
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
|
||||
if (post.preload?.thumbnail != null &&
|
||||
(post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
|
||||
thumbnail = PostWriteMedia(post.preload!.thumbnail);
|
||||
}
|
||||
if (post.preload?.realm != null) {
|
||||
realm = post.preload!.realm!;
|
||||
}
|
||||
|
||||
editingPost = post;
|
||||
}
|
||||
@@ -266,7 +283,8 @@ class PostWriteController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<SnAttachment> _uploadAttachment(BuildContext context, PostWriteMedia media,
|
||||
Future<SnAttachment> _uploadAttachment(
|
||||
BuildContext context, PostWriteMedia media,
|
||||
{bool isCompressed = false}) async {
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
@@ -275,7 +293,9 @@ class PostWriteController extends ChangeNotifier {
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image
|
||||
? 'image/png'
|
||||
: null,
|
||||
);
|
||||
|
||||
var item = await attach.chunkedUploadParts(
|
||||
@@ -291,9 +311,11 @@ class PostWriteController extends ChangeNotifier {
|
||||
|
||||
if (media.type == SnMediaType.video && !isCompressed && context.mounted) {
|
||||
try {
|
||||
final compressedAttachment = await _tryCompressVideoCopy(context, media);
|
||||
final compressedAttachment =
|
||||
await _tryCompressVideoCopy(context, media);
|
||||
if (compressedAttachment != null) {
|
||||
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
|
||||
item = await attach.updateOne(item,
|
||||
compressedId: compressedAttachment.id);
|
||||
}
|
||||
} catch (err) {
|
||||
if (context.mounted) context.showErrorDialog(err);
|
||||
@@ -303,8 +325,10 @@ class PostWriteController extends ChangeNotifier {
|
||||
return item;
|
||||
}
|
||||
|
||||
Future<SnAttachment?> _tryCompressVideoCopy(BuildContext context, PostWriteMedia media) async {
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS)) return null;
|
||||
Future<SnAttachment?> _tryCompressVideoCopy(
|
||||
BuildContext context, PostWriteMedia media) async {
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS))
|
||||
return null;
|
||||
if (media.type != SnMediaType.video) return null;
|
||||
if (media.file == null) return null;
|
||||
if (VideoCompress.isCompressing) return null;
|
||||
@@ -328,7 +352,8 @@ class PostWriteController extends ChangeNotifier {
|
||||
if (!context.mounted) return null;
|
||||
|
||||
final compressedMedia = PostWriteMedia.fromFile(XFile(mediaInfo.path!));
|
||||
final compressedAttachment = await _uploadAttachment(context, compressedMedia, isCompressed: true);
|
||||
final compressedAttachment =
|
||||
await _uploadAttachment(context, compressedMedia, isCompressed: true);
|
||||
|
||||
return compressedAttachment;
|
||||
}
|
||||
@@ -364,26 +389,40 @@ class PostWriteController extends ChangeNotifier {
|
||||
'content': contentController.text,
|
||||
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
||||
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
||||
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
|
||||
if (descriptionController.text.isNotEmpty)
|
||||
'description': descriptionController.text,
|
||||
if (rewardController.text.isNotEmpty) 'reward': rewardController.text,
|
||||
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.toJson(),
|
||||
'attachments':
|
||||
attachments.where((e) => e.attachment != null).map((e) => e.attachment!.toJson()).toList(growable: true),
|
||||
if (thumbnail != null && thumbnail!.attachment != null)
|
||||
'thumbnail': thumbnail!.attachment!.toJson(),
|
||||
'attachments': attachments
|
||||
.where((e) => e.attachment != null)
|
||||
.map((e) => e.attachment!.toJson())
|
||||
.toList(growable: true),
|
||||
'tags': tags.map((ele) => {'alias': ele}).toList(growable: true),
|
||||
'categories': categories.map((ele) => {'alias': ele}).toList(growable: true),
|
||||
'categories':
|
||||
categories.map((ele) => {'alias': ele}).toList(growable: true),
|
||||
'visibility': visibility,
|
||||
'visible_users_list': visibleUsers,
|
||||
'invisible_users_list': invisibleUsers,
|
||||
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedAt != null)
|
||||
'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedUntil != null)
|
||||
'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||
if (replyingPost != null) 'reply_to': replyingPost!.toJson(),
|
||||
if (repostingPost != null) 'repost_to': repostingPost!.toJson(),
|
||||
if (poll != null) 'poll': poll!.toJson(),
|
||||
if (realm != null) 'realm': realm!.toJson(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
bool get isNotEmpty =>
|
||||
title.isNotEmpty ||
|
||||
description.isNotEmpty ||
|
||||
contentController.text.isNotEmpty ||
|
||||
attachments.isNotEmpty;
|
||||
|
||||
bool temporaryRestored = false;
|
||||
|
||||
void _temporaryLoad() {
|
||||
@@ -396,19 +435,26 @@ class PostWriteController extends ChangeNotifier {
|
||||
titleController.text = data['title'] ?? '';
|
||||
descriptionController.text = data['description'] ?? '';
|
||||
rewardController.text = data['reward']?.toString() ?? '';
|
||||
if (data['thumbnail'] != null) thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
|
||||
attachments
|
||||
.addAll(data['attachments'].map((ele) => PostWriteMedia(SnAttachment.fromJson(ele))).cast<PostWriteMedia>());
|
||||
if (data['thumbnail'] != null)
|
||||
thumbnail = PostWriteMedia(SnAttachment.fromJson(data['thumbnail']));
|
||||
attachments.addAll(data['attachments']
|
||||
.map((ele) => PostWriteMedia(SnAttachment.fromJson(ele)))
|
||||
.cast<PostWriteMedia>());
|
||||
tags = List.from(data['tags'].map((ele) => ele['alias']));
|
||||
categories = List.from(data['categories'].map((ele) => ele['alias']));
|
||||
visibility = data['visibility'];
|
||||
visibleUsers = List.from(data['visible_users_list'] ?? []);
|
||||
invisibleUsers = List.from(data['invisible_users_list'] ?? []);
|
||||
if (data['published_at'] != null) publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
|
||||
if (data['published_until'] != null) publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
|
||||
replyingPost = data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
|
||||
repostingPost = data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
|
||||
if (data['published_at'] != null)
|
||||
publishedAt = DateTime.tryParse(data['published_at'])?.toLocal();
|
||||
if (data['published_until'] != null)
|
||||
publishedUntil = DateTime.tryParse(data['published_until'])?.toLocal();
|
||||
replyingPost =
|
||||
data['reply_to'] != null ? SnPost.fromJson(data['reply_to']) : null;
|
||||
repostingPost =
|
||||
data['repost_to'] != null ? SnPost.fromJson(data['repost_to']) : null;
|
||||
poll = data['poll'] != null ? SnPoll.fromJson(data['poll']) : null;
|
||||
realm = data['realm'] != null ? SnRealm.fromJson(data['realm']) : null;
|
||||
temporaryRestored = true;
|
||||
notifyListeners();
|
||||
});
|
||||
@@ -455,7 +501,9 @@ class PostWriteController extends ChangeNotifier {
|
||||
media.name,
|
||||
'interactive',
|
||||
null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image ? 'image/png' : null,
|
||||
mimetype: media.raw != null && media.type == SnMediaType.image
|
||||
? 'image/png'
|
||||
: null,
|
||||
);
|
||||
|
||||
var item = await attach.chunkedUploadParts(
|
||||
@@ -464,16 +512,20 @@ class PostWriteController extends ChangeNotifier {
|
||||
place.$2,
|
||||
onProgress: (value) {
|
||||
// Calculate overall progress for attachments
|
||||
progress = math.max(((i + value) / attachments.length) * kAttachmentProgressWeight, value);
|
||||
progress = math.max(
|
||||
((i + value) / attachments.length) * kAttachmentProgressWeight,
|
||||
value);
|
||||
notifyListeners();
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (context.mounted) {
|
||||
final compressedAttachment = await _tryCompressVideoCopy(context, media);
|
||||
final compressedAttachment =
|
||||
await _tryCompressVideoCopy(context, media);
|
||||
if (compressedAttachment != null) {
|
||||
item = await attach.updateOne(item, compressedId: compressedAttachment.id);
|
||||
item = await attach.updateOne(item,
|
||||
compressedId: compressedAttachment.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -510,28 +562,39 @@ class PostWriteController extends ChangeNotifier {
|
||||
'content': contentController.text,
|
||||
if (aliasController.text.isNotEmpty) 'alias': aliasController.text,
|
||||
if (titleController.text.isNotEmpty) 'title': titleController.text,
|
||||
if (descriptionController.text.isNotEmpty) 'description': descriptionController.text,
|
||||
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
|
||||
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
|
||||
if (descriptionController.text.isNotEmpty)
|
||||
'description': descriptionController.text,
|
||||
if (thumbnail != null && thumbnail!.attachment != null)
|
||||
'thumbnail': thumbnail!.attachment!.rid,
|
||||
'attachments': attachments
|
||||
.where((e) => e.attachment != null)
|
||||
.map((e) => e.attachment!.rid)
|
||||
.toList(),
|
||||
'tags': tags.map((ele) => {'alias': ele}).toList(),
|
||||
'categories': categories.map((ele) => {'alias': ele}).toList(),
|
||||
'visibility': visibility,
|
||||
'visible_users_list': visibleUsers,
|
||||
'invisible_users_list': invisibleUsers,
|
||||
if (publishedAt != null) 'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedUntil != null) 'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedAt != null)
|
||||
'published_at': publishedAt!.toUtc().toIso8601String(),
|
||||
if (publishedUntil != null)
|
||||
'published_until': publishedAt!.toUtc().toIso8601String(),
|
||||
if (replyingPost != null) 'reply_to': replyingPost!.id,
|
||||
if (repostingPost != null) 'repost_to': repostingPost!.id,
|
||||
if (reward != null) 'reward': reward,
|
||||
if (videoAttachment != null) 'video': videoAttachment!.rid,
|
||||
if (poll != null) 'poll': poll!.id,
|
||||
if (realm != null) 'realm': realm!.id,
|
||||
},
|
||||
onSendProgress: (count, total) {
|
||||
progress = baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
|
||||
progress =
|
||||
baseProgressVal + (count / total) * (kPostingProgressWeight / 2);
|
||||
notifyListeners();
|
||||
},
|
||||
onReceiveProgress: (count, total) {
|
||||
progress = baseProgressVal + (kPostingProgressWeight / 2) + (count / total) * (kPostingProgressWeight / 2);
|
||||
progress = baseProgressVal +
|
||||
(kPostingProgressWeight / 2) +
|
||||
(count / total) * (kPostingProgressWeight / 2);
|
||||
notifyListeners();
|
||||
},
|
||||
options: Options(
|
||||
@@ -571,17 +634,8 @@ class PostWriteController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setThumbnail(int? idx) {
|
||||
if (idx == null) {
|
||||
attachments.add(thumbnail!);
|
||||
thumbnail = null;
|
||||
} else {
|
||||
if (thumbnail != null) {
|
||||
attachments.add(thumbnail!);
|
||||
}
|
||||
thumbnail = attachments[idx];
|
||||
attachments.removeAt(idx);
|
||||
}
|
||||
void setThumbnail(SnAttachment? value) {
|
||||
thumbnail = value == null ? null : PostWriteMedia(value);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -633,6 +687,11 @@ class PostWriteController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setRealm(SnRealm? value) {
|
||||
realm = value;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void setProgress(double? value) {
|
||||
progress = value;
|
||||
_temporaryPlanSave();
|
||||
@@ -678,7 +737,8 @@ class PostWriteController extends ChangeNotifier {
|
||||
repostingPost = null;
|
||||
mode = kTitleMap.keys.first;
|
||||
temporaryRestored = false;
|
||||
SharedPreferences.getInstance().then((prefs) => prefs.remove(kTemporaryStorageKey));
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.remove(kTemporaryStorageKey));
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
42
lib/database/account.dart
Normal file
42
lib/database/account.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
|
||||
class SnAccountConverter extends TypeConverter<SnAccount, String>
|
||||
with JsonTypeConverter2<SnAccount, String, Map<String, Object?>> {
|
||||
const SnAccountConverter();
|
||||
|
||||
@override
|
||||
SnAccount fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnAccount value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnAccount fromJson(Map<String, Object?> json) {
|
||||
return SnAccount.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnAccount value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@TableIndex(name: 'idx_account_name', columns: {#name})
|
||||
class SnLocalAccount extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get name => text()();
|
||||
|
||||
TextColumn get content => text().map(const SnAccountConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||
}
|
||||
47
lib/database/attachment.dart
Normal file
47
lib/database/attachment.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
|
||||
class SnAttachmentConverter extends TypeConverter<SnAttachment, String>
|
||||
with JsonTypeConverter2<SnAttachment, String, Map<String, Object?>> {
|
||||
const SnAttachmentConverter();
|
||||
|
||||
@override
|
||||
SnAttachment fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnAttachment value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnAttachment fromJson(Map<String, Object?> json) {
|
||||
return SnAttachment.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnAttachment value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@TableIndex(name: 'idx_attachment_rid', columns: {#rid})
|
||||
@TableIndex(name: 'idx_attachment_account', columns: {#accountId})
|
||||
class SnLocalAttachment extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get rid => text().unique()();
|
||||
|
||||
TextColumn get uuid => text().unique()();
|
||||
|
||||
TextColumn get content => text().map(const SnAttachmentConverter())();
|
||||
|
||||
IntColumn get accountId => integer()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||
}
|
||||
117
lib/database/chat.dart
Normal file
117
lib/database/chat.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
|
||||
class SnChannelConverter extends TypeConverter<SnChannel, String>
|
||||
with JsonTypeConverter2<SnChannel, String, Map<String, Object?>> {
|
||||
const SnChannelConverter();
|
||||
|
||||
@override
|
||||
SnChannel fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnChannel value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnChannel fromJson(Map<String, Object?> json) {
|
||||
return SnChannel.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnChannel value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@TableIndex(name: 'idx_channel_alias', columns: {#alias})
|
||||
class SnLocalChatChannel extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get alias => text()();
|
||||
|
||||
TextColumn get content => text().map(const SnChannelConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
class SnMessageConverter extends TypeConverter<SnChatMessage, String>
|
||||
with JsonTypeConverter2<SnChatMessage, String, Map<String, Object?>> {
|
||||
const SnMessageConverter();
|
||||
|
||||
@override
|
||||
SnChatMessage fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnChatMessage value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnChatMessage fromJson(Map<String, Object?> json) {
|
||||
return SnChatMessage.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnChatMessage value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
@TableIndex(name: 'idx_chat_channel', columns: {#channelId})
|
||||
class SnLocalChatMessage extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
IntColumn get channelId => integer()();
|
||||
|
||||
IntColumn get senderId => integer().nullable()();
|
||||
|
||||
TextColumn get content => text().map(const SnMessageConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
class SnChannelMemberConverter extends TypeConverter<SnChannelMember, String>
|
||||
with JsonTypeConverter2<SnChannelMember, String, Map<String, Object?>> {
|
||||
const SnChannelMemberConverter();
|
||||
|
||||
@override
|
||||
SnChannelMember fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnChannelMember value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnChannelMember fromJson(Map<String, Object?> json) {
|
||||
return SnChannelMember.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnChannelMember value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
class SnLocalChannelMember extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
IntColumn get channelId => integer()();
|
||||
|
||||
IntColumn get accountId => integer()();
|
||||
|
||||
TextColumn get content => text().map(SnChannelMemberConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
DateTimeColumn get cacheExpiredAt => dateTime()();
|
||||
}
|
||||
55
lib/database/database.dart
Normal file
55
lib/database/database.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:drift_flutter/drift_flutter.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:surface/database/account.dart';
|
||||
import 'package:surface/database/attachment.dart';
|
||||
import 'package:surface/database/chat.dart';
|
||||
import 'package:surface/database/database.steps.dart';
|
||||
import 'package:surface/database/keypair.dart';
|
||||
import 'package:surface/database/sticker.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
|
||||
part 'database.g.dart';
|
||||
|
||||
@DriftDatabase(tables: [
|
||||
SnLocalChatChannel,
|
||||
SnLocalChatMessage,
|
||||
SnLocalChannelMember,
|
||||
SnLocalKeyPair,
|
||||
SnLocalAccount,
|
||||
SnLocalAttachment,
|
||||
SnLocalSticker,
|
||||
SnLocalStickerPack,
|
||||
])
|
||||
class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase([QueryExecutor? e]) : super(e ?? _openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
name: 'solar_network_data',
|
||||
native: const DriftNativeOptions(
|
||||
databaseDirectory: getApplicationSupportDirectory,
|
||||
),
|
||||
web: DriftWebOptions(
|
||||
sqlite3Wasm: Uri.parse('sqlite3.wasm'),
|
||||
driftWorker: Uri.parse('drift_worker.dart.js'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onUpgrade: stepByStep(from1To2: (m, schema) async {
|
||||
// Nothing else to do here
|
||||
}, from2To3: (m, schema) async {
|
||||
// Nothing else to do here, too
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
3932
lib/database/database.g.dart
Normal file
3932
lib/database/database.g.dart
Normal file
File diff suppressed because it is too large
Load Diff
445
lib/database/database.steps.dart
Normal file
445
lib/database/database.steps.dart
Normal file
@@ -0,0 +1,445 @@
|
||||
// dart format width=80
|
||||
import 'package:drift/internal/versioned_schema.dart' as i0;
|
||||
import 'package:drift/drift.dart' as i1;
|
||||
import 'package:drift/drift.dart'; // ignore_for_file: type=lint,unused_import
|
||||
|
||||
// GENERATED BY drift_dev, DO NOT MODIFY.
|
||||
final class Schema2 extends i0.VersionedSchema {
|
||||
Schema2({required super.database}) : super(version: 2);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
snLocalChatChannel,
|
||||
snLocalChatMessage,
|
||||
snLocalKeyPair,
|
||||
];
|
||||
late final Shape0 snLocalChatChannel = Shape0(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_chat_channel',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_2,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape1 snLocalChatMessage = Shape1(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_chat_message',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_4,
|
||||
_column_2,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape2 snLocalKeyPair = Shape2(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_key_pair',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(id)',
|
||||
],
|
||||
columns: [
|
||||
_column_5,
|
||||
_column_6,
|
||||
_column_7,
|
||||
_column_8,
|
||||
_column_9,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
}
|
||||
|
||||
class Shape0 extends i0.VersionedTable {
|
||||
Shape0({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get alias =>
|
||||
columnsByName['alias']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_0(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('id', aliasedName, false,
|
||||
hasAutoIncrement: true,
|
||||
type: i1.DriftSqlType.int,
|
||||
defaultConstraints:
|
||||
i1.GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
|
||||
i1.GeneratedColumn<String> _column_1(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('alias', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_2(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('content', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<DateTime> _column_3(String aliasedName) =>
|
||||
i1.GeneratedColumn<DateTime>('created_at', aliasedName, false,
|
||||
type: i1.DriftSqlType.dateTime,
|
||||
defaultValue: const CustomExpression(
|
||||
'CAST(strftime(\'%s\', CURRENT_TIMESTAMP) AS INTEGER)'));
|
||||
|
||||
class Shape1 extends i0.VersionedTable {
|
||||
Shape1({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get channelId =>
|
||||
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_4(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('channel_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.int);
|
||||
|
||||
class Shape2 extends i0.VersionedTable {
|
||||
Shape2({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get accountId =>
|
||||
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get publicKey =>
|
||||
columnsByName['public_key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get privateKey =>
|
||||
columnsByName['private_key']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<bool> get isActive =>
|
||||
columnsByName['is_active']! as i1.GeneratedColumn<bool>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_5(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('id', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<int> _column_6(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('account_id', aliasedName, false,
|
||||
type: i1.DriftSqlType.int);
|
||||
i1.GeneratedColumn<String> _column_7(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('public_key', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<String> _column_8(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('private_key', aliasedName, true,
|
||||
type: i1.DriftSqlType.string);
|
||||
i1.GeneratedColumn<bool> _column_9(String aliasedName) =>
|
||||
i1.GeneratedColumn<bool>('is_active', aliasedName, false,
|
||||
type: i1.DriftSqlType.bool,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_active" IN (0, 1))'),
|
||||
defaultValue: const CustomExpression('0'));
|
||||
|
||||
final class Schema3 extends i0.VersionedSchema {
|
||||
Schema3({required super.database}) : super(version: 3);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
snLocalChatChannel,
|
||||
snLocalChatMessage,
|
||||
snLocalChannelMember,
|
||||
snLocalKeyPair,
|
||||
snLocalAccount,
|
||||
snLocalAttachment,
|
||||
snLocalSticker,
|
||||
snLocalStickerPack,
|
||||
idxChannelAlias,
|
||||
idxChatChannel,
|
||||
idxAccountName,
|
||||
idxAttachmentRid,
|
||||
idxAttachmentAccount,
|
||||
];
|
||||
late final Shape0 snLocalChatChannel = Shape0(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_chat_channel',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_2,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape3 snLocalChatMessage = Shape3(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_chat_message',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_4,
|
||||
_column_10,
|
||||
_column_2,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape4 snLocalChannelMember = Shape4(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_channel_member',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_4,
|
||||
_column_6,
|
||||
_column_2,
|
||||
_column_3,
|
||||
_column_11,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape2 snLocalKeyPair = Shape2(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_key_pair',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [
|
||||
'PRIMARY KEY(id)',
|
||||
],
|
||||
columns: [
|
||||
_column_5,
|
||||
_column_6,
|
||||
_column_7,
|
||||
_column_8,
|
||||
_column_9,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape5 snLocalAccount = Shape5(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_account',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_12,
|
||||
_column_2,
|
||||
_column_3,
|
||||
_column_11,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape6 snLocalAttachment = Shape6(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_attachment',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_13,
|
||||
_column_14,
|
||||
_column_2,
|
||||
_column_6,
|
||||
_column_3,
|
||||
_column_11,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape7 snLocalSticker = Shape7(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_sticker',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_1,
|
||||
_column_15,
|
||||
_column_2,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
late final Shape8 snLocalStickerPack = Shape8(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'sn_local_sticker_pack',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_0,
|
||||
_column_2,
|
||||
_column_3,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null);
|
||||
final i1.Index idxChannelAlias = i1.Index('idx_channel_alias',
|
||||
'CREATE INDEX idx_channel_alias ON sn_local_chat_channel (alias)');
|
||||
final i1.Index idxChatChannel = i1.Index('idx_chat_channel',
|
||||
'CREATE INDEX idx_chat_channel ON sn_local_chat_message (channel_id)');
|
||||
final i1.Index idxAccountName = i1.Index('idx_account_name',
|
||||
'CREATE INDEX idx_account_name ON sn_local_account (name)');
|
||||
final i1.Index idxAttachmentRid = i1.Index('idx_attachment_rid',
|
||||
'CREATE INDEX idx_attachment_rid ON sn_local_attachment (rid)');
|
||||
final i1.Index idxAttachmentAccount = i1.Index('idx_attachment_account',
|
||||
'CREATE INDEX idx_attachment_account ON sn_local_attachment (account_id)');
|
||||
}
|
||||
|
||||
class Shape3 extends i0.VersionedTable {
|
||||
Shape3({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get channelId =>
|
||||
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get senderId =>
|
||||
columnsByName['sender_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_10(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>('sender_id', aliasedName, true,
|
||||
type: i1.DriftSqlType.int);
|
||||
|
||||
class Shape4 extends i0.VersionedTable {
|
||||
Shape4({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get channelId =>
|
||||
columnsByName['channel_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get accountId =>
|
||||
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<DateTime> _column_11(String aliasedName) =>
|
||||
i1.GeneratedColumn<DateTime>('cache_expired_at', aliasedName, false,
|
||||
type: i1.DriftSqlType.dateTime);
|
||||
|
||||
class Shape5 extends i0.VersionedTable {
|
||||
Shape5({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get name =>
|
||||
columnsByName['name']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_12(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('name', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape6 extends i0.VersionedTable {
|
||||
Shape6({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get rid =>
|
||||
columnsByName['rid']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get uuid =>
|
||||
columnsByName['uuid']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get accountId =>
|
||||
columnsByName['account_id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
i1.GeneratedColumn<DateTime> get cacheExpiredAt =>
|
||||
columnsByName['cache_expired_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_13(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('rid', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
|
||||
i1.GeneratedColumn<String> _column_14(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('uuid', aliasedName, false,
|
||||
type: i1.DriftSqlType.string,
|
||||
defaultConstraints: i1.GeneratedColumn.constraintIsAlways('UNIQUE'));
|
||||
|
||||
class Shape7 extends i0.VersionedTable {
|
||||
Shape7({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get alias =>
|
||||
columnsByName['alias']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get fullAlias =>
|
||||
columnsByName['full_alias']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<String> _column_15(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>('full_alias', aliasedName, false,
|
||||
type: i1.DriftSqlType.string);
|
||||
|
||||
class Shape8 extends i0.VersionedTable {
|
||||
Shape8({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<int> get id =>
|
||||
columnsByName['id']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get content =>
|
||||
columnsByName['content']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<DateTime> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
|
||||
}
|
||||
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
case 1:
|
||||
final schema = Schema2(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from1To2(migrator, schema);
|
||||
return 2;
|
||||
case 2:
|
||||
final schema = Schema3(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from2To3(migrator, schema);
|
||||
return 3;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
i1.OnUpgrade stepByStep({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
}) =>
|
||||
i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
from2To3: from2To3,
|
||||
));
|
||||
8
lib/database/drift_worker.dart
Normal file
8
lib/database/drift_worker.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:drift/wasm.dart';
|
||||
|
||||
// Use `dart compile js -O4 ./drift_worker.dart` to compile this file.
|
||||
// And place it in the web/ directory.
|
||||
|
||||
// When compiled with dart2js, this file defines a dedicated or shared web
|
||||
// worker used by drift.
|
||||
void main() => WasmDatabase.workerMainForOpen();
|
||||
16
lib/database/keypair.dart
Normal file
16
lib/database/keypair.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:drift/drift.dart';
|
||||
|
||||
class SnLocalKeyPair extends Table {
|
||||
TextColumn get id => text()();
|
||||
|
||||
IntColumn get accountId => integer()();
|
||||
|
||||
TextColumn get publicKey => text()();
|
||||
|
||||
TextColumn get privateKey => text().nullable()();
|
||||
|
||||
BoolColumn get isActive => boolean().withDefault(Constant(false))();
|
||||
|
||||
@override
|
||||
Set<Column<Object>> get primaryKey => {id};
|
||||
}
|
||||
74
lib/database/sticker.dart
Normal file
74
lib/database/sticker.dart
Normal file
@@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
|
||||
class SnStickerConverter extends TypeConverter<SnSticker, String>
|
||||
with JsonTypeConverter2<SnSticker, String, Map<String, Object?>> {
|
||||
const SnStickerConverter();
|
||||
|
||||
@override
|
||||
SnSticker fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnSticker value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnSticker fromJson(Map<String, Object?> json) {
|
||||
return SnSticker.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnSticker value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
class SnLocalSticker extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get alias => text()();
|
||||
|
||||
TextColumn get fullAlias => text()();
|
||||
|
||||
TextColumn get content => text().map(const SnStickerConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
|
||||
class SnStickerPackConverter extends TypeConverter<SnStickerPack, String>
|
||||
with JsonTypeConverter2<SnStickerPack, String, Map<String, Object?>> {
|
||||
const SnStickerPackConverter();
|
||||
|
||||
@override
|
||||
SnStickerPack fromSql(String fromDb) {
|
||||
return fromJson(jsonDecode(fromDb) as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
String toSql(SnStickerPack value) {
|
||||
return jsonEncode(toJson(value));
|
||||
}
|
||||
|
||||
@override
|
||||
SnStickerPack fromJson(Map<String, Object?> json) {
|
||||
return SnStickerPack.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson(SnStickerPack value) {
|
||||
return value.toJson();
|
||||
}
|
||||
}
|
||||
|
||||
class SnLocalStickerPack extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
|
||||
TextColumn get content => text().map(const SnStickerPackConverter())();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
}
|
||||
10
lib/logger.dart
Normal file
10
lib/logger.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:talker/talker.dart';
|
||||
|
||||
final logging = Talker(
|
||||
settings: TalkerSettings(
|
||||
enabled: true,
|
||||
useHistory: true,
|
||||
maxHistoryItems: 1000,
|
||||
useConsoleLogs: true,
|
||||
),
|
||||
);
|
||||
155
lib/main.dart
155
lib/main.dart
@@ -13,7 +13,6 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:hotkey_manager/hotkey_manager.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -21,9 +20,12 @@ import 'package:relative_time/relative_time.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:surface/firebase_options.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/channel.dart';
|
||||
import 'package:surface/providers/chat_call.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/keypair.dart';
|
||||
import 'package:surface/providers/link_preview.dart';
|
||||
import 'package:surface/providers/navigation.dart';
|
||||
import 'package:surface/providers/notification.dart';
|
||||
@@ -31,6 +33,7 @@ import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/relationship.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_realm.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/providers/special_day.dart';
|
||||
import 'package:surface/providers/theme.dart';
|
||||
@@ -39,8 +42,6 @@ import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
import 'package:surface/providers/widget.dart';
|
||||
import 'package:surface/router.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:flutter_web_plugins/url_strategy.dart' show usePathUrlStrategy;
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:tray_manager/tray_manager.dart';
|
||||
@@ -49,6 +50,7 @@ import 'package:workmanager/workmanager.dart';
|
||||
import 'package:in_app_review/in_app_review.dart';
|
||||
import 'package:image_picker_android/image_picker_android.dart';
|
||||
import 'package:image_picker_platform_interface/image_picker_platform_interface.dart';
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void appBackgroundDispatcher() {
|
||||
@@ -81,13 +83,7 @@ void main() async {
|
||||
|
||||
await EasyLocalization.ensureInitialized();
|
||||
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(SnChannelImplAdapter());
|
||||
Hive.registerAdapter(SnRealmImplAdapter());
|
||||
Hive.registerAdapter(SnChannelMemberImplAdapter());
|
||||
Hive.registerAdapter(SnChatMessageImplAdapter());
|
||||
|
||||
if (kIsWeb && !Platform.isLinux) {
|
||||
if (!kIsWeb && !Platform.isLinux) {
|
||||
await Firebase.initializeApp(
|
||||
options: DefaultFirebaseOptions.currentPlatform,
|
||||
);
|
||||
@@ -113,7 +109,8 @@ void main() async {
|
||||
}
|
||||
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
final ImagePickerPlatform imagePickerImplementation = ImagePickerPlatform.instance;
|
||||
final ImagePickerPlatform imagePickerImplementation =
|
||||
ImagePickerPlatform.instance;
|
||||
if (imagePickerImplementation is ImagePickerAndroid) {
|
||||
imagePickerImplementation.useAndroidPhotoPicker = true;
|
||||
}
|
||||
@@ -141,6 +138,9 @@ class SolianApp extends StatelessWidget {
|
||||
assetLoader: JsonAssetLoader(),
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
// Infrastructure layer
|
||||
Provider(create: (ctx) => DatabaseProvider(ctx)),
|
||||
|
||||
// System extensions layer
|
||||
Provider(create: (ctx) => HomeWidgetProvider(ctx)),
|
||||
|
||||
@@ -155,12 +155,14 @@ class SolianApp extends StatelessWidget {
|
||||
Provider(create: (ctx) => SnNetworkProvider(ctx)),
|
||||
Provider(create: (ctx) => UserDirectoryProvider(ctx)),
|
||||
Provider(create: (ctx) => SnAttachmentProvider(ctx)),
|
||||
Provider(create: (ctx) => SnRealmProvider(ctx)),
|
||||
Provider(create: (ctx) => SnPostContentProvider(ctx)),
|
||||
Provider(create: (ctx) => SnRelationshipProvider(ctx)),
|
||||
Provider(create: (ctx) => SnLinkPreviewProvider(ctx)),
|
||||
Provider(create: (ctx) => SnStickerProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => UserProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => WebSocketProvider(ctx)),
|
||||
Provider(create: (ctx) => KeyPairProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => NotificationProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatChannelProvider(ctx)),
|
||||
ChangeNotifierProvider(create: (ctx) => ChatCallProvider(ctx)),
|
||||
@@ -228,14 +230,15 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
if (prefs.containsKey('first_boot_time')) {
|
||||
final rawTime = prefs.getString('first_boot_time');
|
||||
final time = DateTime.tryParse(rawTime ?? '');
|
||||
if (time != null && time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
|
||||
if (time != null &&
|
||||
time.isBefore(DateTime.now().subtract(const Duration(days: 3)))) {
|
||||
final inAppReview = InAppReview.instance;
|
||||
if (prefs.getBool('rating_requested') == true) return;
|
||||
if (await inAppReview.isAvailable()) {
|
||||
await inAppReview.requestReview();
|
||||
prefs.setBool('rating_requested', true);
|
||||
} else {
|
||||
log('Unable request app review, unavailable');
|
||||
logging.error('Unable request app review, unavailable');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -254,20 +257,27 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
),
|
||||
).get(
|
||||
'https://git.solsynth.dev/api/v1/repos/HyperNet/Surface/tags?page=1&limit=1',
|
||||
'https://api.github.com/repos/Solsynth/HyperNet.Surface/releases/latest',
|
||||
);
|
||||
final remoteVersionString = (resp.data as List).firstOrNull?['name'] ?? '0.0.0+0';
|
||||
final remoteVersionString = resp.data?['tag_name'] ?? '0.0.0+0';
|
||||
final remoteVersion = Version.parse(remoteVersionString.split('+').first);
|
||||
final localVersion = Version.parse(localVersionString.split('+').first);
|
||||
final remoteBuildNumber = int.tryParse(remoteVersionString.split('+').last) ?? 0;
|
||||
final localBuildNumber = int.tryParse(localVersionString.split('+').last) ?? 0;
|
||||
log("[Update] Local: $localVersionString, Remote: $remoteVersionString");
|
||||
if ((remoteVersion > localVersion || remoteBuildNumber > localBuildNumber) && mounted) {
|
||||
final remoteBuildNumber =
|
||||
int.tryParse(remoteVersionString.split('+').last) ?? 0;
|
||||
final localBuildNumber =
|
||||
int.tryParse(localVersionString.split('+').last) ?? 0;
|
||||
logging.info(
|
||||
"[Update] Local: $localVersionString, Remote: $remoteVersionString");
|
||||
if ((remoteVersion > localVersion ||
|
||||
remoteBuildNumber > localBuildNumber) &&
|
||||
mounted) {
|
||||
final config = context.read<ConfigProvider>();
|
||||
config.setUpdate(remoteVersionString);
|
||||
log("[Update] Update available: $remoteVersionString");
|
||||
config.setUpdate(
|
||||
remoteVersionString, resp.data?['body'] ?? 'No changelog');
|
||||
logging.info("[Update] Update available: $remoteVersionString");
|
||||
}
|
||||
} catch (e) {
|
||||
logging.error('[Error] Unable to check update...', e);
|
||||
if (mounted) context.showErrorDialog('Unable to check update: $e');
|
||||
}
|
||||
}
|
||||
@@ -298,8 +308,17 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
notify.listen();
|
||||
await notify.registerPushNotifications();
|
||||
if (!mounted) return;
|
||||
final kp = context.read<KeyPairProvider>();
|
||||
await kp.reloadActive();
|
||||
kp.listen();
|
||||
if (!mounted) return;
|
||||
final sticker = context.read<SnStickerProvider>();
|
||||
await sticker.listStickerEagerly();
|
||||
await sticker.listSticker();
|
||||
if (!mounted) return;
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final userCacheSize = await ud.loadAccountCache();
|
||||
logging.info('[Users] Loaded local user cache, size: $userCacheSize');
|
||||
logging.info('[Bootstrap] Everything initialized!');
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
await context.showErrorDialog(err);
|
||||
@@ -326,30 +345,58 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
}
|
||||
}
|
||||
|
||||
final Menu _appTrayMenu = Menu(
|
||||
items: [
|
||||
MenuItem(
|
||||
key: 'version_label',
|
||||
label: 'Solian',
|
||||
disabled: true,
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem.checkbox(
|
||||
checked: false,
|
||||
key: 'mute_notification',
|
||||
label: 'trayMenuMuteNotification'.tr(),
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem(
|
||||
key: 'window_show',
|
||||
label: 'trayMenuShow'.tr(),
|
||||
),
|
||||
MenuItem(
|
||||
key: 'exit',
|
||||
label: 'trayMenuExit'.tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Future<void> _trayInitialization() async {
|
||||
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
|
||||
|
||||
final icon = Platform.isWindows ? 'assets/icon/tray-icon.ico' : 'assets/icon/tray-icon.png';
|
||||
final icon = Platform.isWindows
|
||||
? 'assets/icon/tray-icon.ico'
|
||||
: 'assets/icon/tray-icon.png';
|
||||
final appVersion = await PackageInfo.fromPlatform();
|
||||
|
||||
trayManager.addListener(this);
|
||||
await trayManager.setIcon(icon);
|
||||
|
||||
Menu menu = Menu(
|
||||
items: [
|
||||
MenuItem(
|
||||
key: 'version_label',
|
||||
label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
|
||||
disabled: true,
|
||||
),
|
||||
MenuItem.separator(),
|
||||
MenuItem(
|
||||
key: 'exit',
|
||||
label: 'trayMenuExit'.tr(),
|
||||
),
|
||||
],
|
||||
_appTrayMenu.items![0] = MenuItem(
|
||||
key: 'version_label',
|
||||
label: 'Solian ${appVersion.version}+${appVersion.buildNumber}',
|
||||
disabled: true,
|
||||
);
|
||||
|
||||
await trayManager.setContextMenu(_appTrayMenu);
|
||||
}
|
||||
|
||||
Future<void> _notifyInitialization() async {
|
||||
if (kIsWeb || Platform.isAndroid || Platform.isIOS) return;
|
||||
|
||||
await localNotifier.setup(
|
||||
appName: 'Solian',
|
||||
shortcutPolicy: ShortcutPolicy.requireCreate,
|
||||
);
|
||||
await trayManager.setContextMenu(menu);
|
||||
}
|
||||
|
||||
AppLifecycleListener? _appLifecycleListener;
|
||||
@@ -366,6 +413,7 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
|
||||
_trayInitialization();
|
||||
_hotkeyInitialization();
|
||||
_notifyInitialization();
|
||||
_initialize().then((_) {
|
||||
_postInitialization();
|
||||
_tryRequestRating();
|
||||
@@ -401,9 +449,23 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
@override
|
||||
void onTrayMenuItemClick(MenuItem menuItem) {
|
||||
switch (menuItem.key) {
|
||||
case 'mute_notification':
|
||||
final nty = context.read<NotificationProvider>();
|
||||
nty.isMuted = !nty.isMuted;
|
||||
_appTrayMenu.items![2].checked = nty.isMuted;
|
||||
trayManager.setContextMenu(_appTrayMenu);
|
||||
break;
|
||||
case 'window_show':
|
||||
// To prevent the window from being hide after just show on macOS
|
||||
Timer(const Duration(milliseconds: 100), () => appWindow.show());
|
||||
break;
|
||||
case 'exit':
|
||||
_appLifecycleListener?.dispose();
|
||||
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
|
||||
if (Platform.isWindows) {
|
||||
appWindow.close();
|
||||
} else {
|
||||
SystemChannels.platform.invokeMethod('SystemNavigator.pop');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -427,8 +489,21 @@ class _AppSplashScreenState extends State<_AppSplashScreen> with TrayListener {
|
||||
});
|
||||
return false;
|
||||
},
|
||||
child: SizeChangedLayoutNotifier(
|
||||
child: widget.child,
|
||||
child: OrientationBuilder(
|
||||
builder: (context, orientation) {
|
||||
final cfg = context.read<ConfigProvider>();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
cfg.calcDrawerSize(context);
|
||||
});
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (context.mounted) {
|
||||
cfg.calcDrawerSize(context);
|
||||
}
|
||||
});
|
||||
return SizeChangedLayoutNotifier(
|
||||
child: widget.child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,57 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/controllers/chat_message_controller.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_realm.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
|
||||
class ChatChannelProvider extends ChangeNotifier {
|
||||
static const kChatChannelBoxName = 'nex_chat_channels';
|
||||
|
||||
late final SnNetworkProvider _sn;
|
||||
late final UserDirectoryProvider _ud;
|
||||
|
||||
Box<SnChannel>? get _channelBox => Hive.box<SnChannel>(kChatChannelBoxName);
|
||||
late final UserProvider _ua;
|
||||
late final DatabaseProvider _dt;
|
||||
late final SnRealmProvider _rels;
|
||||
|
||||
ChatChannelProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_ud = context.read<UserDirectoryProvider>();
|
||||
_initializeLocalData();
|
||||
}
|
||||
|
||||
Future<void> _initializeLocalData() async {
|
||||
await Hive.openBox<SnChannel>(kChatChannelBoxName);
|
||||
_ua = context.read<UserProvider>();
|
||||
_dt = context.read<DatabaseProvider>();
|
||||
_rels = context.read<SnRealmProvider>();
|
||||
}
|
||||
|
||||
Future<void> _saveChannelToLocal(Iterable<SnChannel> channels) async {
|
||||
if (_channelBox == null) return;
|
||||
await _channelBox!.putAll({
|
||||
for (final channel in channels) channel.key: channel,
|
||||
});
|
||||
await Future.wait(
|
||||
channels.map(
|
||||
(ele) => _dt.db.snLocalChatChannel.insertOne(
|
||||
SnLocalChatChannelCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
alias: ele.key,
|
||||
content: ele,
|
||||
createdAt: Value(ele.createdAt),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SnLocalChatChannelCompanion.custom(
|
||||
content: Constant(jsonEncode(ele.toJson())),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<SnChannel>> _fetchChannelsFromServer({
|
||||
String scope = 'global',
|
||||
bool direct = false,
|
||||
bool doNotSave = false,
|
||||
}) async {
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/im/channels/$scope/me/available',
|
||||
queryParameters: {
|
||||
'direct': direct,
|
||||
},
|
||||
);
|
||||
final resp = await _sn.client.get('/cgi/im/channels/me/available');
|
||||
final out = List<SnChannel>.from(
|
||||
resp.data?.map((e) => SnChannel.fromJson(e)) ?? [],
|
||||
);
|
||||
@@ -54,18 +63,25 @@ class ChatChannelProvider extends ChangeNotifier {
|
||||
/// It will use the local storage as much as possible.
|
||||
/// The alias should include the scope, formatted as `scope:alias`.
|
||||
Future<SnChannel> getChannel(String key) async {
|
||||
if (_channelBox != null) {
|
||||
final local = _channelBox!.get(key);
|
||||
if (local != null) return local;
|
||||
final local = await (_dt.db.snLocalChatChannel.select()
|
||||
..where((e) => e.alias.equals(key)))
|
||||
.getSingleOrNull();
|
||||
if (local != null) {
|
||||
final out = local.content;
|
||||
if (out.realmId != null) {
|
||||
return out.copyWith(realm: await _rels.getRealm(out.realmId!));
|
||||
} else {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
var resp = await _sn.client.get('/cgi/im/channels/$key');
|
||||
var resp =
|
||||
await _sn.client.get('/cgi/im/channels/${key.replaceAll(':', '/')}');
|
||||
var out = SnChannel.fromJson(resp.data);
|
||||
|
||||
// Preload realm of the channel
|
||||
if (out.realmId != null) {
|
||||
resp = await _sn.client.get('/cgi/id/realms/${out.realmId}');
|
||||
out = out.copyWith(realm: SnRealm.fromJson(resp.data));
|
||||
out = out.copyWith(realm: await _rels.getRealm(out.realmId!));
|
||||
}
|
||||
|
||||
_saveChannelToLocal([out]);
|
||||
@@ -77,66 +93,119 @@ class ChatChannelProvider extends ChangeNotifier {
|
||||
/// And the second time is when the data was fetched from the server.
|
||||
/// But there is some exception that will only cause one of them to be emitted.
|
||||
/// Like the local storage is broken or the server is down.
|
||||
Stream<List<SnChannel>> fetchChannels() async* {
|
||||
if (_channelBox != null) yield _channelBox!.values.toList();
|
||||
|
||||
var resp = await _sn.client.get('/cgi/id/realms/me/available');
|
||||
final realms = List<SnRealm>.from(
|
||||
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
|
||||
);
|
||||
final realmMap = {
|
||||
for (final realm in realms) realm.alias: realm,
|
||||
};
|
||||
|
||||
final scopeToFetch = {'global', ...realms.map((e) => e.alias)};
|
||||
|
||||
final List<SnChannel> result = List.empty(growable: true);
|
||||
final directMessages = await _fetchChannelsFromServer(
|
||||
scope: scopeToFetch.first,
|
||||
direct: true,
|
||||
);
|
||||
result.addAll(directMessages);
|
||||
|
||||
final nonBelongsChannels = await _fetchChannelsFromServer(
|
||||
scope: scopeToFetch.first,
|
||||
direct: false,
|
||||
);
|
||||
result.addAll(nonBelongsChannels);
|
||||
|
||||
for (final scope in scopeToFetch.skip(1)) {
|
||||
final channel = await _fetchChannelsFromServer(
|
||||
scope: scope,
|
||||
direct: false,
|
||||
doNotSave: true,
|
||||
);
|
||||
final out = channel.map((ele) => ele.copyWith(realm: realmMap[scope]));
|
||||
_saveChannelToLocal(out);
|
||||
result.addAll(out);
|
||||
Stream<List<SnChannel>> fetchChannels(
|
||||
{bool noRemote = false, bool noLocal = false}) async* {
|
||||
if (!noLocal) {
|
||||
final local = await (_dt.db.snLocalChatChannel.select()
|
||||
..orderBy([
|
||||
(e) =>
|
||||
OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
|
||||
]))
|
||||
.get();
|
||||
final out = local.map((e) => e.content).toList();
|
||||
for (var idx = 0; idx < out.length; idx++) {
|
||||
final channel = out[idx];
|
||||
if (channel.realmId != null) {
|
||||
out[idx] = out[idx].copyWith(
|
||||
realm: await _rels.getRealm(channel.realmId!),
|
||||
);
|
||||
}
|
||||
}
|
||||
yield out;
|
||||
}
|
||||
|
||||
if (noRemote) return;
|
||||
final List<SnChannel> result = List.empty(growable: true);
|
||||
final channels = await _fetchChannelsFromServer();
|
||||
for (var idx = 0; idx < channels.length; idx++) {
|
||||
final channel = channels[idx];
|
||||
if (channel.realmId != null) {
|
||||
channels[idx] = channels[idx].copyWith(
|
||||
realm: await _rels.getRealm(channel.realmId!),
|
||||
);
|
||||
}
|
||||
}
|
||||
result.addAll(channels);
|
||||
|
||||
yield result;
|
||||
}
|
||||
|
||||
Future<List<SnChatMessage>> getLastMessages(
|
||||
Iterable<SnChannel> channels,
|
||||
) async {
|
||||
final result = List<SnChatMessage>.empty(growable: true);
|
||||
final result = List<Future<SnLocalChatMessageData?>>.empty(growable: true);
|
||||
for (final channel in channels) {
|
||||
final channelBox = await Hive.openBox<SnChatMessage>(
|
||||
'${ChatMessageController.kChatMessageBoxPrefix}${channel.id}',
|
||||
);
|
||||
final lastMessage =
|
||||
channelBox.isNotEmpty ? channelBox.values.reduce((a, b) => a.createdAt.isAfter(b.createdAt) ? a : b) : null;
|
||||
if (lastMessage != null) result.add(lastMessage);
|
||||
channelBox.close();
|
||||
final out = (_dt.db.snLocalChatMessage.select()
|
||||
..where((e) => e.channelId.equals(channel.id))
|
||||
..orderBy([
|
||||
(e) =>
|
||||
OrderingTerm(expression: e.createdAt, mode: OrderingMode.desc)
|
||||
])
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
result.add(out);
|
||||
}
|
||||
await _ud.listAccount(result.map((ele) => ele.sender.accountId).toSet());
|
||||
return result;
|
||||
final out = (await Future.wait(result))
|
||||
.where((e) => e != null)
|
||||
.map((e) => e!.content)
|
||||
.toList();
|
||||
await _ud.listAccount(out.map((ele) => ele.sender.accountId).toSet());
|
||||
return out;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_channelBox?.close();
|
||||
super.dispose();
|
||||
Future<void> _saveMemberToLocal(Iterable<SnChannelMember> members) async {
|
||||
final queries = members.map((ele) {
|
||||
return _dt.db.snLocalChannelMember.insertOne(
|
||||
SnLocalChannelMemberCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
channelId: ele.channelId,
|
||||
accountId: ele.accountId,
|
||||
content: ele,
|
||||
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SnLocalChannelMemberCompanion.custom(
|
||||
content: Constant(jsonEncode(ele.toJson())),
|
||||
cacheExpiredAt:
|
||||
Constant(DateTime.now().add(const Duration(days: 7))),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
await Future.wait(queries);
|
||||
}
|
||||
|
||||
Future<void> removeLocalChannel(SnChannel channel) async {
|
||||
await _dt.db.transaction(() async {
|
||||
await (_dt.db.snLocalChannelMember.delete()
|
||||
..where((e) => e.channelId.equals(channel.id)))
|
||||
.go();
|
||||
await (_dt.db.snLocalChatChannel.delete()
|
||||
..where((e) => e.id.equals(channel.id)))
|
||||
.go();
|
||||
await (_dt.db.snLocalChatMessage.delete()
|
||||
..where((e) => e.channelId.equals(channel.id)))
|
||||
.go();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> updateChannelProfile(SnChannelMember member) {
|
||||
return _saveMemberToLocal([member]);
|
||||
}
|
||||
|
||||
Future<SnChannelMember> getChannelProfile(SnChannel channel) async {
|
||||
if (_ua.user == null) throw Exception('User not logged in');
|
||||
final local = await (_dt.db.snLocalChannelMember.select()
|
||||
..where((e) => e.channelId.equals(channel.id))
|
||||
..where((e) => e.accountId.equals(_ua.user!.id)))
|
||||
.getSingleOrNull();
|
||||
if (local != null) {
|
||||
return local.content;
|
||||
}
|
||||
|
||||
final resp = await _sn.client.get('/cgi/im/channels/${channel.keyPath}/me');
|
||||
final out = SnChannelMember.fromJson(resp.data);
|
||||
_saveMemberToLocal([out]);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ const kAppDrawerPreferCollapse = 'app_drawer_prefer_collapse';
|
||||
const kAppNotifyWithHaptic = 'app_notify_with_haptic';
|
||||
const kAppExpandPostLink = 'app_expand_post_link';
|
||||
const kAppExpandChatLink = 'app_expand_chat_link';
|
||||
const kAppRealmCompactView = 'app_realm_compact_view';
|
||||
const kAppCustomFonts = 'app_custom_fonts';
|
||||
|
||||
const Map<String, FilterQuality> kImageQualityLevel = {
|
||||
'settingsImageQualityLowest': FilterQuality.none,
|
||||
@@ -57,7 +59,8 @@ class ConfigProvider extends ChangeNotifier {
|
||||
: false;
|
||||
}
|
||||
|
||||
if (newDrawerIsExpanded != drawerIsExpanded || newDrawerIsCollapsed != drawerIsCollapsed) {
|
||||
if (newDrawerIsExpanded != drawerIsExpanded ||
|
||||
newDrawerIsCollapsed != drawerIsCollapsed) {
|
||||
drawerIsExpanded = newDrawerIsExpanded;
|
||||
drawerIsCollapsed = newDrawerIsCollapsed;
|
||||
notifyListeners();
|
||||
@@ -65,22 +68,34 @@ class ConfigProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
FilterQuality get imageQuality {
|
||||
return kImageQualityLevel.values.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ?? FilterQuality.high;
|
||||
return kImageQualityLevel.values
|
||||
.elementAtOrNull(prefs.getInt('app_image_quality') ?? 3) ??
|
||||
FilterQuality.high;
|
||||
}
|
||||
|
||||
String get serverUrl {
|
||||
return prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
||||
}
|
||||
|
||||
bool get realmCompactView {
|
||||
return prefs.getBool(kAppRealmCompactView) ?? false;
|
||||
}
|
||||
|
||||
set realmCompactView(bool value) {
|
||||
prefs.setBool(kAppRealmCompactView, value);
|
||||
}
|
||||
|
||||
set serverUrl(String url) {
|
||||
prefs.setString(kNetworkServerStoreKey, url);
|
||||
_home.saveWidgetData("nex_server_url", url);
|
||||
}
|
||||
|
||||
String? updatableVersion;
|
||||
String? updatableChangelog;
|
||||
|
||||
void setUpdate(String newVersion) {
|
||||
void setUpdate(String newVersion, String newChangelog) {
|
||||
updatableVersion = newVersion;
|
||||
updatableChangelog = newChangelog;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
31
lib/providers/database.dart
Normal file
31
lib/providers/database.dart
Normal file
@@ -0,0 +1,31 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart' show join;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
|
||||
class DatabaseProvider {
|
||||
late AppDatabase db;
|
||||
|
||||
DatabaseProvider(BuildContext context) {
|
||||
db = AppDatabase();
|
||||
}
|
||||
|
||||
Future<int> getDatabaseSize() async {
|
||||
if (kIsWeb) return 0;
|
||||
final basepath = await getApplicationSupportDirectory();
|
||||
return await File(join(basepath.path, 'solar_network_data.sqlite'))
|
||||
.length();
|
||||
}
|
||||
|
||||
Future<void> removeDatabase() async {
|
||||
if (kIsWeb) return;
|
||||
final basepath = await getApplicationSupportDirectory();
|
||||
final file = File(join(basepath.path, 'solar_network_data.sqlite'));
|
||||
db.close();
|
||||
await file.delete();
|
||||
db = AppDatabase();
|
||||
}
|
||||
}
|
||||
245
lib/providers/keypair.dart
Normal file
245
lib/providers/keypair.dart
Normal file
@@ -0,0 +1,245 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
import 'package:surface/types/keypair.dart';
|
||||
import 'package:fast_rsa/fast_rsa.dart';
|
||||
import 'package:surface/types/websocket.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
// Currently the keypair only provide RSA encryption
|
||||
// Supported by the `fast_rsa` package
|
||||
class KeyPairProvider {
|
||||
late final DatabaseProvider _dt;
|
||||
late final UserProvider _ua;
|
||||
late final WebSocketProvider _ws;
|
||||
|
||||
SnKeyPair? activeKp;
|
||||
|
||||
KeyPairProvider(BuildContext context) {
|
||||
_dt = context.read<DatabaseProvider>();
|
||||
_ua = context.read<UserProvider>();
|
||||
_ws = context.read<WebSocketProvider>();
|
||||
}
|
||||
|
||||
void listen() {
|
||||
_ws.pk.stream.listen((event) {
|
||||
switch (event.method) {
|
||||
case 'kex.ack':
|
||||
ackKeyExchange(event);
|
||||
break;
|
||||
case 'kex.ask':
|
||||
replyAskKeyExchange(event);
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> decryptText(String text, String kpId, {int? kpOwner}) async {
|
||||
String? publicKey;
|
||||
final kp = await (_dt.db.snLocalKeyPair.select()
|
||||
..where((e) => e.id.equals(kpId)))
|
||||
.getSingleOrNull();
|
||||
if (kp == null) {
|
||||
if (kpOwner != null) {
|
||||
final out = await askKeyExchange(kpOwner, kpId);
|
||||
publicKey = out.publicKey;
|
||||
}
|
||||
} else {
|
||||
publicKey = kp.publicKey;
|
||||
}
|
||||
if (publicKey == null) {
|
||||
throw Exception('Key pair not found');
|
||||
}
|
||||
return await RSA.decryptPKCS1v15(text, publicKey);
|
||||
}
|
||||
|
||||
Future<String> encryptText(String text) async {
|
||||
if (activeKp == null) throw Exception('No active key pair');
|
||||
return await RSA.encryptPKCS1v15(text, activeKp!.privateKey!);
|
||||
}
|
||||
|
||||
final Map<String, Completer<SnKeyPair>> _requests = {};
|
||||
|
||||
Future<SnKeyPair> askKeyExchange(int kpOwner, String kpId) async {
|
||||
if (_requests.containsKey(kpId)) return await _requests[kpId]!.future;
|
||||
|
||||
final completer = Completer<SnKeyPair>();
|
||||
_requests[kpId] = completer;
|
||||
|
||||
_ws.conn?.sink.add(
|
||||
jsonEncode(WebSocketPackage(
|
||||
method: 'kex.ask',
|
||||
endpoint: 'id',
|
||||
payload: {
|
||||
'keypair_id': kpId,
|
||||
'user_id': kpOwner,
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
||||
return Future.any([
|
||||
_requests[kpId]!.future,
|
||||
Future.delayed(const Duration(seconds: 60), () {
|
||||
_requests.remove(kpId);
|
||||
throw TimeoutException("Key exchange timed out");
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> ackKeyExchange(WebSocketPackage pkt) async {
|
||||
if (pkt.payload == null) return;
|
||||
final kpMeta = SnKeyPair(
|
||||
id: pkt.payload!['keypair_id'] as String,
|
||||
accountId: pkt.payload!['user_id'] as int,
|
||||
publicKey: pkt.payload!['public_key'] as String,
|
||||
privateKey: pkt.payload?['private_key'] as String?,
|
||||
);
|
||||
|
||||
if (_requests.containsKey(kpMeta.id)) {
|
||||
_requests[kpMeta.id]!.complete(kpMeta);
|
||||
_requests.remove(kpMeta.id);
|
||||
}
|
||||
|
||||
// Save the keypair to the local database
|
||||
await _dt.db.snLocalKeyPair.insertOne(
|
||||
SnLocalKeyPairCompanion.insert(
|
||||
id: kpMeta.id,
|
||||
accountId: kpMeta.accountId,
|
||||
publicKey: kpMeta.publicKey,
|
||||
privateKey: Value(kpMeta.privateKey),
|
||||
),
|
||||
onConflict: DoNothing(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> replyAskKeyExchange(WebSocketPackage pkt) async {
|
||||
final kpId = pkt.payload!['keypair_id'] as String;
|
||||
final userId = pkt.payload!['user_id'] as int;
|
||||
final clientId = pkt.payload!['client_id'] as String;
|
||||
|
||||
final localKp = await (_dt.db.snLocalKeyPair.select()
|
||||
..where((e) => e.id.equals(kpId))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (localKp == null) return;
|
||||
|
||||
logging.info(
|
||||
'[Kex] Reply to key exchange request of $kpId from user $userId',
|
||||
);
|
||||
|
||||
// We do not give the private key to the client
|
||||
_ws.conn?.sink.add(jsonEncode(
|
||||
WebSocketPackage(
|
||||
method: 'kex.ack',
|
||||
endpoint: 'id',
|
||||
payload: {
|
||||
'keypair_id': localKp.id,
|
||||
'user_id': localKp.accountId,
|
||||
'public_key': localKp.publicKey,
|
||||
'client_id': clientId,
|
||||
},
|
||||
).toJson(),
|
||||
));
|
||||
}
|
||||
|
||||
Future<SnKeyPair?> reloadActive({bool autoEnroll = true}) async {
|
||||
final kp = await (_dt.db.snLocalKeyPair.select()
|
||||
..where((e) => e.accountId.equals(_ua.user!.id))
|
||||
..where((e) => e.privateKey.isNotNull())
|
||||
..where((e) => e.isActive.equals(true))
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
|
||||
if (kp != null) {
|
||||
activeKp = SnKeyPair(
|
||||
id: kp.id,
|
||||
accountId: kp.accountId,
|
||||
publicKey: kp.publicKey,
|
||||
privateKey: kp.privateKey,
|
||||
);
|
||||
}
|
||||
|
||||
if (kp == null && autoEnroll) {
|
||||
return await enrollNew();
|
||||
}
|
||||
|
||||
return activeKp;
|
||||
}
|
||||
|
||||
Future<List<SnKeyPair>> listKeyPair() async {
|
||||
final kps = await (_dt.db.snLocalKeyPair.select()).get();
|
||||
return kps
|
||||
.map((e) => SnKeyPair(
|
||||
id: e.id,
|
||||
accountId: e.accountId,
|
||||
publicKey: e.publicKey,
|
||||
privateKey: e.privateKey,
|
||||
isActive: e.isActive,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> activeKeyPair(String kpId) async {
|
||||
final kp = await (_dt.db.snLocalKeyPair.select()
|
||||
..where((e) => e.id.equals(kpId))
|
||||
..where((e) => e.privateKey.isNotNull())
|
||||
..limit(1))
|
||||
.getSingleOrNull();
|
||||
if (kp == null) return;
|
||||
|
||||
await _dt.db.transaction(() async {
|
||||
await (_dt.db.update(_dt.db.snLocalKeyPair)
|
||||
..where((e) => e.isActive.equals(true)))
|
||||
.write(SnLocalKeyPairCompanion(isActive: Value(false)));
|
||||
|
||||
await (_dt.db.update(_dt.db.snLocalKeyPair)
|
||||
..where((e) => e.id.equals(kp.id)))
|
||||
.write(SnLocalKeyPairCompanion(isActive: Value(true)));
|
||||
});
|
||||
}
|
||||
|
||||
Future<SnKeyPair> enrollNew() async {
|
||||
if (!_ua.isAuthorized) throw Exception('Unauthorized');
|
||||
|
||||
final id = const Uuid().v4();
|
||||
final kp = await RSA.generate(2048);
|
||||
final kpMeta = SnKeyPair(
|
||||
id: id,
|
||||
accountId: _ua.user!.id,
|
||||
// This is work as expected
|
||||
// We need to share private key to let everyone can decode the message
|
||||
publicKey: kp.privateKey,
|
||||
privateKey: kp.publicKey,
|
||||
);
|
||||
|
||||
// Save the keypair to the local database
|
||||
// If there is already one with private key, it will be overwritten
|
||||
await _dt.db.transaction(() async {
|
||||
await (_dt.db.update(_dt.db.snLocalKeyPair)
|
||||
..where((e) => e.isActive.equals(true)))
|
||||
.write(SnLocalKeyPairCompanion(isActive: Value(false)));
|
||||
|
||||
await _dt.db.snLocalKeyPair.insertOne(
|
||||
SnLocalKeyPairCompanion.insert(
|
||||
id: kpMeta.id,
|
||||
accountId: kpMeta.accountId,
|
||||
publicKey: kpMeta.publicKey,
|
||||
privateKey: Value(kpMeta.privateKey),
|
||||
isActive: Value(true),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await reloadActive(autoEnroll: false);
|
||||
|
||||
return kpMeta;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/link.dart';
|
||||
|
||||
@@ -20,7 +20,7 @@ class SnLinkPreviewProvider {
|
||||
final target = b64.encode(url);
|
||||
if (_cache.containsKey(target)) return _cache[target];
|
||||
|
||||
log('[LinkPreview] Fetching $url ($target)');
|
||||
logging.debug('[LinkPreview] Fetching $url ($target)');
|
||||
|
||||
try {
|
||||
final resp = await _sn.client.get('/cgi/re/link/$target');
|
||||
@@ -28,7 +28,7 @@ class SnLinkPreviewProvider {
|
||||
_cache[url] = meta;
|
||||
return meta;
|
||||
} catch (err) {
|
||||
log('[LinkPreview] Failed to fetch $url ($target)...');
|
||||
logging.warning('[LinkPreview] Failed to fetch $url ($target)...', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ class NavigationProvider extends ChangeNotifier {
|
||||
screen: 'news',
|
||||
label: 'screenNews',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.emoji_emotions, weight: 400, opticalSize: 20),
|
||||
screen: 'stickers',
|
||||
label: 'screenStickers',
|
||||
),
|
||||
AppNavDestination(
|
||||
icon: Icon(Symbols.photo_library, weight: 400, opticalSize: 20),
|
||||
screen: 'album',
|
||||
@@ -88,7 +93,8 @@ class NavigationProvider extends ChangeNotifier {
|
||||
|
||||
List<AppNavDestination> destinations = [];
|
||||
|
||||
int get pinnedDestinationCount => destinations.where((ele) => ele.isPinned).length;
|
||||
int get pinnedDestinationCount =>
|
||||
destinations.where((ele) => ele.isPinned).length;
|
||||
|
||||
NavigationProvider() {
|
||||
buildDestinations(kDefaultPinnedDestination);
|
||||
@@ -117,13 +123,17 @@ class NavigationProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
bool isIndexInRange(int min, int max) {
|
||||
return _currentIndex != null && _currentIndex! >= min && _currentIndex! < max;
|
||||
return _currentIndex != null &&
|
||||
_currentIndex! >= min &&
|
||||
_currentIndex! < max;
|
||||
}
|
||||
|
||||
void autoDetectIndex(GoRouter? state) {
|
||||
if (state == null) return;
|
||||
final idx = destinations.indexWhere(
|
||||
(ele) => ele.screen == state.routerDelegate.currentConfiguration.last.route.name,
|
||||
(ele) =>
|
||||
ele.screen ==
|
||||
state.routerDelegate.currentConfiguration.last.route.name,
|
||||
);
|
||||
_currentIndex = idx == -1 ? null : idx;
|
||||
notifyListeners();
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:bitsdojo_window/bitsdojo_window.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:local_notifier/local_notifier.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
@@ -46,11 +48,13 @@ class NotificationProvider extends ChangeNotifier {
|
||||
var deviceUuid = await FlutterUdid.consistentUdid;
|
||||
|
||||
if (deviceUuid.isEmpty) {
|
||||
log("Unable to active push notifications, couldn't get device uuid");
|
||||
logging.warning(
|
||||
'[Push Notification] Unable to active push notifications, couldn\'t get device uuid');
|
||||
return;
|
||||
} else {
|
||||
log('Device UUID is $deviceUuid');
|
||||
log('Registering device push notifications...');
|
||||
logging.info('[Push Notification] Device UUID is $deviceUuid');
|
||||
logging
|
||||
.info('[Push Notification] Registering device push notifications...');
|
||||
}
|
||||
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
@@ -60,7 +64,7 @@ class NotificationProvider extends ChangeNotifier {
|
||||
provider = 'fcm';
|
||||
token = await FirebaseMessaging.instance.getToken();
|
||||
}
|
||||
log('Device Push Token is $token');
|
||||
logging.info('[Push Notification] Device Push Token is $token');
|
||||
|
||||
await _sn.client.post(
|
||||
'/cgi/id/notifications/subscription',
|
||||
@@ -76,22 +80,49 @@ class NotificationProvider extends ChangeNotifier {
|
||||
int showingTrayCount = 0;
|
||||
List<SnNotification> notifications = List.empty(growable: true);
|
||||
|
||||
int? skippableNotifyChannel;
|
||||
bool isMuted = false;
|
||||
|
||||
void listen() {
|
||||
_ws.pk.stream.listen((event) {
|
||||
if (event.method == 'notifications.new') {
|
||||
final notification = SnNotification.fromJson(event.payload!);
|
||||
|
||||
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
|
||||
if (doHaptic) HapticFeedback.mediumImpact();
|
||||
|
||||
if (notification.topic == 'messaging.message' &&
|
||||
skippableNotifyChannel != null) {
|
||||
if (notification.metadata['channel_id'] != null &&
|
||||
notification.metadata['channel_id'] == skippableNotifyChannel) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (showingCount < 0) showingCount = 0;
|
||||
showingCount++;
|
||||
showingTrayCount++;
|
||||
notifications.add(notification);
|
||||
Future.delayed(const Duration(seconds: 3), () {
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
if (showingCount >= 0) showingCount--;
|
||||
notifyListeners();
|
||||
});
|
||||
notifyListeners();
|
||||
updateTray();
|
||||
final doHaptic = _cfg.prefs.getBool(kAppNotifyWithHaptic) ?? true;
|
||||
if (doHaptic) HapticFeedback.mediumImpact();
|
||||
|
||||
if (!kIsWeb && !isMuted) {
|
||||
if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) {
|
||||
LocalNotification notify = LocalNotification(
|
||||
title: notification.title,
|
||||
subtitle: notification.subtitle,
|
||||
body: notification.body,
|
||||
);
|
||||
notify.onClick = () {
|
||||
appWindow.show();
|
||||
};
|
||||
notify.show();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,19 +2,23 @@ import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_attachment.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_realm.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/types/poll.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
|
||||
class SnPostContentProvider {
|
||||
late final SnNetworkProvider _sn;
|
||||
late final UserDirectoryProvider _ud;
|
||||
late final SnAttachmentProvider _attach;
|
||||
late final SnRealmProvider _realm;
|
||||
|
||||
SnPostContentProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_ud = context.read<UserDirectoryProvider>();
|
||||
_attach = context.read<SnAttachmentProvider>();
|
||||
_realm = context.read<SnRealmProvider>();
|
||||
}
|
||||
|
||||
Future<SnPoll> _fetchPoll(int id) async {
|
||||
@@ -24,6 +28,7 @@ class SnPostContentProvider {
|
||||
|
||||
Future<List<SnPost>> _preloadRelatedDataInBatch(List<SnPost> out) async {
|
||||
Set<String> rids = {};
|
||||
Set<int> uids = {};
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
rids.addAll(out[i].body['attachments']?.cast<String>() ?? []);
|
||||
if (out[i].body['thumbnail'] != null) {
|
||||
@@ -37,14 +42,21 @@ class SnPostContentProvider {
|
||||
repostTo: await _preloadRelatedDataSingle(out[i].repostTo!),
|
||||
);
|
||||
}
|
||||
if (out[i].publisher.type == 0) {
|
||||
uids.add(out[i].publisher.accountId);
|
||||
}
|
||||
}
|
||||
|
||||
final attachments = await _attach.getMultiple(rids.toList());
|
||||
for (var i = 0; i < out.length; i++) {
|
||||
SnPoll? poll;
|
||||
SnRealm? realm;
|
||||
if (out[i].pollId != null) {
|
||||
poll = await _fetchPoll(out[i].pollId!);
|
||||
}
|
||||
if (out[i].realmId != null) {
|
||||
realm = await _realm.getRealm(out[i].realmId!);
|
||||
}
|
||||
|
||||
out[i] = out[i].copyWith(
|
||||
preload: SnPostPreload(
|
||||
@@ -52,19 +64,20 @@ class SnPostContentProvider {
|
||||
attachments: attachments.where((ele) => out[i].body['attachments']?.contains(ele?.rid) ?? false).toList(),
|
||||
video: attachments.where((ele) => ele?.rid == out[i].body['video']).firstOrNull,
|
||||
poll: poll,
|
||||
realm: realm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await _ud.listAccount(
|
||||
attachments.where((ele) => ele != null).map((ele) => ele!.accountId).toSet(),
|
||||
);
|
||||
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
|
||||
await _ud.listAccount(uids);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<SnPost> _preloadRelatedDataSingle(SnPost out) async {
|
||||
Set<String> rids = {};
|
||||
Set<int> uids = {};
|
||||
rids.addAll(out.body['attachments']?.cast<String>() ?? []);
|
||||
if (out.body['thumbnail'] != null) {
|
||||
rids.add(out.body['thumbnail']);
|
||||
@@ -77,13 +90,20 @@ class SnPostContentProvider {
|
||||
repostTo: await _preloadRelatedDataSingle(out.repostTo!),
|
||||
);
|
||||
}
|
||||
if (out.publisher.type == 0) {
|
||||
uids.add(out.publisher.accountId);
|
||||
}
|
||||
|
||||
final attachments = await _attach.getMultiple(rids.toList());
|
||||
|
||||
SnPoll? poll;
|
||||
SnRealm? realm;
|
||||
if (out.pollId != null) {
|
||||
poll = await _fetchPoll(out.pollId!);
|
||||
}
|
||||
if (out.realmId != null) {
|
||||
realm = await _realm.getRealm(out.realmId!);
|
||||
}
|
||||
|
||||
out = out.copyWith(
|
||||
preload: SnPostPreload(
|
||||
@@ -91,9 +111,13 @@ class SnPostContentProvider {
|
||||
attachments: attachments.where((ele) => out.body['attachments']?.contains(ele?.rid) ?? false).toList(),
|
||||
video: attachments.where((ele) => ele?.rid == out.body['video']).firstOrNull,
|
||||
poll: poll,
|
||||
realm: realm,
|
||||
),
|
||||
);
|
||||
|
||||
uids.addAll(attachments.where((ele) => ele != null).map((ele) => ele!.accountId));
|
||||
await _ud.listAccount(uids);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -112,6 +136,8 @@ class SnPostContentProvider {
|
||||
String? author,
|
||||
Iterable<String>? categories,
|
||||
Iterable<String>? tags,
|
||||
String? realm,
|
||||
String? channel,
|
||||
}) async {
|
||||
final resp = await _sn.client.get('/cgi/co/posts', queryParameters: {
|
||||
'take': take,
|
||||
@@ -120,6 +146,8 @@ class SnPostContentProvider {
|
||||
if (author != null) 'author': author,
|
||||
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
|
||||
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
|
||||
if (realm != null) 'realm': realm,
|
||||
if (channel != null) 'channel': channel,
|
||||
});
|
||||
final List<SnPost> out = await _preloadRelatedDataInBatch(
|
||||
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
|
||||
@@ -13,10 +16,12 @@ const kConcurrentUploadChunks = 5;
|
||||
|
||||
class SnAttachmentProvider {
|
||||
late final SnNetworkProvider _sn;
|
||||
late final DatabaseProvider _dt;
|
||||
final Map<String, SnAttachment> _cache = {};
|
||||
|
||||
SnAttachmentProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_dt = context.read<DatabaseProvider>();
|
||||
}
|
||||
|
||||
void putCache(Iterable<SnAttachment> items, {bool noCheck = false}) {
|
||||
@@ -28,20 +33,33 @@ class SnAttachmentProvider {
|
||||
}
|
||||
|
||||
Future<SnAttachment> getOne(String rid, {noCache = false}) async {
|
||||
// In-memory cache
|
||||
if (!noCache && _cache.containsKey(rid)) {
|
||||
return _cache[rid]!;
|
||||
}
|
||||
|
||||
// On-disk cache
|
||||
final dbResp = await (_dt.db.snLocalAttachment.select()
|
||||
..where((e) => e.rid.equals(rid))
|
||||
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||
.getSingleOrNull();
|
||||
if (dbResp != null) {
|
||||
_cache[rid] = dbResp.content;
|
||||
return dbResp.content;
|
||||
}
|
||||
// Remote server
|
||||
final resp = await _sn.client.get('/cgi/uc/attachments/$rid/meta');
|
||||
final out = SnAttachment.fromJson(resp.data);
|
||||
if (out.isAnalyzed) {
|
||||
_cache[rid] = out;
|
||||
}
|
||||
_saveToLocal([out]);
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<List<SnAttachment?>> getMultiple(List<String> rids, {noCache = false}) async {
|
||||
Future<List<SnAttachment?>> getMultiple(List<String> rids,
|
||||
{bool noCache = false}) async {
|
||||
// In-memory cache
|
||||
final result = List<SnAttachment?>.filled(rids.length, null);
|
||||
final Map<String, int> randomMapping = {};
|
||||
for (int i = 0; i < rids.length; i++) {
|
||||
@@ -52,32 +70,55 @@ class SnAttachmentProvider {
|
||||
result[i] = _cache[rid]!;
|
||||
}
|
||||
}
|
||||
final pendingFetch = randomMapping.keys;
|
||||
|
||||
if (pendingFetch.isNotEmpty) {
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/uc/attachments',
|
||||
queryParameters: {
|
||||
'take': pendingFetch.length,
|
||||
'id': pendingFetch.join(','),
|
||||
},
|
||||
);
|
||||
final List<SnAttachment?> out =
|
||||
resp.data['data'].map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e)).cast<SnAttachment?>().toList();
|
||||
|
||||
for (final item in out) {
|
||||
if (item == null) continue;
|
||||
if (item.isAnalyzed) {
|
||||
_cache[item.rid] = item;
|
||||
var pendingFetch = randomMapping.keys;
|
||||
// On-disk cache
|
||||
if (pendingFetch.isEmpty) return result;
|
||||
if (!noCache) {
|
||||
final dbResp = await (_dt.db.snLocalAttachment.select()
|
||||
..where((e) => e.rid.isIn(pendingFetch))
|
||||
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||
.get();
|
||||
for (final item in dbResp) {
|
||||
if (item.content.isAnalyzed) {
|
||||
_cache[item.rid] = item.content;
|
||||
}
|
||||
result[randomMapping[item.rid]!] = item;
|
||||
result[randomMapping[item.rid]!] = item.content;
|
||||
randomMapping.remove(item.rid);
|
||||
}
|
||||
pendingFetch = randomMapping.keys;
|
||||
}
|
||||
// Remote server
|
||||
if (pendingFetch.isEmpty) return result;
|
||||
final resp = await _sn.client.get(
|
||||
'/cgi/uc/attachments',
|
||||
queryParameters: {
|
||||
'take': pendingFetch.length,
|
||||
'id': pendingFetch.join(','),
|
||||
},
|
||||
);
|
||||
final List<SnAttachment?> out = resp.data['data']
|
||||
.map((e) => e['id'] == 0 ? null : SnAttachment.fromJson(e))
|
||||
.cast<SnAttachment?>()
|
||||
.toList();
|
||||
for (final item in out) {
|
||||
if (item == null) continue;
|
||||
if (item.isAnalyzed) {
|
||||
_cache[item.rid] = item;
|
||||
}
|
||||
result[randomMapping[item.rid]!] = item;
|
||||
}
|
||||
_saveToLocal(out.where((ele) => ele != null).cast());
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static Map<String, String> mimetypeOverrides = {'mov': 'video/quicktime', 'mp4': 'video/mp4'};
|
||||
static Map<String, String> mimetypeOverrides = {
|
||||
'mov': 'video/quicktime',
|
||||
'mp4': 'video/mp4',
|
||||
'm4a': 'audio/mp4',
|
||||
'apng': 'image/apng',
|
||||
'webp': 'image/webp',
|
||||
};
|
||||
|
||||
Future<SnAttachment> directUploadOne(
|
||||
Uint8List data,
|
||||
@@ -89,8 +130,11 @@ class SnAttachmentProvider {
|
||||
bool analyzeNow = false,
|
||||
}) async {
|
||||
final filePayload = MultipartFile.fromBytes(data, filename: filename);
|
||||
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
|
||||
final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||
final fileAlt = filename.contains('.')
|
||||
? filename.substring(0, filename.lastIndexOf('.'))
|
||||
: filename;
|
||||
final fileExt =
|
||||
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||
|
||||
String? mimetypeOverride;
|
||||
if (mimetype != null) {
|
||||
@@ -127,8 +171,11 @@ class SnAttachmentProvider {
|
||||
Map<String, dynamic>? metadata, {
|
||||
String? mimetype,
|
||||
}) async {
|
||||
final fileAlt = filename.contains('.') ? filename.substring(0, filename.lastIndexOf('.')) : filename;
|
||||
final fileExt = filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||
final fileAlt = filename.contains('.')
|
||||
? filename.substring(0, filename.lastIndexOf('.'))
|
||||
: filename;
|
||||
final fileExt =
|
||||
filename.substring(filename.lastIndexOf('.') + 1).toLowerCase();
|
||||
|
||||
String? mimetypeOverride;
|
||||
if (mimetype == null && mimetypeOverrides.keys.contains(fileExt)) {
|
||||
@@ -146,7 +193,10 @@ class SnAttachmentProvider {
|
||||
if (mimetypeOverride != null) 'mimetype': mimetypeOverride,
|
||||
});
|
||||
|
||||
return (SnAttachmentFragment.fromJson(resp.data['meta']), resp.data['chunk_size'] as int);
|
||||
return (
|
||||
SnAttachmentFragment.fromJson(resp.data['meta']),
|
||||
resp.data['chunk_size'] as int
|
||||
);
|
||||
}
|
||||
|
||||
Future<dynamic> _chunkedUploadOnePart(
|
||||
@@ -197,7 +247,10 @@ class SnAttachmentProvider {
|
||||
(entry.value + 1) * chunkSize,
|
||||
await file.length(),
|
||||
);
|
||||
final data = Uint8List.fromList(await file.openRead(beginCursor, endCursor).expand((chunk) => chunk).toList());
|
||||
final data = Uint8List.fromList(await file
|
||||
.openRead(beginCursor, endCursor)
|
||||
.expand((chunk) => chunk)
|
||||
.toList());
|
||||
|
||||
final result = await _chunkedUploadOnePart(
|
||||
data,
|
||||
@@ -253,6 +306,31 @@ class SnAttachmentProvider {
|
||||
'metadata': metadata ?? item.usermeta,
|
||||
'is_indexable': isIndexable ?? item.isIndexable,
|
||||
});
|
||||
return SnAttachment.fromJson(resp.data);
|
||||
final out = SnAttachment.fromJson(resp.data);
|
||||
_saveToLocal([out]);
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<void> _saveToLocal(Iterable<SnAttachment> out) async {
|
||||
for (final ele in out) {
|
||||
if (!ele.isAnalyzed || ele.destination == 0) continue;
|
||||
await _dt.db.snLocalAttachment.insertOne(
|
||||
SnLocalAttachmentCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
rid: ele.rid,
|
||||
uuid: ele.uuid,
|
||||
content: ele,
|
||||
accountId: ele.accountId,
|
||||
cacheExpiredAt: DateTime.now().add(const Duration(days: 7)),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SnLocalAttachmentCompanion.custom(
|
||||
content: Constant(jsonEncode(ele.toJson())),
|
||||
cacheExpiredAt:
|
||||
Constant(DateTime.now().add(const Duration(days: 7))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -11,9 +10,12 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/widget.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger_interceptor.dart';
|
||||
import 'package:talker_dio_logger/talker_dio_logger_settings.dart';
|
||||
|
||||
const kNetworkServerDirectory = [
|
||||
('Solar Network', 'https://api.sn.solsynth.dev'),
|
||||
@@ -36,6 +38,19 @@ class SnNetworkProvider {
|
||||
|
||||
client = Dio();
|
||||
|
||||
client.interceptors.add(
|
||||
TalkerDioLogger(
|
||||
talker: logging,
|
||||
settings: const TalkerDioLoggerSettings(
|
||||
printRequestHeaders: false,
|
||||
printResponseHeaders: false,
|
||||
printResponseMessage: false,
|
||||
printResponseData: false,
|
||||
printRequestData: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
client.interceptors.add(RetryInterceptor(
|
||||
dio: client,
|
||||
retries: 3,
|
||||
@@ -69,7 +84,6 @@ class SnNetworkProvider {
|
||||
_prefs = _config.prefs;
|
||||
client.options.baseUrl = _config.serverUrl;
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
static Future<Dio> createOffContextClient() async {
|
||||
@@ -91,7 +105,8 @@ class SnNetworkProvider {
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey), prefs.getString(kRtkStoreKey), (atk, rtk) {
|
||||
final atk = await _getFreshAtk(client, prefs.getString(kAtkStoreKey),
|
||||
prefs.getString(kRtkStoreKey), (atk, rtk) {
|
||||
prefs.setString(kAtkStoreKey, atk);
|
||||
prefs.setString(kRtkStoreKey, rtk);
|
||||
});
|
||||
@@ -103,7 +118,8 @@ class SnNetworkProvider {
|
||||
},
|
||||
),
|
||||
);
|
||||
client.options.baseUrl = prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
||||
client.options.baseUrl =
|
||||
prefs.getString(kNetworkServerStoreKey) ?? kNetworkServerDefault;
|
||||
|
||||
return client;
|
||||
}
|
||||
@@ -119,7 +135,8 @@ class SnNetworkProvider {
|
||||
platformInfo = 'Web; ${deviceInfo.vendor}';
|
||||
} else if (Platform.isAndroid) {
|
||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||
platformInfo = 'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
|
||||
platformInfo =
|
||||
'Android; ${deviceInfo.brand} ${deviceInfo.model}; ${deviceInfo.id}';
|
||||
} else if (Platform.isIOS) {
|
||||
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
||||
platformInfo = 'iOS; ${deviceInfo.model}; ${deviceInfo.name}';
|
||||
@@ -128,7 +145,8 @@ class SnNetworkProvider {
|
||||
platformInfo = 'MacOS; ${deviceInfo.model}; ${deviceInfo.hostName}';
|
||||
} else if (Platform.isWindows) {
|
||||
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
||||
platformInfo = 'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
|
||||
platformInfo =
|
||||
'Windows NT; ${deviceInfo.productName}; ${deviceInfo.computerName}';
|
||||
} else if (Platform.isLinux) {
|
||||
final deviceInfo = await DeviceInfoPlugin().linuxInfo;
|
||||
platformInfo = 'Linux; ${deviceInfo.prettyName}';
|
||||
@@ -148,12 +166,15 @@ class SnNetworkProvider {
|
||||
final tkLock = Lock();
|
||||
|
||||
Future<String?> getFreshAtk() async {
|
||||
return await _getFreshAtk(client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey), (atk, rtk) {
|
||||
return await _getFreshAtk(
|
||||
client, _prefs.getString(kAtkStoreKey), _prefs.getString(kRtkStoreKey),
|
||||
(atk, rtk) {
|
||||
setTokenPair(atk, rtk);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk, Function(String atk, String rtk)? onRefresh) async {
|
||||
static Future<String?> _getFreshAtk(Dio client, String? atk, String? rtk,
|
||||
Function(String atk, String rtk)? onRefresh) async {
|
||||
if (_refreshCompleter != null) {
|
||||
return await _refreshCompleter!.future;
|
||||
} else {
|
||||
@@ -185,7 +206,8 @@ class SnNetworkProvider {
|
||||
final payload = b64.decode(rawPayload);
|
||||
final exp = jsonDecode(payload)['exp'];
|
||||
if (exp <= DateTime.now().millisecondsSinceEpoch ~/ 1000) {
|
||||
log('Access token need refresh, doing it at ${DateTime.now()}');
|
||||
logging.debug(
|
||||
'[Auth] Access token need refresh, doing it at ${DateTime.now()}');
|
||||
final result = await _refreshToken(client.options.baseUrl, rtk);
|
||||
if (result == null) {
|
||||
atk = null;
|
||||
@@ -199,12 +221,12 @@ class SnNetworkProvider {
|
||||
_refreshCompleter!.complete(atk);
|
||||
return atk;
|
||||
} else {
|
||||
log('Access token refresh failed...');
|
||||
logging.error('[Auth] Access token refresh failed...');
|
||||
_refreshCompleter!.complete(null);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('Failed to authenticate user: $err');
|
||||
logging.error('[Auth] Failed to authenticate user...', err);
|
||||
_refreshCompleter!.completeError(err);
|
||||
} finally {
|
||||
_refreshCompleter = null;
|
||||
@@ -237,7 +259,8 @@ class SnNetworkProvider {
|
||||
return result.$1;
|
||||
}
|
||||
|
||||
static Future<(String, String)?> _refreshToken(String baseUrl, String? rtk) async {
|
||||
static Future<(String, String)?> _refreshToken(
|
||||
String baseUrl, String? rtk) async {
|
||||
if (rtk == null) return null;
|
||||
|
||||
final dio = Dio();
|
||||
|
||||
37
lib/providers/sn_realm.dart
Normal file
37
lib/providers/sn_realm.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
|
||||
class SnRealmProvider {
|
||||
late final SnNetworkProvider _sn;
|
||||
|
||||
SnRealmProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
}
|
||||
|
||||
final Map<String, SnRealm> _cache = {};
|
||||
|
||||
Future<List<SnRealm>> listAvailableRealms() async {
|
||||
final resp = await _sn.client.get('/cgi/id/realms/me/available');
|
||||
final out = List<SnRealm>.from(
|
||||
resp.data?.map((e) => SnRealm.fromJson(e)) ?? [],
|
||||
);
|
||||
for (final realm in out) {
|
||||
_cache[realm.alias] = realm;
|
||||
_cache[realm.id.toString()] = realm;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<SnRealm> getRealm(dynamic aliasOrId) async {
|
||||
if (_cache.containsKey(aliasOrId.toString())) {
|
||||
return _cache[aliasOrId.toString()]!;
|
||||
}
|
||||
final resp = await _sn.client.get('/cgi/id/realms/$aliasOrId');
|
||||
final out = SnRealm.fromJson(resp.data);
|
||||
_cache[out.alias] = out;
|
||||
_cache[out.id.toString()] = out;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
|
||||
class SnStickerProvider {
|
||||
late final SnNetworkProvider _sn;
|
||||
late final DatabaseProvider _dt;
|
||||
final Map<String, SnSticker?> _cache = {};
|
||||
|
||||
final Map<int, List<SnSticker>> stickersByPack = {};
|
||||
|
||||
List<SnSticker> get stickers => _cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
|
||||
List<SnSticker> get stickers =>
|
||||
_cache.values.where((ele) => ele != null).cast<SnSticker>().toList();
|
||||
|
||||
SnStickerProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_dt = context.read<DatabaseProvider>();
|
||||
}
|
||||
|
||||
bool hasNotSticker(String alias) {
|
||||
@@ -23,52 +30,103 @@ class SnStickerProvider {
|
||||
|
||||
void _cacheSticker(SnSticker sticker) {
|
||||
_cache['${sticker.pack.prefix}:${sticker.alias}'] = sticker;
|
||||
if (stickersByPack[sticker.pack.id] == null) stickersByPack[sticker.pack.id] = List.empty(growable: true);
|
||||
if (!stickersByPack[sticker.pack.id]!.contains(sticker)) stickersByPack[sticker.pack.id]!.add(sticker);
|
||||
if (stickersByPack[sticker.pack.id] == null) {
|
||||
stickersByPack[sticker.pack.id] = List.empty(growable: true);
|
||||
}
|
||||
if (!stickersByPack[sticker.pack.id]!.contains(sticker)) {
|
||||
stickersByPack[sticker.pack.id]!.add(sticker);
|
||||
}
|
||||
}
|
||||
|
||||
void putSticker(Iterable<SnSticker> stickers) {
|
||||
for (final ele in stickers) {
|
||||
_cacheSticker(ele);
|
||||
}
|
||||
_saveStickerToLocal(stickers);
|
||||
_saveStickerPackToLocal(stickers.map((ele) => ele.pack).toSet());
|
||||
}
|
||||
|
||||
Future<SnSticker?> lookupSticker(String alias) async {
|
||||
// In-memory cache
|
||||
if (_cache.containsKey(alias)) {
|
||||
return _cache[alias];
|
||||
}
|
||||
|
||||
// On-disk cache
|
||||
final localStickers = await (_dt.db.snLocalSticker.select()
|
||||
..where((e) => e.fullAlias.equals(alias)))
|
||||
.getSingleOrNull();
|
||||
if (localStickers != null) {
|
||||
_cache[alias] = localStickers.content;
|
||||
return localStickers.content;
|
||||
}
|
||||
// Remote server
|
||||
try {
|
||||
final resp = await _sn.client.get('/cgi/uc/stickers/lookup/$alias');
|
||||
final sticker = SnSticker.fromJson(resp.data);
|
||||
_cacheSticker(sticker);
|
||||
|
||||
putSticker([sticker]);
|
||||
return sticker;
|
||||
} catch (err) {
|
||||
_cache[alias] = null;
|
||||
log('[Sticker] Failed to lookup sticker $alias: $err');
|
||||
logging.warning('[Sticker] Failed to lookup sticker $alias', err);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> listStickerEagerly() async {
|
||||
var count = await listSticker();
|
||||
for (var page = 1; count > 0; count -= 10) {
|
||||
await listSticker(page: page);
|
||||
page++;
|
||||
Future<void> listSticker() async {
|
||||
final localPacks = await _dt.db.snLocalStickerPack.select().get();
|
||||
final localStickers = await _dt.db.snLocalSticker.select().get();
|
||||
final local = localStickers.map((ele) {
|
||||
return ele.content.copyWith(
|
||||
pack: localPacks
|
||||
.firstWhere((pk) => pk.content.id == ele.content.packId)
|
||||
.content,
|
||||
);
|
||||
});
|
||||
for (final sticker in local) {
|
||||
_cacheSticker(sticker);
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> listSticker({int page = 0}) async {
|
||||
try {
|
||||
final resp = await _sn.client.get('/cgi/uc/stickers', queryParameters: {
|
||||
'take': 10,
|
||||
'offset': page * 10,
|
||||
});
|
||||
final resp = await _sn.client.get('/cgi/uc/stickers');
|
||||
final data = resp.data;
|
||||
final stickers = List.from(data['data']).map((ele) => SnSticker.fromJson(ele));
|
||||
final stickers = List.from(data).map((ele) => SnSticker.fromJson(ele));
|
||||
for (final sticker in stickers) {
|
||||
_cacheSticker(sticker);
|
||||
}
|
||||
return data['count'] as int;
|
||||
} catch (err) {
|
||||
log('[Sticker] Failed to list stickers: $err');
|
||||
logging.error('[Sticker] Failed to list stickers...', err);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveStickerToLocal(Iterable<SnSticker> stickers) async {
|
||||
await _dt.db.snLocalSticker.insertAll(
|
||||
stickers.map(
|
||||
(ele) => SnLocalStickerCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
alias: ele.alias,
|
||||
fullAlias: '${ele.pack.prefix}${ele.alias}',
|
||||
content: ele,
|
||||
createdAt: Value(ele.createdAt),
|
||||
),
|
||||
),
|
||||
onConflict: DoNothing(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _saveStickerPackToLocal(Iterable<SnStickerPack> packs) async {
|
||||
final queries = packs
|
||||
.map(
|
||||
(ele) => _dt.db.snLocalStickerPack.insertOne(
|
||||
SnLocalStickerPackCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
content: ele,
|
||||
createdAt: Value(ele.createdAt),
|
||||
),
|
||||
onConflict: DoUpdate((_) => SnLocalStickerPackCompanion.custom(
|
||||
content: Constant(jsonEncode(ele.toJson()))))),
|
||||
)
|
||||
.toList();
|
||||
await Future.wait(queries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,16 @@ class ThemeProvider extends ChangeNotifier {
|
||||
});
|
||||
}
|
||||
|
||||
void reloadTheme({Color? seedColorOverride, bool? useMaterial3}) {
|
||||
createAppThemeSet(seedColorOverride: seedColorOverride, useMaterial3: useMaterial3).then((value) {
|
||||
void reloadTheme({
|
||||
Color? seedColorOverride,
|
||||
bool? useMaterial3,
|
||||
String? customFonts,
|
||||
}) {
|
||||
createAppThemeSet(
|
||||
seedColorOverride: seedColorOverride,
|
||||
useMaterial3: useMaterial3,
|
||||
customFonts: customFonts,
|
||||
).then((value) {
|
||||
theme = value;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
@@ -1,19 +1,36 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/database/database.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
|
||||
class UserDirectoryProvider {
|
||||
late final SnNetworkProvider _sn;
|
||||
late final DatabaseProvider _dt;
|
||||
|
||||
UserDirectoryProvider(BuildContext context) {
|
||||
_sn = context.read<SnNetworkProvider>();
|
||||
_dt = context.read<DatabaseProvider>();
|
||||
}
|
||||
|
||||
final Map<String, int> _idCache = {};
|
||||
final Map<int, SnAccount> _cache = {};
|
||||
|
||||
Future<int> loadAccountCache({int max = 100}) async {
|
||||
final out = await (_dt.db.snLocalAccount.select()..limit(max)).get();
|
||||
for (final ele in out) {
|
||||
_cache[ele.id] = ele.content;
|
||||
_idCache[ele.name] = ele.id;
|
||||
}
|
||||
return out.length;
|
||||
}
|
||||
|
||||
Future<List<SnAccount?>> listAccount(Iterable<dynamic> id) async {
|
||||
// In-memory cache
|
||||
final out = List<SnAccount?>.generate(id.length, (e) => null);
|
||||
final plannedQuery = <int>{};
|
||||
for (var idx = 0; idx < out.length; idx++) {
|
||||
@@ -27,8 +44,29 @@ class UserDirectoryProvider {
|
||||
plannedQuery.add(item);
|
||||
}
|
||||
}
|
||||
final resp = await _sn.client.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
|
||||
final respDecoded = resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
|
||||
// On-disk cache
|
||||
if (plannedQuery.isEmpty) return out;
|
||||
final dbResp = await (_dt.db.snLocalAccount.select()
|
||||
..where((e) => e.id.isIn(plannedQuery))
|
||||
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now()))
|
||||
..limit(plannedQuery.length))
|
||||
.get();
|
||||
for (var idx = 0; idx < out.length; idx++) {
|
||||
if (out[idx] != null) continue;
|
||||
if (dbResp.length <= idx) {
|
||||
break;
|
||||
}
|
||||
out[idx] = dbResp[idx].content;
|
||||
_cache[dbResp[idx].id] = dbResp[idx].content;
|
||||
_idCache[dbResp[idx].name] = dbResp[idx].id;
|
||||
plannedQuery.remove(dbResp[idx].id);
|
||||
}
|
||||
// Remote server
|
||||
if (plannedQuery.isEmpty) return out;
|
||||
final resp = await _sn.client
|
||||
.get('/cgi/id/users', queryParameters: {'id': plannedQuery.join(',')});
|
||||
final respDecoded =
|
||||
resp.data.map((e) => SnAccount.fromJson(e)).cast<SnAccount>().toList();
|
||||
var sideIdx = 0;
|
||||
for (var idx = 0; idx < out.length; idx++) {
|
||||
if (out[idx] != null) continue;
|
||||
@@ -40,17 +78,29 @@ class UserDirectoryProvider {
|
||||
_idCache[respDecoded[sideIdx].name] = respDecoded[sideIdx].id;
|
||||
sideIdx++;
|
||||
}
|
||||
if (respDecoded.isNotEmpty) _saveToLocal(respDecoded);
|
||||
return out;
|
||||
}
|
||||
|
||||
Future<SnAccount?> getAccount(dynamic id) async {
|
||||
// In-memory cache
|
||||
if (id is String && _idCache.containsKey(id)) {
|
||||
id = _idCache[id];
|
||||
}
|
||||
if (_cache.containsKey(id)) {
|
||||
return _cache[id];
|
||||
}
|
||||
|
||||
// On-disk cache
|
||||
final dbResp = await (_dt.db.snLocalAccount.select()
|
||||
..where((e) => e.id.equals(id))
|
||||
..where((e) => e.cacheExpiredAt.isBiggerThanValue(DateTime.now())))
|
||||
.getSingleOrNull();
|
||||
if (dbResp != null) {
|
||||
_cache[dbResp.id] = dbResp.content;
|
||||
_idCache[dbResp.name] = dbResp.id;
|
||||
return dbResp.content;
|
||||
}
|
||||
// Remote server
|
||||
try {
|
||||
final resp = await _sn.client.get('/cgi/id/users/$id');
|
||||
final account = SnAccount.fromJson(
|
||||
@@ -58,16 +108,42 @@ class UserDirectoryProvider {
|
||||
);
|
||||
_cache[account.id] = account;
|
||||
if (id is String) _idCache[id] = account.id;
|
||||
_saveToLocal([account]);
|
||||
return account;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
SnAccount? getAccountFromCache(dynamic id) {
|
||||
SnAccount? getFromCache(dynamic id) {
|
||||
if (id is String && _idCache.containsKey(id)) {
|
||||
id = _idCache[id];
|
||||
}
|
||||
return _cache[id];
|
||||
}
|
||||
|
||||
Future<void> _saveToLocal(Iterable<SnAccount> out) async {
|
||||
// For better on conflict resolution
|
||||
// And consider the method usually called with usually small amount of data
|
||||
// Use for to insert each record instead of bulk insert
|
||||
List<Future<int>> queries = out.map((ele) {
|
||||
return _dt.db.snLocalAccount.insertOne(
|
||||
SnLocalAccountCompanion.insert(
|
||||
id: Value(ele.id),
|
||||
name: ele.name,
|
||||
content: ele,
|
||||
cacheExpiredAt: DateTime.now().add(const Duration(hours: 1)),
|
||||
),
|
||||
onConflict: DoUpdate(
|
||||
(_) => SnLocalAccountCompanion.custom(
|
||||
name: Constant(ele.name),
|
||||
content: Constant(jsonEncode(ele.toJson())),
|
||||
cacheExpiredAt:
|
||||
Constant(DateTime.now().add(const Duration(hours: 1))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
await Future.wait(queries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
@@ -30,8 +29,8 @@ class UserProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
refreshUser().then((value) async {
|
||||
if (value != null) {
|
||||
log('Logged in as @${value.name}');
|
||||
log('Atk: ${await atk}');
|
||||
logging.info('[Auth] Logged in as @${value.name}');
|
||||
logging.debug('[Auth] Access token: ${await atk}');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_udid/flutter_udid.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/websocket.dart';
|
||||
import 'package:web_socket_channel/io.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
|
||||
class WebSocketProvider extends ChangeNotifier {
|
||||
@@ -30,7 +33,7 @@ class WebSocketProvider extends ChangeNotifier {
|
||||
if (isConnected) return;
|
||||
if (!_ua.isAuthorized) return;
|
||||
|
||||
log('[WebSocket] Connecting to the server...');
|
||||
logging.debug('[WebSocket] Connecting to the server...');
|
||||
await connect();
|
||||
}
|
||||
|
||||
@@ -39,7 +42,7 @@ class WebSocketProvider extends ChangeNotifier {
|
||||
Future<void> connect({noRetry = false}) async {
|
||||
if (_connectCompleter != null) {
|
||||
await _connectCompleter!.future;
|
||||
_connectCompleter = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_ua.isAuthorized) return;
|
||||
@@ -52,27 +55,39 @@ class WebSocketProvider extends ChangeNotifier {
|
||||
|
||||
final atk = await _sn.getFreshAtk();
|
||||
final uri = Uri.parse(
|
||||
'${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk',
|
||||
kIsWeb
|
||||
? '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?tk=$atk'
|
||||
: '${_sn.client.options.baseUrl.replaceFirst('http', 'ws')}/ws?clientId=${await FlutterUdid.consistentUdid}tk=$atk',
|
||||
);
|
||||
|
||||
isBusy = true;
|
||||
notifyListeners();
|
||||
|
||||
conn = WebSocketChannel.connect(uri);
|
||||
conn = kIsWeb
|
||||
? WebSocketChannel.connect(uri)
|
||||
: IOWebSocketChannel.connect(
|
||||
uri,
|
||||
headers: {'Authorization': 'Bearer $atk'},
|
||||
);
|
||||
await conn!.ready;
|
||||
_wsStream = conn!.stream.asBroadcastStream();
|
||||
listen();
|
||||
log('[WebSocket] Connected to server!');
|
||||
logging.info('[WebSocket] Connected to server!');
|
||||
isConnected = true;
|
||||
} catch (err) {
|
||||
if (err is WebSocketChannelException) {
|
||||
log('Failed to connect to websocket: ${(err.inner as dynamic).message}');
|
||||
logging.error(
|
||||
'[WebSocket] Failed to connect to websocket...',
|
||||
err.inner,
|
||||
);
|
||||
} else {
|
||||
log('Failed to connect to websocket: $err');
|
||||
logging.error('[WebSocket] Failed to connect to websocket...', err);
|
||||
}
|
||||
|
||||
if (!noRetry) {
|
||||
log('Retry connecting to websocket in 3 seconds...');
|
||||
logging.warning(
|
||||
'[WebSocket] Retry connecting to websocket in 3 seconds...',
|
||||
);
|
||||
return Future.delayed(
|
||||
const Duration(seconds: 3),
|
||||
() => connect(noRetry: true),
|
||||
@@ -100,7 +115,9 @@ class WebSocketProvider extends ChangeNotifier {
|
||||
_wsStream!.listen(
|
||||
(event) {
|
||||
final packet = WebSocketPackage.fromJson(jsonDecode(event));
|
||||
log('Websocket incoming message: ${packet.method} ${packet.message}');
|
||||
logging.debug(
|
||||
'[Websocket] Incoming message: ${packet.method} ${packet.message}',
|
||||
);
|
||||
pk.sink.add(packet);
|
||||
},
|
||||
onDone: () {
|
||||
|
||||
165
lib/router.dart
165
lib/router.dart
@@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:surface/screens/abuse_report.dart';
|
||||
import 'package:surface/screens/account.dart';
|
||||
import 'package:surface/screens/account/account_settings.dart';
|
||||
import 'package:surface/screens/account/badges.dart';
|
||||
import 'package:surface/screens/account/factor_settings.dart';
|
||||
import 'package:surface/screens/account/keypairs.dart';
|
||||
import 'package:surface/screens/account/profile_page.dart';
|
||||
import 'package:surface/screens/account/profile_edit.dart';
|
||||
import 'package:surface/screens/account/publishers/publisher_edit.dart';
|
||||
@@ -21,6 +23,7 @@ import 'package:surface/screens/chat/room.dart';
|
||||
import 'package:surface/screens/explore.dart';
|
||||
import 'package:surface/screens/friend.dart';
|
||||
import 'package:surface/screens/home.dart';
|
||||
import 'package:surface/screens/logging.dart';
|
||||
import 'package:surface/screens/news/news_detail.dart';
|
||||
import 'package:surface/screens/news/news_list.dart';
|
||||
import 'package:surface/screens/notification.dart';
|
||||
@@ -34,13 +37,15 @@ import 'package:surface/screens/realm/realm_detail.dart';
|
||||
import 'package:surface/screens/realm/realm_discovery.dart';
|
||||
import 'package:surface/screens/settings.dart';
|
||||
import 'package:surface/screens/sharing.dart';
|
||||
import 'package:surface/screens/stickers.dart';
|
||||
import 'package:surface/screens/stickers/pack_detail.dart';
|
||||
import 'package:surface/screens/wallet.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/about.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
Widget _fadeThroughTransition(
|
||||
BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
|
||||
Widget _fadeThroughTransition(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return FadeThroughTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
@@ -61,10 +66,10 @@ final _appRoutes = [
|
||||
builder: (context, state) => const ExploreScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/write/:mode',
|
||||
path: '/write',
|
||||
name: 'postEditor',
|
||||
builder: (context, state) => PostEditorScreen(
|
||||
mode: state.pathParameters['mode']!,
|
||||
mode: state.uri.queryParameters['mode'],
|
||||
postEditId: int.tryParse(
|
||||
state.uri.queryParameters['editing'] ?? '',
|
||||
),
|
||||
@@ -82,13 +87,15 @@ final _appRoutes = [
|
||||
name: 'postSearch',
|
||||
builder: (context, state) => PostSearchScreen(
|
||||
initialTags: state.uri.queryParameters['tags']?.split(','),
|
||||
initialCategories: state.uri.queryParameters['categories']?.split(','),
|
||||
initialCategories:
|
||||
state.uri.queryParameters['categories']?.split(','),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/:name',
|
||||
name: 'postPublisher',
|
||||
builder: (context, state) => PostPublisherScreen(name: state.pathParameters['name']!),
|
||||
builder: (context, state) =>
|
||||
PostPublisherScreen(name: state.pathParameters['name']!),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:slug',
|
||||
@@ -100,52 +107,67 @@ final _appRoutes = [
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(path: '/account', name: 'account', builder: (context, state) => const AccountScreen(), routes: [
|
||||
GoRoute(
|
||||
path: '/wallet',
|
||||
name: 'accountWallet',
|
||||
builder: (context, state) => const WalletScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: 'accountSettings',
|
||||
builder: (context, state) => AccountSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/factors',
|
||||
name: 'factorSettings',
|
||||
builder: (context, state) => FactorSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile/edit',
|
||||
name: 'accountProfileEdit',
|
||||
builder: (context, state) => ProfileEditScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers',
|
||||
name: 'accountPublishers',
|
||||
builder: (context, state) => PublisherScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/new',
|
||||
name: 'accountPublisherNew',
|
||||
builder: (context, state) => AccountPublisherNewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/edit/:name',
|
||||
name: 'accountPublisherEdit',
|
||||
builder: (context, state) => AccountPublisherEditScreen(
|
||||
name: state.pathParameters['name']!,
|
||||
GoRoute(
|
||||
path: '/account',
|
||||
name: 'account',
|
||||
builder: (context, state) => const AccountScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/badges',
|
||||
name: 'accountBadges',
|
||||
builder: (context, state) => const AccountBadgesScreen(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:name',
|
||||
name: 'accountProfilePage',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: UserScreen(name: state.pathParameters['name']!),
|
||||
GoRoute(
|
||||
path: '/wallet',
|
||||
name: 'accountWallet',
|
||||
builder: (context, state) => const WalletScreen(),
|
||||
),
|
||||
),
|
||||
]),
|
||||
GoRoute(
|
||||
path: '/keypairs',
|
||||
name: 'accountKeyPairs',
|
||||
builder: (context, state) => const KeyPairScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
name: 'accountSettings',
|
||||
builder: (context, state) => AccountSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/settings/factors',
|
||||
name: 'factorSettings',
|
||||
builder: (context, state) => FactorSettingsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile/edit',
|
||||
name: 'accountProfileEdit',
|
||||
builder: (context, state) => ProfileEditScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers',
|
||||
name: 'accountPublishers',
|
||||
builder: (context, state) => PublisherScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/new',
|
||||
name: 'accountPublisherNew',
|
||||
builder: (context, state) => AccountPublisherNewScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/publishers/edit/:name',
|
||||
name: 'accountPublisherEdit',
|
||||
builder: (context, state) => AccountPublisherEditScreen(
|
||||
name: state.pathParameters['name']!,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/:name',
|
||||
name: 'accountProfilePage',
|
||||
pageBuilder: (context, state) => NoTransitionPage(
|
||||
child: UserScreen(name: state.pathParameters['name']!),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
@@ -208,19 +230,44 @@ final _appRoutes = [
|
||||
GoRoute(
|
||||
path: '/:alias',
|
||||
name: 'realmDetail',
|
||||
builder: (context, state) => RealmDetailScreen(alias: state.pathParameters['alias']!),
|
||||
builder: (context, state) =>
|
||||
RealmDetailScreen(alias: state.pathParameters['alias']!),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(path: '/news', name: 'news', builder: (context, state) => const NewsScreen(), routes: [
|
||||
GoRoute(
|
||||
path: '/:hash',
|
||||
name: 'newsDetail',
|
||||
builder: (context, state) => NewsDetailScreen(
|
||||
hash: state.pathParameters['hash']!,
|
||||
GoRoute(
|
||||
path: '/news',
|
||||
name: 'news',
|
||||
builder: (context, state) => const NewsScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/:hash',
|
||||
name: 'newsDetail',
|
||||
builder: (context, state) => NewsDetailScreen(
|
||||
hash: state.pathParameters['hash']!,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/stickers',
|
||||
name: 'stickers',
|
||||
builder: (context, state) => const StickerScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/packs/:id',
|
||||
name: 'stickerPack',
|
||||
builder: (context, state) => StickerPackScreen(
|
||||
id: int.tryParse(state.pathParameters['id']!)!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GoRoute(
|
||||
path: '/debug/logging',
|
||||
name: 'debugLogging',
|
||||
builder: (context, state) => const DebugLoggingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/album',
|
||||
name: 'album',
|
||||
|
||||
@@ -4,10 +4,10 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
@@ -45,7 +45,8 @@ class AccountScreen extends StatelessWidget {
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner), fit: BoxFit.cover),
|
||||
AutoResizeUniversalImage(sn.getAttachmentUrl(ua.user!.banner),
|
||||
fit: BoxFit.cover),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -79,7 +80,9 @@ class AccountScreen extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: ua.isAuthorized ? _AuthorizedAccountScreen() : _UnauthorizedAccountScreen(),
|
||||
child: ua.isAuthorized
|
||||
? _AuthorizedAccountScreen()
|
||||
: _UnauthorizedAccountScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -115,12 +118,21 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(ua.user!.nick).textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
Text(ua.user!.nick)
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
const Gap(4),
|
||||
Text('@${ua.user!.name}').textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
Text('@${ua.user!.name}')
|
||||
.textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
],
|
||||
),
|
||||
Text(ua.user!.description).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||
Text(
|
||||
(ua.user!.profile?.description.isNotEmpty ?? false)
|
||||
? ua.user!.profile!.description
|
||||
: 'userNoDescription'.tr(),
|
||||
style: (ua.user!.profile?.description.isEmpty ?? true)
|
||||
? TextStyle(fontStyle: FontStyle.italic)
|
||||
: null,
|
||||
).textStyle(Theme.of(context).textTheme.bodyMedium!),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -166,6 +178,26 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
||||
GoRouter.of(context).pushNamed('accountWallet');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountBadges').tr(),
|
||||
subtitle: Text('accountBadgesDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.award_star),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountBadges');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountKeyPairs').tr(),
|
||||
subtitle: Text('accountKeyPairsDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.key),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('accountKeyPairs');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('accountSettings').tr(),
|
||||
subtitle: Text('accountSettingsSubtitle').tr(),
|
||||
@@ -193,8 +225,7 @@ class _AuthorizedAccountScreen extends StatelessWidget {
|
||||
ua.logoutUser();
|
||||
final ws = context.read<WebSocketProvider>();
|
||||
ws.disconnect();
|
||||
await Hive.deleteFromDisk();
|
||||
await Hive.initFlutter();
|
||||
context.read<DatabaseProvider>().removeDatabase();
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -220,7 +251,9 @@ class _UnauthorizedAccountScreen extends StatelessWidget {
|
||||
child: Icon(Symbols.waving_hand, size: 28),
|
||||
),
|
||||
const Gap(8),
|
||||
Text('accountIntroTitle').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
Text('accountIntroTitle')
|
||||
.tr()
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
Text('accountIntroSubtitle').tr(),
|
||||
],
|
||||
).padding(all: 20),
|
||||
|
||||
@@ -54,14 +54,20 @@ class AccountSettingsScreen extends StatelessWidget {
|
||||
child: DropdownButton2<Locale?>(
|
||||
isExpanded: true,
|
||||
items: [
|
||||
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
|
||||
...EasyLocalization.of(context)!
|
||||
.supportedLocales
|
||||
.mapIndexed((idx, ele) {
|
||||
return DropdownMenuItem<Locale?>(
|
||||
value: Locale.parse(ele.toString()),
|
||||
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
|
||||
child: Text('${ele.languageCode}-${ele.countryCode}')
|
||||
.fontSize(14),
|
||||
);
|
||||
}),
|
||||
],
|
||||
value: ua.user?.language != null ? Locale.parse(ua.user!.language) : Locale.parse('en-US'),
|
||||
value: ua.user?.language != null
|
||||
? (Locale.tryParse(ua.user!.language) ??
|
||||
Locale.parse('en-US'))
|
||||
: Locale.parse('en-US'),
|
||||
onChanged: (Locale? value) {
|
||||
if (value == null) return;
|
||||
_setAccountLanguage(context, value);
|
||||
|
||||
140
lib/screens/account/badges.dart
Normal file
140
lib/screens/account/badges.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/screens/account/profile_page.dart' show kBadgesMeta;
|
||||
import 'package:surface/theme.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class AccountBadgesScreen extends StatefulWidget {
|
||||
const AccountBadgesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AccountBadgesScreen> createState() => _AccountBadgesScreenState();
|
||||
}
|
||||
|
||||
class _AccountBadgesScreenState extends State<AccountBadgesScreen> {
|
||||
bool _isBusy = false;
|
||||
List<SnAccountBadge>? _badges;
|
||||
|
||||
Future<void> _fetchBadges() async {
|
||||
setState(() => _isBusy = true);
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/badges/me');
|
||||
if (!mounted) return;
|
||||
setState(
|
||||
() => _badges = List<SnAccountBadge>.from(
|
||||
resp.data?.map((e) => SnAccountBadge.fromJson(e)) ?? [],
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isActivating = false;
|
||||
|
||||
Future<void> _activateBadge(SnAccountBadge badge) async {
|
||||
try {
|
||||
setState(() => _isActivating = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post('/cgi/id/badges/${badge.id}/active');
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('badgeActivated'
|
||||
.tr(args: [(kBadgesMeta[badge.type]?.$1 ?? 'unknown').tr()]));
|
||||
await _fetchBadges();
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isActivating = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchBadges();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('screenAccountBadges').tr(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
if (_badges != null)
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchBadges,
|
||||
child: ListView.builder(
|
||||
itemCount: _badges!.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final badge = _badges![idx];
|
||||
return ListTile(
|
||||
title: Text(
|
||||
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
|
||||
).tr(),
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 16,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (badge.metadata['title'] != null)
|
||||
Text(badge.metadata['title']).fontSize(14).bold()
|
||||
else
|
||||
Text(
|
||||
'#${badge.id.toString().padLeft(8, '0')}',
|
||||
style: GoogleFonts.robotoMono(),
|
||||
).fontSize(14).bold(),
|
||||
Text(
|
||||
DateFormat('y/M/d').format(badge.createdAt),
|
||||
)
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.check),
|
||||
onPressed: (badge.isActive || _isActivating)
|
||||
? null
|
||||
: () {
|
||||
_activateBadge(badge);
|
||||
},
|
||||
),
|
||||
leading: Icon(
|
||||
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
|
||||
color: badge.metadata['color'] != null
|
||||
? HexColor.fromHex(badge.metadata['color']!)
|
||||
: kBadgesMeta[badge.type]?.$3,
|
||||
fill: 1,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
106
lib/screens/account/keypairs.dart
Normal file
106
lib/screens/account/keypairs.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:surface/providers/keypair.dart';
|
||||
import 'package:surface/types/keypair.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class KeyPairScreen extends StatefulWidget {
|
||||
const KeyPairScreen({super.key});
|
||||
|
||||
@override
|
||||
State<KeyPairScreen> createState() => _KeyPairScreenState();
|
||||
}
|
||||
|
||||
class _KeyPairScreenState extends State<KeyPairScreen> {
|
||||
bool _isBusy = false;
|
||||
List<SnKeyPair>? _keyPairs;
|
||||
|
||||
Future<void> _loadKeyPairs() async {
|
||||
setState(() => _isBusy = true);
|
||||
final kps = await context.read<KeyPairProvider>().listKeyPair();
|
||||
setState(() {
|
||||
_keyPairs = kps;
|
||||
_isBusy = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadKeyPairs();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('screenKeyPairs').tr(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.add),
|
||||
title: Text('enrollNewKeyPair').tr(),
|
||||
subtitle: Text('enrollNewKeyPairDescription').tr(),
|
||||
onTap: () async {
|
||||
await context.read<KeyPairProvider>().enrollNew();
|
||||
_loadKeyPairs();
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (_keyPairs != null)
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _loadKeyPairs,
|
||||
child: ListView.builder(
|
||||
itemCount: _keyPairs!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final kp = _keyPairs![index];
|
||||
return ListTile(
|
||||
title: Text(kp.id.toUpperCase()),
|
||||
subtitle: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (kp.privateKey != null)
|
||||
Text(
|
||||
'keyPairHasPrivateKey'.tr(),
|
||||
),
|
||||
if (kp.privateKey != null) Text('·'),
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: Text(
|
||||
'UID #${kp.accountId.toString().padLeft(8, '0')}',
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.check),
|
||||
onPressed: kp.isActive == true
|
||||
? null
|
||||
: () async {
|
||||
final k = context.read<KeyPairProvider>();
|
||||
await k.activeKeyPair(kp.id);
|
||||
_loadKeyPairs();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -36,11 +37,16 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
final _firstNameController = TextEditingController();
|
||||
final _lastNameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
final _timezoneController = TextEditingController();
|
||||
final _genderController = TextEditingController();
|
||||
final _pronounsController = TextEditingController();
|
||||
final _locationController = TextEditingController();
|
||||
final _birthdayController = TextEditingController();
|
||||
|
||||
String? _avatar;
|
||||
String? _banner;
|
||||
DateTime? _birthday;
|
||||
List<(String, String)>? _links;
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
@@ -51,43 +57,46 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
final prof = ua.user!;
|
||||
_usernameController.text = prof.name;
|
||||
_nicknameController.text = prof.nick;
|
||||
_descriptionController.text = prof.description;
|
||||
_descriptionController.text = prof.profile!.description;
|
||||
_firstNameController.text = prof.profile!.firstName;
|
||||
_lastNameController.text = prof.profile!.lastName;
|
||||
_timezoneController.text = prof.profile!.timeZone;
|
||||
_genderController.text = prof.profile!.gender;
|
||||
_pronounsController.text = prof.profile!.pronouns;
|
||||
_locationController.text = prof.profile!.location;
|
||||
_avatar = prof.avatar;
|
||||
_banner = prof.banner;
|
||||
if (prof.profile!.birthday != null) {
|
||||
_birthdayController.text = DateFormat(_kDateFormat).format(
|
||||
prof.profile!.birthday!.toLocal(),
|
||||
);
|
||||
_links = prof.profile!.links.entries.map((ele) => (ele.key, ele.value)).toList();
|
||||
_birthday = prof.profile!.birthday?.toLocal();
|
||||
if (_birthday != null) {
|
||||
_birthdayController.text = DateFormat(_kDateFormat).format(prof.profile!.birthday!.toLocal());
|
||||
}
|
||||
}
|
||||
|
||||
void _selectBirthday() async {
|
||||
await showCupertinoModalPopup<DateTime?>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => Container(
|
||||
height: 216,
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
margin: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: CupertinoDatePicker(
|
||||
initialDateTime: _birthday?.toLocal(),
|
||||
mode: CupertinoDatePickerMode.date,
|
||||
use24hFormat: true,
|
||||
onDateTimeChanged: (DateTime newDate) {
|
||||
setState(() {
|
||||
_birthday = newDate;
|
||||
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
|
||||
});
|
||||
},
|
||||
builder:
|
||||
(BuildContext context) => Container(
|
||||
height: 216,
|
||||
padding: const EdgeInsets.only(top: 6.0),
|
||||
margin: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: CupertinoDatePicker(
|
||||
initialDateTime: _birthday?.toLocal(),
|
||||
mode: CupertinoDatePickerMode.date,
|
||||
use24hFormat: true,
|
||||
onDateTimeChanged: (DateTime newDate) {
|
||||
setState(() {
|
||||
_birthday = newDate;
|
||||
_birthdayController.text = DateFormat(_kDateFormat).format(_birthday!);
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,32 +105,42 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
if (image == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
final skipCrop = image.path.endsWith('.gif');
|
||||
|
||||
if (result == null) return;
|
||||
Uint8List? rawBytes;
|
||||
if (!skipCrop) {
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result =
|
||||
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
rawBytes = await image.readAsBytes();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
|
||||
try {
|
||||
final attachment = await attach.directUploadOne(
|
||||
rawBytes,
|
||||
@@ -133,10 +152,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
|
||||
if (!mounted) return;
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.put(
|
||||
'/cgi/id/users/me/$place',
|
||||
data: {'attachment': attachment.rid},
|
||||
);
|
||||
await sn.client.put('/cgi/id/users/me/$place', data: {'attachment': attachment.rid});
|
||||
|
||||
if (!mounted) return;
|
||||
final ua = context.read<UserProvider>();
|
||||
@@ -166,7 +182,14 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
'description': _descriptionController.value.text,
|
||||
'first_name': _firstNameController.value.text,
|
||||
'last_name': _lastNameController.value.text,
|
||||
'time_zone': _timezoneController.value.text,
|
||||
'gender': _genderController.value.text,
|
||||
'pronouns': _pronounsController.value.text,
|
||||
'location': _locationController.value.text,
|
||||
'birthday': _birthday?.toUtc().toIso8601String(),
|
||||
'links': {
|
||||
for (final link in _links!.where((ele) => ele.$1.isNotEmpty && ele.$2.isNotEmpty)) link.$1: link.$2,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -197,6 +220,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
_firstNameController.dispose();
|
||||
_lastNameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
_timezoneController.dispose();
|
||||
_genderController.dispose();
|
||||
_pronounsController.dispose();
|
||||
_locationController.dispose();
|
||||
_birthdayController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -208,10 +235,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('screenAccountProfileEdit').tr(),
|
||||
),
|
||||
appBar: AppBar(leading: const PageBackButton(), title: Text('screenAccountProfileEdit').tr()),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -230,12 +254,10 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: _banner != null
|
||||
? AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(_banner!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
child:
|
||||
_banner != null
|
||||
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -262,6 +284,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
).padding(horizontal: padding),
|
||||
const Gap(8 + 28),
|
||||
Column(
|
||||
spacing: 4,
|
||||
children: [
|
||||
TextField(
|
||||
readOnly: true,
|
||||
@@ -271,16 +294,13 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
labelText: 'fieldUsername'.tr(),
|
||||
helperText: 'fieldUsernameCannotEditHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _nicknameController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldNickname'.tr(),
|
||||
),
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldNickname'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
@@ -291,6 +311,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldFirstName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
@@ -302,31 +323,165 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldLastName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: TextField(
|
||||
controller: _genderController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldGender'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: TextField(
|
||||
controller: _pronounsController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldPronouns'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
keyboardType: TextInputType.multiline,
|
||||
maxLines: null,
|
||||
minLines: 3,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldDescription'.tr(),
|
||||
),
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldDescription'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _timezoneController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldTimeZone'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
StyledWidget(
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.calendar_month),
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
_timezoneController.text = await FlutterTimezone.getLocalTimezone();
|
||||
},
|
||||
),
|
||||
).padding(top: 6),
|
||||
const Gap(4),
|
||||
StyledWidget(
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.clear),
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () {
|
||||
_timezoneController.clear();
|
||||
},
|
||||
),
|
||||
).padding(top: 6),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: _locationController,
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldLocation'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _birthdayController,
|
||||
readOnly: true,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldBirthday'.tr(),
|
||||
),
|
||||
decoration: InputDecoration(border: const UnderlineInputBorder(), labelText: 'fieldBirthday'.tr()),
|
||||
onTap: () => _selectBirthday(),
|
||||
),
|
||||
if (_links != null)
|
||||
Card(
|
||||
margin: const EdgeInsets.only(top: 16, bottom: 4),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'fieldLinks'.tr(),
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 17),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
icon: const Icon(Symbols.add),
|
||||
onPressed: () {
|
||||
setState(() => _links!.add(('', '')));
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
for (var idx = 0; idx < _links!.length; idx++)
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: TextFormField(
|
||||
initialValue: _links![idx].$1,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'fieldLinkName'.tr(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_links![idx] = (value, _links![idx].$2);
|
||||
},
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Flexible(
|
||||
flex: 1,
|
||||
child: TextFormField(
|
||||
initialValue: _links![idx].$2,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'fieldLinkUrl'.tr(),
|
||||
),
|
||||
onChanged: (value) {
|
||||
_links![idx] = (_links![idx].$1, value);
|
||||
},
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: padding + 8),
|
||||
const Gap(12),
|
||||
@@ -340,6 +495,7 @@ class _ProfileEditScreenState extends State<ProfileEditScreen> {
|
||||
),
|
||||
],
|
||||
).padding(horizontal: padding),
|
||||
Gap(MediaQuery.of(context).padding.bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,8 +20,10 @@ import 'package:surface/types/post.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:surface/theme.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
const Map<String, (String, IconData, Color)> kBadgesMeta = {
|
||||
final Map<String, (String, IconData, Color)> kBadgesMeta = {
|
||||
'company.staff': (
|
||||
'badgeCompanyStaff',
|
||||
Symbols.tools_wrench,
|
||||
@@ -32,6 +34,31 @@ const Map<String, (String, IconData, Color)> kBadgesMeta = {
|
||||
Symbols.flag,
|
||||
Colors.orange,
|
||||
),
|
||||
'site.anniversary': (
|
||||
'badgeSiteAnniversary',
|
||||
Symbols.celebration,
|
||||
Colors.orangeAccent,
|
||||
),
|
||||
'user.birthday': (
|
||||
'badgeUserBirthday',
|
||||
Symbols.cake,
|
||||
Colors.red[400]!,
|
||||
),
|
||||
'community.survey': (
|
||||
'badgeCommunitySurvey',
|
||||
Symbols.star,
|
||||
Colors.yellow[700]!,
|
||||
),
|
||||
'community.verified': (
|
||||
'badgeCommunityVerified',
|
||||
Symbols.verified,
|
||||
Colors.blue,
|
||||
),
|
||||
'community.contributor': (
|
||||
'badgeCommunityContributor',
|
||||
Symbols.thumb_up,
|
||||
Colors.lightGreen,
|
||||
),
|
||||
};
|
||||
|
||||
class UserScreen extends StatefulWidget {
|
||||
@@ -43,7 +70,8 @@ class UserScreen extends StatefulWidget {
|
||||
State<UserScreen> createState() => _UserScreenState();
|
||||
}
|
||||
|
||||
class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateMixin {
|
||||
class _UserScreenState extends State<UserScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final ScrollController _scrollController = ScrollController();
|
||||
|
||||
SnAccount? _account;
|
||||
@@ -64,13 +92,18 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<SnCheckInRecord>> _getCheckInRecords() async {
|
||||
List<SnCheckInRecord>? _records;
|
||||
|
||||
Future<void> _getCheckInRecords() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
|
||||
return List.from(
|
||||
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
|
||||
);
|
||||
final resp =
|
||||
await sn.client.get('/cgi/id/users/${widget.name}/check-in?take=14');
|
||||
setState(() {
|
||||
_records = List.from(
|
||||
resp.data['data']?.map((x) => SnCheckInRecord.fromJson(x)) ?? [],
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
rethrow;
|
||||
@@ -98,7 +131,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
Future<void> _fetchPublishers() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/publishers?user=${widget.name}');
|
||||
final resp =
|
||||
await sn.client.get('/cgi/co/publishers?user=${widget.name}');
|
||||
_publishers = List<SnPublisher>.from(
|
||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||
);
|
||||
@@ -144,7 +178,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
'related': _account!.name,
|
||||
});
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
||||
context.showSnackbar(
|
||||
'userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@@ -160,9 +195,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
|
||||
try {
|
||||
final rel = context.read<SnRelationshipProvider>();
|
||||
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
|
||||
await rel.updateRelationship(
|
||||
_account!.id, 1, _accountRelationship?.permNodes ?? {});
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
||||
context.showSnackbar(
|
||||
'userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'}']));
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@@ -188,12 +225,14 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
double _appBarBlur = 0.0;
|
||||
|
||||
late final _appBarWidth = MediaQuery.of(context).size.width;
|
||||
late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
|
||||
late final _appBarHeight =
|
||||
(_appBarWidth * kBannerAspectRatio).roundToDouble();
|
||||
|
||||
void _updateAppBarBlur() {
|
||||
if (_scrollController.offset > _appBarHeight) return;
|
||||
setState(() {
|
||||
_appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
|
||||
_appBarBlur =
|
||||
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -205,6 +244,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
|
||||
_fetchStatus();
|
||||
_fetchPublishers();
|
||||
_getCheckInRecords();
|
||||
|
||||
try {
|
||||
final rel = context.read<SnRelationshipProvider>();
|
||||
@@ -260,18 +300,20 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
text: TextSpan(children: [
|
||||
TextSpan(
|
||||
text: _account!.nick,
|
||||
style: Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
style:
|
||||
Theme.of(context).textTheme.titleLarge!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: '@${_account!.name}',
|
||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||
color: Colors.white,
|
||||
shadows: labelShadows,
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
@@ -280,14 +322,21 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
? Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
UniversalImage(
|
||||
sn.getAttachmentUrl(_account!.banner),
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
width: _appBarWidth,
|
||||
cacheHeight: imageHeight,
|
||||
cacheWidth: _appBarWidth,
|
||||
),
|
||||
if (_account!.banner.isNotEmpty)
|
||||
UniversalImage(
|
||||
sn.getAttachmentUrl(_account!.banner),
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
width: _appBarWidth,
|
||||
cacheHeight: imageHeight,
|
||||
cacheWidth: _appBarWidth,
|
||||
)
|
||||
else
|
||||
Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -339,7 +388,8 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
PopupMenuButton(
|
||||
padding: EdgeInsets.zero,
|
||||
style: ButtonStyle(
|
||||
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
|
||||
visualDensity:
|
||||
VisualDensity(horizontal: -4, vertical: -4),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
@@ -389,8 +439,12 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
),
|
||||
],
|
||||
).padding(right: 8),
|
||||
const Gap(12),
|
||||
Text(_account!.description).padding(horizontal: 8),
|
||||
if (_account!.profile!.description.isNotEmpty)
|
||||
const Gap(12)
|
||||
else
|
||||
const Gap(8),
|
||||
if (_account!.profile!.description.isNotEmpty)
|
||||
Text(_account!.profile!.description).padding(horizontal: 8),
|
||||
const Gap(4),
|
||||
Card(
|
||||
child: Row(
|
||||
@@ -399,7 +453,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
Symbols.circle,
|
||||
fill: 1,
|
||||
size: 16,
|
||||
color: (_status?.isOnline ?? false) ? Colors.green : Colors.grey,
|
||||
color: (_status?.isOnline ?? false)
|
||||
? Colors.green
|
||||
: Colors.grey,
|
||||
).padding(all: 4),
|
||||
const Gap(8),
|
||||
Text(
|
||||
@@ -409,7 +465,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
: 'accountStatusOffline'.tr()
|
||||
: 'loading'.tr(),
|
||||
),
|
||||
if (_status != null && !_status!.isOnline && _status!.lastSeenAt != null)
|
||||
if (_status != null &&
|
||||
!_status!.isOnline &&
|
||||
_status!.lastSeenAt != null)
|
||||
Text(
|
||||
'accountStatusLastSeen'.tr(args: [
|
||||
_status!.lastSeenAt != null
|
||||
@@ -429,11 +487,15 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
(ele) => Tooltip(
|
||||
richMessage: TextSpan(
|
||||
children: [
|
||||
TextSpan(text: kBadgesMeta[ele.type]?.$1.tr() ?? 'unknown'.tr()),
|
||||
TextSpan(
|
||||
text: kBadgesMeta[ele.type]?.$1.tr() ??
|
||||
'unknown'.tr(),
|
||||
),
|
||||
if (ele.metadata['title'] != null)
|
||||
TextSpan(
|
||||
text: '\n${ele.metadata['title']}',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
@@ -442,8 +504,11 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
kBadgesMeta[ele.type]?.$2 ?? Symbols.question_mark,
|
||||
color: kBadgesMeta[ele.type]?.$3,
|
||||
kBadgesMeta[ele.type]?.$2 ??
|
||||
Symbols.question_mark,
|
||||
color: ele.metadata['color'] != null
|
||||
? HexColor.fromHex(ele.metadata['color']!)
|
||||
: kBadgesMeta[ele.type]?.$3,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
@@ -458,7 +523,9 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
children: [
|
||||
const Icon(Symbols.calendar_add_on),
|
||||
const Gap(8),
|
||||
Text('publisherJoinedAt').tr(args: [DateFormat('y/M/d').format(_account!.createdAt)]),
|
||||
Text('publisherJoinedAt').tr(args: [
|
||||
DateFormat('y/M/d').format(_account!.createdAt)
|
||||
]),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
@@ -475,6 +542,44 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
]),
|
||||
],
|
||||
),
|
||||
if (_account!.profile!.gender.isNotEmpty ||
|
||||
_account!.profile!.pronouns.isNotEmpty)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.wc),
|
||||
const Gap(8),
|
||||
Text(
|
||||
_account!.profile!.gender.isNotEmpty
|
||||
? _account!.profile!.gender
|
||||
: 'unknown'.tr(),
|
||||
),
|
||||
Text(' · ').padding(horizontal: 4),
|
||||
Text(
|
||||
_account!.profile!.pronouns.isNotEmpty
|
||||
? _account!.profile!.pronouns
|
||||
: 'unknown'.tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_account!.profile!.timeZone.isNotEmpty)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.schedule),
|
||||
const Gap(8),
|
||||
Text(_account!.profile!.timeZone),
|
||||
],
|
||||
),
|
||||
if (_account!.profile!.location.isNotEmpty)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.location_on),
|
||||
const Gap(8),
|
||||
Text(_account!.profile!.location),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -491,17 +596,24 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
children: [
|
||||
const Icon(Symbols.star),
|
||||
const Gap(8),
|
||||
Text('Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
|
||||
Text(
|
||||
'Lv${getLevelFromExp(_account?.profile?.experience ?? 0)}'),
|
||||
const Gap(8),
|
||||
Text(calcLevelUpProgressLevel(_account?.profile?.experience ?? 0)).fontSize(11).opacity(0.5),
|
||||
Text(calcLevelUpProgressLevel(
|
||||
_account?.profile?.experience ?? 0))
|
||||
.fontSize(11)
|
||||
.opacity(0.5),
|
||||
const Gap(8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(maxWidth: 160),
|
||||
child: LinearProgressIndicator(
|
||||
value: calcLevelUpProgress(_account?.profile?.experience ?? 0),
|
||||
value: calcLevelUpProgress(
|
||||
_account?.profile?.experience ?? 0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
|
||||
backgroundColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainer,
|
||||
).alignment(Alignment.centerLeft),
|
||||
),
|
||||
],
|
||||
@@ -511,24 +623,46 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
],
|
||||
).padding(all: 16),
|
||||
),
|
||||
if (_account?.profile?.links.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(child: const Divider()),
|
||||
if (_account?.profile?.links.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: _account!.profile!.links.entries.map((ele) {
|
||||
return ListTile(
|
||||
leading: const Icon(Symbols.link),
|
||||
title: Text(ele.key),
|
||||
subtitle: Text(ele.value),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
launchUrlString(ele.value);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(child: const Divider()),
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(
|
||||
child: FutureBuilder<List<SnCheckInRecord>>(
|
||||
future: _getCheckInRecords(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
if (snapshot.data!.length <= 1) {
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (_records == null) return const SizedBox.shrink();
|
||||
if (_records!.length <= 1) {
|
||||
return Text(
|
||||
'accountCheckInNoRecords',
|
||||
textAlign: TextAlign.center,
|
||||
).tr().fontWeight(FontWeight.bold).center().padding(horizontal: 20, vertical: 8);
|
||||
)
|
||||
.tr()
|
||||
.fontWeight(FontWeight.bold)
|
||||
.center()
|
||||
.padding(horizontal: 20, vertical: 8);
|
||||
}
|
||||
final records = snapshot.data!;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 240,
|
||||
child: CheckInRecordChart(records: records),
|
||||
child: CheckInRecordChart(records: _records!),
|
||||
).padding(
|
||||
right: 24,
|
||||
left: 16,
|
||||
@@ -540,45 +674,55 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(child: const Divider()),
|
||||
const SliverGap(12),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('accountBadge').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
width: double.infinity,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final badge in _account?.badges ?? [])
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
kBadgesMeta[badge.type]?.$2 ?? Symbols.question_mark,
|
||||
color: kBadgesMeta[badge.type]?.$3,
|
||||
fill: 1,
|
||||
if (_account?.badges.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('accountBadge')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
SizedBox(
|
||||
height: 80,
|
||||
width: double.infinity,
|
||||
child: ListView(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final badge in _account?.badges ?? [])
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
kBadgesMeta[badge.type]?.$2 ??
|
||||
Symbols.question_mark,
|
||||
color: badge.metadata['color'] != null
|
||||
? HexColor.fromHex(
|
||||
badge.metadata['color']!)
|
||||
: kBadgesMeta[badge.type]?.$3,
|
||||
fill: 1,
|
||||
),
|
||||
title: Text(
|
||||
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
|
||||
).tr(),
|
||||
subtitle: badge.metadata['title'] != null
|
||||
? Text(badge.metadata['title'])
|
||||
: Text(
|
||||
DateFormat('y/M/d')
|
||||
.format(badge.createdAt),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
kBadgesMeta[badge.type]?.$1 ?? 'unknown',
|
||||
).tr(),
|
||||
subtitle: badge.metadata['title'] != null
|
||||
? Text(badge.metadata['title'])
|
||||
: Text(
|
||||
DateFormat('y/M/d').format(badge.createdAt),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverGap(8),
|
||||
SliverToBoxAdapter(child: const Divider()),
|
||||
SliverList.builder(
|
||||
@@ -664,7 +808,8 @@ class CheckInRecordChart extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
getTooltipColor: (_) => Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
getTooltipColor: (_) =>
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
titlesData: FlTitlesData(
|
||||
|
||||
@@ -68,16 +68,19 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
await sn.client.put('/cgi/co/publishers/${widget.name}', data: {
|
||||
'avatar': _avatar,
|
||||
'banner': _banner,
|
||||
'nick': _nickController.text,
|
||||
'name': _nameController.text,
|
||||
'description': _descriptionController.text,
|
||||
});
|
||||
await sn.client.put(
|
||||
'/cgi/co/publishers/${widget.name}',
|
||||
data: {
|
||||
'avatar': _avatar,
|
||||
'banner': _banner,
|
||||
'nick': _nickController.text,
|
||||
'name': _nameController.text,
|
||||
'description': _descriptionController.text,
|
||||
},
|
||||
);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
if(mounted) context.showErrorDialog(err);
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
@@ -97,7 +100,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
_banner = ua.user!.banner;
|
||||
_nickController.text = ua.user!.nick;
|
||||
_nameController.text = ua.user!.name;
|
||||
_descriptionController.text = ua.user!.description;
|
||||
_descriptionController.text = ua.user!.profile!.description;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@@ -108,32 +111,42 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
if (image == null) return;
|
||||
if (!mounted) return;
|
||||
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result = (!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
final skipCrop = image.path.endsWith('.gif');
|
||||
|
||||
if (result == null) return;
|
||||
Uint8List? rawBytes;
|
||||
if (!skipCrop) {
|
||||
final ImageProvider imageProvider = kIsWeb ? NetworkImage(image.path) : FileImage(File(image.path));
|
||||
final aspectRatios =
|
||||
place == 'banner' ? [CropAspectRatio(width: 16, height: 7)] : [CropAspectRatio(width: 1, height: 1)];
|
||||
final result =
|
||||
(!kIsWeb && (Platform.isIOS || Platform.isMacOS))
|
||||
? await showCupertinoImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
)
|
||||
: await showMaterialImageCropper(
|
||||
// ignore: use_build_context_synchronously
|
||||
context,
|
||||
allowedAspectRatios: aspectRatios,
|
||||
imageProvider: imageProvider,
|
||||
);
|
||||
|
||||
if (result == null) return;
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
setState(() => _isBusy = true);
|
||||
rawBytes = await image.readAsBytes();
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
final attach = context.read<SnAttachmentProvider>();
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final rawBytes = (await result.uiImage.toByteData(format: ImageByteFormat.png))!.buffer.asUint8List();
|
||||
|
||||
try {
|
||||
final attachment = await attach.directUploadOne(
|
||||
rawBytes,
|
||||
@@ -178,10 +191,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: PageBackButton(),
|
||||
title: Text('screenAccountPublisherEdit').tr(),
|
||||
),
|
||||
appBar: AppBar(leading: PageBackButton(), title: Text('screenAccountPublisherEdit').tr()),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -199,12 +209,10 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
aspectRatio: 16 / 9,
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: _banner != null
|
||||
? AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(_banner!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
child:
|
||||
_banner != null
|
||||
? AutoResizeUniversalImage(sn.getAttachmentUrl(_banner!), fit: BoxFit.cover)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -242,9 +250,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _nickController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldNickname'.tr(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: 'fieldNickname'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
@@ -252,9 +258,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
controller: _descriptionController,
|
||||
maxLines: null,
|
||||
minLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldDescription'.tr(),
|
||||
),
|
||||
decoration: InputDecoration(labelText: 'fieldDescription'.tr()),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(12),
|
||||
@@ -275,7 +279,7 @@ class _AccountPublisherEditScreenState extends State<AccountPublisherEditScreen>
|
||||
icon: const Icon(Symbols.save),
|
||||
),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 12),
|
||||
),
|
||||
|
||||
@@ -109,7 +109,7 @@ class _PublisherNewPersonalState extends State<_PublisherNewPersonal> {
|
||||
|
||||
_nameController.text = ua.user!.name;
|
||||
_nickController.text = ua.user!.nick;
|
||||
_descriptionController.text = ua.user!.description;
|
||||
_descriptionController.text = ua.user!.profile!.description;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,6 +2,9 @@ import 'package:dismissible_page/dismissible_page.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
@@ -27,9 +30,23 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
||||
bool _isBusy = false;
|
||||
int? _totalCount;
|
||||
|
||||
SnAttachmentBilling? _billing;
|
||||
|
||||
final List<SnAttachment> _attachments = List.empty(growable: true);
|
||||
final List<String> _heroTags = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchBillingStatus() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/uc/billing');
|
||||
final out = SnAttachmentBilling.fromJson(resp.data);
|
||||
setState(() => _billing = out);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchAttachments() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
@@ -62,6 +79,7 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchBillingStatus();
|
||||
_fetchAttachments();
|
||||
_scrollController.addListener(() {
|
||||
if (_scrollController.position.atEdge) {
|
||||
@@ -91,6 +109,48 @@ class _AlbumScreenState extends State<AlbumScreen> {
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenAlbum').tr(),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Card(
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: CircularProgressIndicator(
|
||||
value: _billing?.includedRatio ?? 0,
|
||||
strokeWidth: 8,
|
||||
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
).padding(all: 12),
|
||||
const Gap(24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('attachmentBillingUploaded').tr().bold(),
|
||||
Text(
|
||||
(_billing?.currentBytes ?? 0).formatBytes(decimals: 4),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
Text('attachmentBillingDiscount').tr().bold(),
|
||||
Text(
|
||||
'${(_billing?.discountFileSize ?? 0).formatBytes(decimals: 2)} · ${((_billing?.includedRatio ?? 0) * 100).toStringAsFixed(2)}%',
|
||||
style: GoogleFonts.robotoMono(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Tooltip(
|
||||
message: 'attachmentBillingHint'.tr(),
|
||||
child: IconButton(
|
||||
icon: const Icon(Symbols.info),
|
||||
onPressed: () {},
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 8),
|
||||
),
|
||||
),
|
||||
SliverMasonryGrid.extent(
|
||||
childCount: _attachments.length,
|
||||
maxCrossAxisExtent: 320,
|
||||
|
||||
@@ -3,23 +3,26 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:responsive_framework/responsive_framework.dart';
|
||||
import 'package:surface/providers/channel.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/screens/chat/room.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/account/account_select.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_background.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/unauthorized_hint.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../providers/sn_network.dart';
|
||||
import '../providers/userinfo.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@@ -34,8 +37,19 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
List<SnChannel>? _channels;
|
||||
Map<int, SnChatMessage>? _lastMessages;
|
||||
Map<int, int>? _unreadCounts;
|
||||
|
||||
void _refreshChannels() {
|
||||
Future<void> _fetchWhatsNew() async {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/im/whats-new');
|
||||
if (resp.data == null) return;
|
||||
final List<dynamic> out = resp.data;
|
||||
setState(() {
|
||||
_unreadCounts = {for (var v in out) v['channel_id']: v['count']};
|
||||
});
|
||||
}
|
||||
|
||||
void _refreshChannels({bool noRemote = false}) {
|
||||
final ua = context.read<UserProvider>();
|
||||
if (!ua.isAuthorized) {
|
||||
setState(() => _isBusy = false);
|
||||
@@ -43,12 +57,15 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
final chan = context.read<ChatChannelProvider>();
|
||||
chan.fetchChannels().listen((channels) async {
|
||||
chan.fetchChannels(noRemote: noRemote).listen((channels) async {
|
||||
final lastMessages = await chan.getLastMessages(channels);
|
||||
_lastMessages = {for (final val in lastMessages) val.channelId: val};
|
||||
channels.sort((a, b) {
|
||||
if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
|
||||
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
|
||||
if (_lastMessages!.containsKey(a.id) &&
|
||||
_lastMessages!.containsKey(b.id)) {
|
||||
return _lastMessages![b.id]!
|
||||
.createdAt
|
||||
.compareTo(_lastMessages![a.id]!.createdAt);
|
||||
}
|
||||
if (_lastMessages!.containsKey(a.id)) return -1;
|
||||
if (_lastMessages!.containsKey(b.id)) return 1;
|
||||
@@ -57,18 +74,20 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
if (!mounted) return;
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final idSet = <int>{};
|
||||
for (final channel in channels) {
|
||||
if (channel.type == 1) {
|
||||
await ud.listAccount(
|
||||
idSet.addAll(
|
||||
channel.members
|
||||
?.cast<SnChannelMember?>()
|
||||
.map((ele) => ele?.accountId)
|
||||
.where((ele) => ele != null)
|
||||
.toSet() ??
|
||||
{},
|
||||
.cast<int>() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
if (idSet.isNotEmpty) await ud.listAccount(idSet);
|
||||
|
||||
if (mounted) setState(() => _channels = channels);
|
||||
})
|
||||
@@ -86,7 +105,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
void _newDirectMessage() async {
|
||||
final user = await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => AccountSelect(title: 'channelNewDirectMessage'.tr()),
|
||||
builder: (context) =>
|
||||
AccountSelect(title: 'channelNewDirectMessage'.tr()),
|
||||
);
|
||||
if (user == null) return;
|
||||
if (!mounted) return;
|
||||
@@ -98,7 +118,8 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
await sn.client.post('/cgi/im/channels/global/dm', data: {
|
||||
'alias': uuid.v4().replaceAll('-', '').substring(0, 12),
|
||||
'name': 'DM',
|
||||
'description': 'A direct message channel between @${ua.user?.name} and @${user.name}',
|
||||
'description':
|
||||
'A direct message channel between @${ua.user?.name} and @${user.name}',
|
||||
'related_user': user.id,
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
@@ -109,15 +130,39 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
SnChannel? _focusChannel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_refreshChannels();
|
||||
_fetchWhatsNew();
|
||||
}
|
||||
|
||||
void _onTapChannel(SnChannel channel) {
|
||||
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
|
||||
|
||||
if (doExpand) {
|
||||
setState(() => _focusChannel = channel);
|
||||
return;
|
||||
}
|
||||
GoRouter.of(context).pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': channel.realm?.alias ?? 'global',
|
||||
'alias': channel.alias,
|
||||
},
|
||||
).then((value) {
|
||||
if (mounted) {
|
||||
_unreadCounts?[channel.id] = 0;
|
||||
setState(() => _unreadCounts?[channel.id] = 0);
|
||||
_refreshChannels(noRemote: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
|
||||
if (!ua.isAuthorized) {
|
||||
@@ -132,7 +177,10 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
return AppScaffold(
|
||||
final doExpand = ResponsiveBreakpoints.of(context).largerOrEqualTo(DESKTOP);
|
||||
|
||||
final chatList = AppScaffold(
|
||||
noBackground: doExpand,
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenChat').tr(),
|
||||
@@ -144,21 +192,26 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
type: ExpandableFabType.up,
|
||||
childrenAnimation: ExpandableFabAnimation.none,
|
||||
overlayStyle: ExpandableFabOverlayStyle(
|
||||
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withAlpha((255 * 0.5).round()),
|
||||
),
|
||||
openButtonBuilder: RotateFloatingActionButtonBuilder(
|
||||
child: const Icon(Symbols.add, size: 28),
|
||||
fabSize: ExpandableFabSize.regular,
|
||||
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
shape: const CircleBorder(),
|
||||
foregroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
),
|
||||
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
||||
child: const Icon(Symbols.close, size: 28),
|
||||
fabSize: ExpandableFabSize.regular,
|
||||
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
shape: const CircleBorder(),
|
||||
foregroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
),
|
||||
children: [
|
||||
Row(
|
||||
@@ -200,80 +253,27 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => Future.sync(() => _refreshChannels()),
|
||||
onRefresh: () => Future.wait([
|
||||
Future.sync(() => _refreshChannels()),
|
||||
_fetchWhatsNew(),
|
||||
]),
|
||||
child: ListView.builder(
|
||||
itemCount: _channels?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final channel = _channels![idx];
|
||||
final lastMessage = _lastMessages?[channel.id];
|
||||
|
||||
if (channel.type == 1) {
|
||||
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
|
||||
(ele) => ele?.accountId != ua.user?.id,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
|
||||
subtitle: lastMessage != null
|
||||
? Text(
|
||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
'channelDirectMessageDescription'.tr(args: [
|
||||
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
|
||||
]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: AccountImage(
|
||||
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': channel.realm?.alias ?? 'global',
|
||||
'alias': channel.alias,
|
||||
},
|
||||
).then((value) {
|
||||
if (mounted) _refreshChannels();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
title: Text(channel.name),
|
||||
subtitle: lastMessage != null
|
||||
? Text(
|
||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
channel.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: AccountImage(
|
||||
content: null,
|
||||
fallbackWidget: const Icon(Symbols.chat, size: 20),
|
||||
),
|
||||
return _ChatChannelEntry(
|
||||
channel: channel,
|
||||
lastMessage: lastMessage,
|
||||
unreadCount: _unreadCounts?[channel.id],
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': channel.realm?.alias ?? 'global',
|
||||
'alias': channel.alias,
|
||||
},
|
||||
).then((value) {
|
||||
if (value == true) _refreshChannels();
|
||||
});
|
||||
if (doExpand) {
|
||||
_unreadCounts?[channel.id] = 0;
|
||||
setState(() => _focusChannel = channel);
|
||||
return;
|
||||
}
|
||||
_onTapChannel(channel);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -284,5 +284,124 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (doExpand) {
|
||||
return AppBackground(
|
||||
isRoot: true,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: 340, child: chatList),
|
||||
const VerticalDivider(width: 1),
|
||||
if (_focusChannel != null)
|
||||
Expanded(
|
||||
child: ChatRoomScreen(
|
||||
key: ValueKey(_focusChannel!.id),
|
||||
scope: _focusChannel!.realm?.alias ?? 'global',
|
||||
alias: _focusChannel!.alias,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return chatList;
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatChannelEntry extends StatelessWidget {
|
||||
final SnChannel channel;
|
||||
final int? unreadCount;
|
||||
final SnChatMessage? lastMessage;
|
||||
final Function? onTap;
|
||||
const _ChatChannelEntry({
|
||||
required this.channel,
|
||||
this.unreadCount,
|
||||
this.lastMessage,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
|
||||
final otherMember = channel.type == 1
|
||||
? channel.members?.cast<SnChannelMember?>().firstWhere(
|
||||
(ele) => ele?.accountId != ua.user?.id,
|
||||
orElse: () => null,
|
||||
)
|
||||
: null;
|
||||
|
||||
final title = otherMember != null
|
||||
? ud.getFromCache(otherMember.accountId)?.nick ?? channel.name
|
||||
: channel.name;
|
||||
|
||||
return ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(title)),
|
||||
const Gap(8),
|
||||
if (unreadCount != null && unreadCount! > 0)
|
||||
Badge(
|
||||
label: Text(unreadCount.toString()),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: lastMessage != null
|
||||
? Row(
|
||||
children: [
|
||||
Badge(
|
||||
label: Text(
|
||||
ud.getFromCache(lastMessage!.sender.accountId)?.nick ??
|
||||
'unknown'.tr()),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
textColor: Theme.of(context).colorScheme.onPrimary,
|
||||
),
|
||||
const Gap(6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
lastMessage!.body['algorithm'] == 'plain'
|
||||
? lastMessage!.body['text'] ??
|
||||
'messageUnablePreview'.tr()
|
||||
: 'messageUnablePreviewEncrypted'.tr(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: lastMessage!.body['algorithm'] != 'plain' ||
|
||||
lastMessage!.body['text'] == null
|
||||
? TextStyle(fontStyle: FontStyle.italic)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
Text(
|
||||
DateFormat(
|
||||
lastMessage!.createdAt.toLocal().day == DateTime.now().day
|
||||
? 'HH:mm'
|
||||
: lastMessage!.createdAt.toLocal().year ==
|
||||
DateTime.now().year
|
||||
? 'MM/dd'
|
||||
: 'yy/MM/dd',
|
||||
).format(lastMessage!.createdAt.toLocal()),
|
||||
style: GoogleFonts.robotoMono(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Text(
|
||||
channel.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: AccountImage(
|
||||
content: otherMember != null
|
||||
? ud.getFromCache(otherMember.accountId)?.avatar
|
||||
: channel.realm?.avatar,
|
||||
fallbackWidget: const Icon(Symbols.chat, size: 20),
|
||||
),
|
||||
onTap: () => onTap?.call(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,10 +57,10 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/im/channels/${_channel!.keyPath}/members/me');
|
||||
_profile = SnChannelMember.fromJson(resp.data);
|
||||
_notifyLevel = _profile!.notify;
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
final resp = await ct.getChannelProfile(_channel!);
|
||||
_profile = resp;
|
||||
_notifyLevel = resp.notify;
|
||||
if (!mounted) return;
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
await ud.getAccount(_profile!.accountId);
|
||||
@@ -102,10 +102,12 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete(
|
||||
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.id}/members/me',
|
||||
'/cgi/im/channels/${_channel!.realm?.alias ?? 'global'}/${_channel!.alias}/me',
|
||||
);
|
||||
await ct.removeLocalChannel(_channel!);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, false);
|
||||
} catch (err) {
|
||||
@@ -129,12 +131,15 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
setState(() => _isUpdatingNotifyLevel = true);
|
||||
|
||||
try {
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.put(
|
||||
final resp = await sn.client.put(
|
||||
'/cgi/im/channels/${_channel!.keyPath}/members/me/notify',
|
||||
data: {'notify_level': value},
|
||||
);
|
||||
_profile = SnChannelMember.fromJson(resp.data);
|
||||
_notifyLevel = value;
|
||||
await ct.updateChannelProfile(_profile!);
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('channelNotifyLevelApplied'.tr());
|
||||
} catch (err) {
|
||||
@@ -245,7 +250,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('channelDetailPersonalRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('channelDetailPersonalRegion')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.notifications),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
@@ -284,14 +293,14 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
),
|
||||
ListTile(
|
||||
leading: AccountImage(
|
||||
content: ud.getAccountFromCache(_profile!.accountId)?.avatar,
|
||||
content: ud.getFromCache(_profile!.accountId)?.avatar,
|
||||
radius: 18,
|
||||
),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('channelEditProfile').tr(),
|
||||
subtitle: Text(
|
||||
(_profile?.nick?.isEmpty ?? true)
|
||||
? ud.getAccountFromCache(_profile!.accountId)!.nick
|
||||
? ud.getFromCache(_profile!.accountId)!.nick
|
||||
: _profile!.nick!,
|
||||
),
|
||||
contentPadding: const EdgeInsets.only(left: 20, right: 20),
|
||||
@@ -303,7 +312,8 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
title: Text('channelActionLeave').tr(),
|
||||
subtitle: Text('channelActionLeaveDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: _leaveChannel,
|
||||
),
|
||||
],
|
||||
@@ -311,7 +321,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('channelDetailMemberRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('channelDetailMemberRegion')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.group),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
@@ -333,7 +347,11 @@ class _ChannelDetailScreenState extends State<ChannelDetailScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('channelDetailAdminRegion').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('channelDetailAdminRegion')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.edit),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
@@ -379,10 +397,12 @@ class _ChannelProfileDetailDialog extends StatefulWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ChannelProfileDetailDialog> createState() => _ChannelProfileDetailDialogState();
|
||||
State<_ChannelProfileDetailDialog> createState() =>
|
||||
_ChannelProfileDetailDialogState();
|
||||
}
|
||||
|
||||
class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog> {
|
||||
class _ChannelProfileDetailDialogState
|
||||
extends State<_ChannelProfileDetailDialog> {
|
||||
bool _isBusy = false;
|
||||
|
||||
final TextEditingController _nickController = TextEditingController();
|
||||
@@ -391,11 +411,14 @@ class _ChannelProfileDetailDialogState extends State<_ChannelProfileDetailDialog
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final ct = context.read<ChatChannelProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.put(
|
||||
final resp = await sn.client.put(
|
||||
'/cgi/im/channels/${widget.channel.keyPath}/members/me',
|
||||
data: {'nick': _nickController.text},
|
||||
);
|
||||
final out = SnChannelMember.fromJson(resp.data);
|
||||
await ct.updateChannelProfile(out);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
@@ -457,7 +480,8 @@ class _ChannelMemberListWidget extends StatefulWidget {
|
||||
const _ChannelMemberListWidget({required this.channel});
|
||||
|
||||
@override
|
||||
State<_ChannelMemberListWidget> createState() => _ChannelMemberListWidgetState();
|
||||
State<_ChannelMemberListWidget> createState() =>
|
||||
_ChannelMemberListWidgetState();
|
||||
}
|
||||
|
||||
class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
@@ -472,10 +496,12 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
try {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/im/channels/${widget.channel.keyPath}/members', queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _members.length,
|
||||
});
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/im/channels/${widget.channel.keyPath}/members',
|
||||
queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _members.length,
|
||||
});
|
||||
final out = List<SnChannelMember>.from(
|
||||
resp.data['data']?.map((e) => SnChannelMember.fromJson(e)) ?? [],
|
||||
);
|
||||
@@ -533,7 +559,9 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
children: [
|
||||
const Icon(Symbols.group, size: 24),
|
||||
const Gap(16),
|
||||
Text('channelMemberManage').tr().textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
Text('channelMemberManage')
|
||||
.tr()
|
||||
.textStyle(Theme.of(context).textTheme.titleLarge!),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
Expanded(
|
||||
@@ -544,7 +572,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
},
|
||||
child: InfiniteList(
|
||||
itemCount: _members.length,
|
||||
hasReachedMax: _totalCount != null && _members.length >= _totalCount!,
|
||||
hasReachedMax:
|
||||
_totalCount != null && _members.length >= _totalCount!,
|
||||
isLoading: _isBusy,
|
||||
onFetchData: _fetchMembers,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -552,10 +581,10 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
||||
leading: AccountImage(
|
||||
content: ud.getAccountFromCache(member.accountId)?.avatar,
|
||||
content: ud.getFromCache(member.accountId)?.avatar,
|
||||
),
|
||||
title: Text(
|
||||
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
||||
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
||||
),
|
||||
subtitle: Text(member.nick ?? 'unknown'.tr()),
|
||||
trailing: SizedBox(
|
||||
@@ -565,7 +594,8 @@ class _ChannelMemberListWidgetState extends State<_ChannelMemberListWidget> {
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _isUpdating ? null : () => _deleteMember(member),
|
||||
onPressed:
|
||||
_isUpdating ? null : () => _deleteMember(member),
|
||||
icon: const Icon(Symbols.person_remove),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -95,6 +95,10 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
'description': _descriptionController.text,
|
||||
'is_public': _isPublic,
|
||||
'is_community': _isCommunity,
|
||||
if (_editingChannel != null && _belongToRealm == null)
|
||||
'new_belongs_realm': 'global'
|
||||
else if (_editingChannel != null && _belongToRealm?.id != _editingChannel?.realm?.id)
|
||||
'new_belongs_realm': _belongToRealm!.alias,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -171,7 +175,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
items: [
|
||||
...(_realms?.map(
|
||||
(SnRealm item) => DropdownMenuItem<SnRealm>(
|
||||
enabled: _editingChannel == null || _editingChannel?.realmId == item.id,
|
||||
value: item,
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -204,7 +207,6 @@ class _ChatManageScreenState extends State<ChatManageScreen> {
|
||||
) ??
|
||||
[]),
|
||||
DropdownMenuItem<SnRealm>(
|
||||
enabled: _editingChannel == null,
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@@ -13,11 +14,13 @@ import 'package:surface/controllers/chat_message_controller.dart';
|
||||
import 'package:surface/controllers/post_write_controller.dart';
|
||||
import 'package:surface/providers/channel.dart';
|
||||
import 'package:surface/providers/chat_call.dart';
|
||||
import 'package:surface/providers/notification.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/providers/websocket.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/websocket.dart';
|
||||
import 'package:surface/widgets/chat/call/call_prejoin.dart';
|
||||
import 'package:surface/widgets/chat/chat_message.dart';
|
||||
import 'package:surface/widgets/chat/chat_message_input.dart';
|
||||
@@ -39,7 +42,8 @@ class ChatRoomScreen extends StatefulWidget {
|
||||
final String alias;
|
||||
final ChatRoomScreenExtra? extra;
|
||||
|
||||
const ChatRoomScreen({super.key, required this.scope, required this.alias, this.extra});
|
||||
const ChatRoomScreen(
|
||||
{super.key, required this.scope, required this.alias, this.extra});
|
||||
|
||||
@override
|
||||
State<ChatRoomScreen> createState() => _ChatRoomScreenState();
|
||||
@@ -56,8 +60,14 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
final GlobalKey<ChatMessageInputState> _inputGlobalKey = GlobalKey();
|
||||
late final ChatMessageController _messageController;
|
||||
|
||||
late final NotificationProvider _nty = context.read<NotificationProvider>();
|
||||
late final WebSocketProvider _ws = context.read<WebSocketProvider>();
|
||||
|
||||
bool _isEncrypted = false;
|
||||
|
||||
StreamSubscription? _wsSubscription;
|
||||
|
||||
// TODO fetch user identity and ask them to join the channel or not
|
||||
Future<void> _fetchChannel() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
@@ -82,6 +92,20 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
orElse: () => null,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
_nty.skippableNotifyChannel = _channel!.id;
|
||||
final ws = context.read<WebSocketProvider>();
|
||||
if (_channel != null) {
|
||||
ws.conn?.sink.add(
|
||||
jsonEncode(WebSocketPackage(
|
||||
method: 'events.subscribe',
|
||||
endpoint: 'im',
|
||||
payload: {
|
||||
'channel_id': _channel!.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@@ -191,10 +215,12 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
log('[ChatInput] Setting initial text and attachments...');
|
||||
if (widget.extra!.initialText != null) {
|
||||
_inputGlobalKey.currentState?.setInitialText(widget.extra!.initialText!);
|
||||
_inputGlobalKey.currentState
|
||||
?.setInitialText(widget.extra!.initialText!);
|
||||
}
|
||||
if (widget.extra!.initialAttachments != null) {
|
||||
_inputGlobalKey.currentState?.setInitialAttachments(widget.extra!.initialAttachments!);
|
||||
_inputGlobalKey.currentState
|
||||
?.setInitialAttachments(widget.extra!.initialAttachments!);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -205,8 +231,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
]);
|
||||
});
|
||||
|
||||
final ws = context.read<WebSocketProvider>();
|
||||
_wsSubscription = ws.pk.stream.listen((event) {
|
||||
_wsSubscription = _ws.pk.stream.listen((event) {
|
||||
switch (event.method) {
|
||||
case 'calls.new':
|
||||
final payload = SnChatCall.fromJson(event.payload!);
|
||||
@@ -228,6 +253,18 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
void dispose() {
|
||||
_wsSubscription?.cancel();
|
||||
_messageController.dispose();
|
||||
_nty.skippableNotifyChannel = null;
|
||||
if (_channel != null) {
|
||||
_ws.conn?.sink.add(
|
||||
jsonEncode(WebSocketPackage(
|
||||
method: 'events.unsubscribe',
|
||||
endpoint: 'im',
|
||||
payload: {
|
||||
'channel_id': _channel!.id,
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -240,12 +277,23 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_channel?.type == 1
|
||||
? ud.getAccountFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
|
||||
? ud.getFromCache(_otherMember?.accountId)?.nick ?? _channel!.name
|
||||
: _channel?.name ?? 'loading'.tr(),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _ongoingCall == null ? const Icon(Symbols.call) : const Icon(Symbols.call_end),
|
||||
onPressed: () {
|
||||
setState(() => _isEncrypted = !_isEncrypted);
|
||||
_inputGlobalKey.currentState?.setEncrypted(_isEncrypted);
|
||||
},
|
||||
icon: _isEncrypted
|
||||
? const Icon(Symbols.lock)
|
||||
: const Icon(Symbols.no_encryption),
|
||||
),
|
||||
IconButton(
|
||||
icon: _ongoingCall == null
|
||||
? const Icon(Symbols.call)
|
||||
: const Icon(Symbols.call_end),
|
||||
onPressed: _isCalling
|
||||
? null
|
||||
: _ongoingCall == null
|
||||
@@ -275,7 +323,9 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
LoadingIndicator(
|
||||
isActive: _isBusy || _messageController.isAggressiveLoading,
|
||||
),
|
||||
SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: MaterialBanner(
|
||||
@@ -295,14 +345,14 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
.height(_ongoingCall != null ? 54 : 0, animate: true)
|
||||
.animate(const Duration(milliseconds: 300), Curves.fastLinearToSlowEaseIn),
|
||||
).height(_ongoingCall != null ? 54 : 0, animate: true).animate(
|
||||
const Duration(milliseconds: 300),
|
||||
Curves.fastLinearToSlowEaseIn),
|
||||
if (_messageController.isPending)
|
||||
Expanded(
|
||||
child: const CircularProgressIndicator().center(),
|
||||
),
|
||||
if (!_messageController.isPending)
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: InfiniteList(
|
||||
reverse: true,
|
||||
@@ -315,6 +365,7 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
},
|
||||
itemBuilder: (context, idx) {
|
||||
final message = _messageController.messages[idx];
|
||||
_messageController.readEvent(message.id);
|
||||
|
||||
bool canMerge = false, canMergePrevious = false;
|
||||
if (idx > 0) {
|
||||
@@ -336,7 +387,8 @@ class _ChatRoomScreenState extends State<ChatRoomScreen> {
|
||||
data: message,
|
||||
isMerged: canMerge,
|
||||
hasMerged: canMergePrevious,
|
||||
isPending: _messageController.unconfirmedMessages.contains(message.uuid),
|
||||
isPending: _messageController.unconfirmedMessages
|
||||
.contains(message.uuid),
|
||||
onReply: (value) {
|
||||
_inputGlobalKey.currentState?.setReply(value);
|
||||
},
|
||||
|
||||
@@ -8,13 +8,19 @@ import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_realm.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
const kPostChannels = ['Global', 'Friends', 'Following'];
|
||||
const kPostChannelIcons = [Symbols.globe, Symbols.group, Symbols.subscriptions];
|
||||
|
||||
const Map<String, IconData> kCategoryIcons = {
|
||||
'technology': Symbols.tools_wrench,
|
||||
'gaming': Symbols.gamepad,
|
||||
@@ -35,61 +41,104 @@ class ExploreScreen extends StatefulWidget {
|
||||
State<ExploreScreen> createState() => _ExploreScreenState();
|
||||
}
|
||||
|
||||
class _ExploreScreenState extends State<ExploreScreen> {
|
||||
class _ExploreScreenState extends State<ExploreScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _tabController = TabController(
|
||||
length: kPostChannels.length,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
final _fabKey = GlobalKey<ExpandableFabState>();
|
||||
final _listKey = GlobalKey<_PostListWidgetState>();
|
||||
|
||||
bool _isBusy = true;
|
||||
bool _showCategories = false;
|
||||
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
final List<SnPostCategory> _categories = List.empty(growable: true);
|
||||
int? _postCount;
|
||||
|
||||
String? _selectedCategory;
|
||||
|
||||
Future<void> _fetchCategories() async {
|
||||
_categories.clear();
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/categories?take=100');
|
||||
_categories.addAll(resp.data.map((e) => SnPostCategory.fromJson(e)).cast<SnPostCategory>() ?? []);
|
||||
setState(() {
|
||||
_categories.addAll(resp.data
|
||||
.map((e) => SnPostCategory.fromJson(e))
|
||||
.cast<SnPostCategory>() ??
|
||||
[]);
|
||||
});
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
||||
final List<SnRealm> _realms = List.empty(growable: true);
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final result = await pt.listPosts(
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
categories: _selectedCategory != null ? [_selectedCategory!] : null,
|
||||
);
|
||||
final out = result.$1;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
_postCount = result.$2;
|
||||
_posts.addAll(out);
|
||||
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
Future<void> _fetchRealms() async {
|
||||
try {
|
||||
final rels = context.read<SnRealmProvider>();
|
||||
final out = await rels.listAvailableRealms();
|
||||
setState(() {
|
||||
_realms.addAll(out);
|
||||
});
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshPosts() {
|
||||
_postCount = null;
|
||||
_posts.clear();
|
||||
return _fetchPosts();
|
||||
void _toggleShowCategories() {
|
||||
_showCategories = !_showCategories;
|
||||
if (_showCategories) {
|
||||
_tabController = TabController(length: _categories.length, vsync: this);
|
||||
} else {
|
||||
_tabController = TabController(length: kPostChannels.length, vsync: this);
|
||||
}
|
||||
_tabListen();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void _tabListen() {
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) {
|
||||
if (_showCategories) {
|
||||
_listKey.currentState?.setCategory(_categories[_tabController.index]);
|
||||
_listKey.currentState?.refreshPosts();
|
||||
return;
|
||||
}
|
||||
switch (_tabController.index) {
|
||||
case 0:
|
||||
case 3:
|
||||
_listKey.currentState?.setChannel(null);
|
||||
break;
|
||||
case 1:
|
||||
_listKey.currentState?.setChannel('friends');
|
||||
break;
|
||||
case 2:
|
||||
_listKey.currentState?.setChannel('following');
|
||||
break;
|
||||
}
|
||||
_listKey.currentState?.refreshPosts();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPosts();
|
||||
_tabListen();
|
||||
_fetchCategories();
|
||||
_fetchRealms();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> refreshPosts() async {
|
||||
await _listKey.currentState?.refreshPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -102,181 +151,381 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
type: ExpandableFabType.up,
|
||||
childrenAnimation: ExpandableFabAnimation.none,
|
||||
overlayStyle: ExpandableFabOverlayStyle(
|
||||
color: Theme.of(context).colorScheme.surface.withAlpha((255 * 0.5).round()),
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surface
|
||||
.withAlpha((255 * 0.5).round()),
|
||||
),
|
||||
openButtonBuilder: RotateFloatingActionButtonBuilder(
|
||||
child: const Icon(Symbols.add, size: 28),
|
||||
fabSize: ExpandableFabSize.regular,
|
||||
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
shape: const CircleBorder(),
|
||||
foregroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
),
|
||||
closeButtonBuilder: DefaultFloatingActionButtonBuilder(
|
||||
child: const Icon(Symbols.close, size: 28),
|
||||
fabSize: ExpandableFabSize.regular,
|
||||
foregroundColor: Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor: Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
shape: const CircleBorder(),
|
||||
foregroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.foregroundColor,
|
||||
backgroundColor:
|
||||
Theme.of(context).floatingActionButtonTheme.backgroundColor,
|
||||
),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text('writePostTypeStory').tr(),
|
||||
Text('writePost').tr(),
|
||||
const Gap(20),
|
||||
FloatingActionButton(
|
||||
heroTag: null,
|
||||
tooltip: 'writePostTypeStory'.tr(),
|
||||
tooltip: 'writePost'.tr(),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
||||
'mode': 'stories',
|
||||
}).then((value) {
|
||||
GoRouter.of(context).pushNamed('postEditor').then((value) {
|
||||
if (value == true) {
|
||||
_refreshPosts();
|
||||
refreshPosts();
|
||||
}
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
},
|
||||
child: const Icon(Symbols.post_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text('writePostTypeArticle').tr(),
|
||||
const Gap(20),
|
||||
FloatingActionButton(
|
||||
heroTag: null,
|
||||
tooltip: 'writePostTypeArticle'.tr(),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
||||
'mode': 'articles',
|
||||
}).then((value) {
|
||||
if (value == true) {
|
||||
_refreshPosts();
|
||||
}
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
},
|
||||
child: const Icon(Symbols.news),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text('writePostTypeQuestion').tr(),
|
||||
const Gap(20),
|
||||
FloatingActionButton(
|
||||
heroTag: null,
|
||||
tooltip: 'writePostTypeQuestion'.tr(),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
||||
'mode': 'questions',
|
||||
}).then((value) {
|
||||
if (value == true) {
|
||||
_refreshPosts();
|
||||
}
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
},
|
||||
child: const Icon(Symbols.question_answer),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Text('writePostTypeVideo').tr(),
|
||||
const Gap(20),
|
||||
FloatingActionButton(
|
||||
heroTag: null,
|
||||
tooltip: 'writePostTypeVideo'.tr(),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('postEditor', pathParameters: {
|
||||
'mode': 'videos',
|
||||
}).then((value) {
|
||||
if (value == true) {
|
||||
_refreshPosts();
|
||||
}
|
||||
});
|
||||
_fabKey.currentState!.toggle();
|
||||
},
|
||||
child: const Icon(Symbols.video_call),
|
||||
child: const Icon(Symbols.edit),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
displacement: 40 + MediaQuery.of(context).padding.top,
|
||||
onRefresh: () => _refreshPosts(),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenExplore').tr(),
|
||||
floating: true,
|
||||
snap: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.search),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('postSearch');
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(50),
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: _categories.map((ele) {
|
||||
return StyledWidget(ChoiceChip(
|
||||
avatar: Icon(kCategoryIcons[ele.alias] ?? Symbols.question_mark),
|
||||
label: Text(
|
||||
'postCategory${ele.alias.capitalize()}'.trExists()
|
||||
? 'postCategory${ele.alias.capitalize()}'.tr()
|
||||
: ele.name,
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
return [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: SliverAppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
titleSpacing: 0,
|
||||
title: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: _listKey.currentState?.realm != null
|
||||
? AccountImage(
|
||||
content: _listKey.currentState!.realm!.avatar,
|
||||
radius: 14,
|
||||
)
|
||||
: const Icon(Symbols.group),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _PostListRealmPopup(
|
||||
realms: _realms,
|
||||
onUpdate: (realm) {
|
||||
_listKey.currentState?.setRealm(realm);
|
||||
_listKey.currentState?.refreshPosts();
|
||||
Future.delayed(const Duration(milliseconds: 100),
|
||||
() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
selected: _selectedCategory == ele.alias,
|
||||
onSelected: (value) {
|
||||
_selectedCategory = value ? ele.alias : null;
|
||||
_refreshPosts();
|
||||
},
|
||||
)).padding(horizontal: 4);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text('screenExplore').tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floating: true,
|
||||
snap: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.category),
|
||||
style: _showCategories
|
||||
? ButtonStyle(
|
||||
foregroundColor: WidgetStateProperty.all(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
backgroundColor: MaterialStateProperty.all(
|
||||
Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onPressed: () {
|
||||
_toggleShowCategories();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.search),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('postSearch');
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: TabBar(
|
||||
isScrollable: _showCategories,
|
||||
controller: _tabController,
|
||||
tabs: _showCategories
|
||||
? [
|
||||
for (final category in _categories)
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
kCategoryIcons[category.alias] ??
|
||||
Symbols.question_mark,
|
||||
color: Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor!),
|
||||
const Gap(8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'postCategory${category.alias.capitalize()}'
|
||||
.trExists()
|
||||
? 'postCategory${category.alias.capitalize()}'
|
||||
.tr()
|
||||
: category.name,
|
||||
maxLines: 1,
|
||||
).textColor(Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor!),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
for (final channel in kPostChannels)
|
||||
Tab(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
kPostChannelIcons[
|
||||
kPostChannels.indexOf(channel)],
|
||||
size: 20,
|
||||
color: Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor,
|
||||
),
|
||||
const Gap(8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
'postChannel$channel',
|
||||
maxLines: 1,
|
||||
).tr().textColor(Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SliverGap(12),
|
||||
SliverInfiniteList(
|
||||
itemCount: _posts.length,
|
||||
isLoading: _isBusy,
|
||||
centerLoading: true,
|
||||
hasReachedMax: _postCount != null && _posts.length >= _postCount!,
|
||||
onFetchData: _fetchPosts,
|
||||
itemBuilder: (context, idx) {
|
||||
return OpenablePostItem(
|
||||
data: _posts[idx],
|
||||
maxWidth: 640,
|
||||
onChanged: (data) {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
_refreshPosts();
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(8),
|
||||
),
|
||||
],
|
||||
];
|
||||
},
|
||||
body: _PostListWidget(
|
||||
key: _listKey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostListWidget extends StatefulWidget {
|
||||
const _PostListWidget({super.key});
|
||||
|
||||
@override
|
||||
State<_PostListWidget> createState() => _PostListWidgetState();
|
||||
}
|
||||
|
||||
class _PostListWidgetState extends State<_PostListWidget> {
|
||||
bool _isBusy = false;
|
||||
|
||||
SnRealm? get realm => _selectedRealm;
|
||||
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
SnRealm? _selectedRealm;
|
||||
String? _selectedChannel;
|
||||
SnPostCategory? _selectedCategory;
|
||||
int? _postCount;
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
if (_postCount != null && _posts.length >= _postCount!) return;
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final result = await pt.listPosts(
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
categories: _selectedCategory != null ? [_selectedCategory!.alias] : null,
|
||||
channel: _selectedChannel,
|
||||
realm: _selectedRealm?.alias,
|
||||
);
|
||||
final out = result.$1;
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
_postCount = result.$2;
|
||||
_posts.addAll(out);
|
||||
|
||||
if (mounted) setState(() => _isBusy = false);
|
||||
}
|
||||
|
||||
void setChannel(String? channel) {
|
||||
_selectedChannel = channel;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void setRealm(SnRealm? realm) {
|
||||
_selectedRealm = realm;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void setCategory(SnPostCategory? category) {
|
||||
_selectedCategory = category;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> refreshPosts() {
|
||||
_postCount = null;
|
||||
_posts.clear();
|
||||
return _fetchPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPosts();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
if (_selectedCategory != null)
|
||||
MaterialBanner(
|
||||
content: Text(
|
||||
'postFilterWithCategory'.tr(args: [
|
||||
'postCategory${_selectedCategory!.alias.capitalize()}'.trExists()
|
||||
? 'postCategory${_selectedCategory!.alias.capitalize()}'
|
||||
.tr()
|
||||
: _selectedCategory!.name,
|
||||
]),
|
||||
),
|
||||
leading: Icon(kCategoryIcons[_selectedCategory!.alias] ??
|
||||
Symbols.question_mark),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.clear),
|
||||
onPressed: () {
|
||||
setState(() => _selectedCategory = null);
|
||||
refreshPosts();
|
||||
},
|
||||
),
|
||||
],
|
||||
padding: const EdgeInsets.only(left: 20, right: 4),
|
||||
),
|
||||
Expanded(
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
displacement: 40 + MediaQuery.of(context).padding.top,
|
||||
onRefresh: () => refreshPosts(),
|
||||
child: InfiniteList(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
itemCount: _posts.length,
|
||||
isLoading: _isBusy,
|
||||
centerLoading: true,
|
||||
hasReachedMax:
|
||||
_postCount != null && _posts.length >= _postCount!,
|
||||
onFetchData: _fetchPosts,
|
||||
itemBuilder: (context, idx) {
|
||||
return OpenablePostItem(
|
||||
data: _posts[idx],
|
||||
maxWidth: 640,
|
||||
onChanged: (data) {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
refreshPosts();
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostListRealmPopup extends StatelessWidget {
|
||||
final List<SnRealm>? realms;
|
||||
final Function(SnRealm?) onUpdate;
|
||||
|
||||
const _PostListRealmPopup({
|
||||
required this.realms,
|
||||
required this.onUpdate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.face, size: 24),
|
||||
const Gap(16),
|
||||
Text('accountRealms', style: Theme.of(context).textTheme.titleLarge)
|
||||
.tr(),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.close),
|
||||
title: Text('postInGlobal').tr(),
|
||||
subtitle: Text('postViewInGlobalDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
onUpdate.call(null);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: realms?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final realm = realms![idx];
|
||||
return ListTile(
|
||||
title: Text(realm.name),
|
||||
subtitle: Text('@${realm.alias}'),
|
||||
leading: AccountImage(content: realm.avatar, radius: 18),
|
||||
onTap: () {
|
||||
onUpdate.call(realm);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_app_update/flutter_app_update.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -29,6 +26,7 @@ import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
import 'package:surface/widgets/updater.dart';
|
||||
|
||||
class HomeScreenDashEntry {
|
||||
final String name;
|
||||
@@ -83,14 +81,24 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Align(
|
||||
alignment: constraints.maxWidth > 640 ? Alignment.center : Alignment.topCenter,
|
||||
alignment: constraints.maxWidth > 640
|
||||
? Alignment.center
|
||||
: Alignment.topCenter,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 640),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisAlignment: constraints.maxWidth > 640 ? MainAxisAlignment.center : MainAxisAlignment.start,
|
||||
mainAxisAlignment: constraints.maxWidth > 640
|
||||
? MainAxisAlignment.center
|
||||
: MainAxisAlignment.start,
|
||||
children: [
|
||||
_HomeDashUpdateWidget(padding: const EdgeInsets.only(bottom: 8, left: 8, right: 8)),
|
||||
_HomeDashUpdateWidget(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 8,
|
||||
left: 8,
|
||||
right: 8,
|
||||
),
|
||||
),
|
||||
_HomeDashSpecialDayWidget().padding(horizontal: 8),
|
||||
StaggeredGrid.extent(
|
||||
maxCrossAxisExtent: 280,
|
||||
@@ -136,21 +144,15 @@ class _HomeDashUpdateWidget extends StatelessWidget {
|
||||
leading: Icon(Symbols.update),
|
||||
title: Text('updateAvailable').tr(),
|
||||
subtitle: Text(config.updatableVersion!),
|
||||
trailing: (kIsWeb || Platform.isWindows || Platform.isLinux)
|
||||
? null
|
||||
: IconButton(
|
||||
icon: const Icon(Symbols.arrow_right_alt),
|
||||
onPressed: () {
|
||||
final model = UpdateModel(
|
||||
'https://files.solsynth.dev/d/production01/solian/app-arm64-v8a-release.apk',
|
||||
'solian-app-release-${config.updatableVersion!}.apk',
|
||||
'ic_launcher',
|
||||
'https://apps.apple.com/us/app/solian/id6499032345',
|
||||
);
|
||||
AzhonAppUpdate.update(model);
|
||||
context.showSnackbar('updateOngoing'.tr());
|
||||
},
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.arrow_right_alt),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => VersionUpdatePopup(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -166,7 +168,8 @@ class _HomeDashSpecialDayWidget extends StatefulWidget {
|
||||
const _HomeDashSpecialDayWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashSpecialDayWidget> createState() => _HomeDashSpecialDayWidgetState();
|
||||
State<_HomeDashSpecialDayWidget> createState() =>
|
||||
_HomeDashSpecialDayWidgetState();
|
||||
}
|
||||
|
||||
class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
|
||||
@@ -208,7 +211,9 @@ class _HomeDashSpecialDayWidgetState extends State<_HomeDashSpecialDayWidget> {
|
||||
margin: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
leading: Text(kSpecialDaysSymbol[name] ?? '🎉').fontSize(24),
|
||||
title: Text('pending$name').tr(args: [RelativeTime(context).format(date).replaceFirst('in', '').trim()]),
|
||||
title: Text('pending$name').tr(args: [
|
||||
RelativeTime(context).format(date).replaceFirst('in', '').trim()
|
||||
]),
|
||||
subtitle: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -297,12 +302,19 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
|
||||
children: [
|
||||
Text(
|
||||
_article!.title,
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontSize: 18),
|
||||
maxLines: MediaQuery.of(context).size.width >= 640 ? 2 : 1,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium!
|
||||
.copyWith(fontSize: 18),
|
||||
maxLines:
|
||||
MediaQuery.of(context).size.width >= 640 ? 2 : 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
parse(_article!.description).children.map((e) => e.text.trim()).join(),
|
||||
parse(_article!.description)
|
||||
.children
|
||||
.map((e) => e.text.trim())
|
||||
.join(),
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
@@ -313,9 +325,13 @@ class _HomeDashTodayNewsState extends State<_HomeDashTodayNews> {
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
spacing: 2,
|
||||
children: [
|
||||
Text(DateFormat().format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
Text(' · ').textStyle(Theme.of(context).textTheme.bodySmall!).bold(),
|
||||
Text(RelativeTime(context).format(date)).textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
Text(DateFormat().format(date)).textStyle(
|
||||
Theme.of(context).textTheme.bodySmall!),
|
||||
Text(' · ')
|
||||
.textStyle(Theme.of(context).textTheme.bodySmall!)
|
||||
.bold(),
|
||||
Text(RelativeTime(context).format(date)).textStyle(
|
||||
Theme.of(context).textTheme.bodySmall!),
|
||||
],
|
||||
).opacity(0.75);
|
||||
}),
|
||||
@@ -386,15 +402,20 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
}
|
||||
|
||||
Widget _buildDetailChunk(int index, bool positive) {
|
||||
final prefix = positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
|
||||
final mod = positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
|
||||
final prefix =
|
||||
positive ? 'dailyCheckPositiveHint' : 'dailyCheckNegativeHint';
|
||||
final mod =
|
||||
positive ? kSuggestionPositiveHintCount : kSuggestionNegativeHintCount;
|
||||
final pos = math.max(1, _todayRecord!.resultModifiers[index] % mod);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
prefix.tr(args: ['$prefix$pos'.tr()]),
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium!
|
||||
.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'$prefix${pos}Description',
|
||||
@@ -429,7 +450,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
else
|
||||
Text(
|
||||
'dailyCheckEverythingIsNegative',
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium!
|
||||
.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
const Gap(8),
|
||||
if (_todayRecord?.resultTier != 4)
|
||||
@@ -445,7 +469,10 @@ class _HomeDashCheckInWidgetState extends State<_HomeDashCheckInWidget> {
|
||||
else
|
||||
Text(
|
||||
'dailyCheckEverythingIsPositive',
|
||||
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.bold),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium!
|
||||
.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
@@ -571,10 +598,12 @@ class _HomeDashNotificationWidget extends StatefulWidget {
|
||||
const _HomeDashNotificationWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashNotificationWidget> createState() => _HomeDashNotificationWidgetState();
|
||||
State<_HomeDashNotificationWidget> createState() =>
|
||||
_HomeDashNotificationWidgetState();
|
||||
}
|
||||
|
||||
class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget> {
|
||||
class _HomeDashNotificationWidgetState
|
||||
extends State<_HomeDashNotificationWidget> {
|
||||
int? _count;
|
||||
|
||||
Future<void> _fetchNotificationCount() async {
|
||||
@@ -612,7 +641,9 @@ class _HomeDashNotificationWidgetState extends State<_HomeDashNotificationWidget
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
).tr(),
|
||||
Text(
|
||||
_count == null ? 'loading'.tr() : 'notificationUnreadCount'.plural(_count ?? 0),
|
||||
_count == null
|
||||
? 'loading'.tr()
|
||||
: 'notificationUnreadCount'.plural(_count ?? 0),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
@@ -643,10 +674,12 @@ class _HomeDashRecommendationPostWidget extends StatefulWidget {
|
||||
const _HomeDashRecommendationPostWidget();
|
||||
|
||||
@override
|
||||
State<_HomeDashRecommendationPostWidget> createState() => _HomeDashRecommendationPostWidgetState();
|
||||
State<_HomeDashRecommendationPostWidget> createState() =>
|
||||
_HomeDashRecommendationPostWidgetState();
|
||||
}
|
||||
|
||||
class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendationPostWidget> {
|
||||
class _HomeDashRecommendationPostWidgetState
|
||||
extends State<_HomeDashRecommendationPostWidget> {
|
||||
bool _isBusy = false;
|
||||
List<SnPost>? _posts;
|
||||
|
||||
@@ -710,13 +743,15 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
Text('${_currentPage + 1}/${_posts?.length ?? 0}', style: GoogleFonts.robotoMono())
|
||||
Text('${_currentPage + 1}/${_posts?.length ?? 0}',
|
||||
style: GoogleFonts.robotoMono())
|
||||
],
|
||||
).padding(horizontal: 18, top: 12, bottom: 8),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
scrollBehavior: ScrollConfiguration.of(context).copyWith(dragDevices: {
|
||||
scrollBehavior:
|
||||
ScrollConfiguration.of(context).copyWith(dragDevices: {
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.touch,
|
||||
}),
|
||||
@@ -729,7 +764,8 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
|
||||
showMenu: false,
|
||||
).padding(bottom: 8),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed('postDetail', pathParameters: {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('postDetail', pathParameters: {
|
||||
'slug': _posts![index].id.toString(),
|
||||
});
|
||||
},
|
||||
|
||||
167
lib/screens/logging.dart
Normal file
167
lib/screens/logging.dart
Normal file
@@ -0,0 +1,167 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/logger.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:talker_dio_logger/dio_logs.dart';
|
||||
import 'package:talker_flutter/talker_flutter.dart';
|
||||
|
||||
final Map<LogLevel, IconData> kLogLevelIcons = {
|
||||
LogLevel.error: Symbols.error,
|
||||
LogLevel.critical: Symbols.error,
|
||||
LogLevel.warning: Symbols.warning,
|
||||
LogLevel.info: Symbols.info,
|
||||
LogLevel.debug: Symbols.info_i,
|
||||
LogLevel.verbose: Symbols.info_i,
|
||||
};
|
||||
|
||||
final Map<LogLevel, bool> kLogLevelFilled = {
|
||||
LogLevel.error: false,
|
||||
LogLevel.critical: true,
|
||||
LogLevel.warning: true,
|
||||
LogLevel.info: true,
|
||||
LogLevel.debug: false,
|
||||
LogLevel.verbose: false,
|
||||
};
|
||||
|
||||
class DebugLoggingScreen extends StatelessWidget {
|
||||
const DebugLoggingScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final talkerTheme = TalkerScreenTheme.fromTheme(Theme.of(context));
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: const PageBackButton(),
|
||||
title: Text('debugLogging').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
logging.cleanHistory();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Symbols.delete),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView.builder(
|
||||
reverse: true,
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom),
|
||||
itemCount: logging.history.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = logging.history[index];
|
||||
final color = log.getFlutterColor(talkerTheme);
|
||||
return ListTile(
|
||||
minTileHeight: 0,
|
||||
tileColor: color.withOpacity(0.2),
|
||||
leading: Icon(
|
||||
kLogLevelIcons[log.logLevel ?? LogLevel.debug] ?? Symbols.help,
|
||||
color: color,
|
||||
fill: (kLogLevelFilled[log.logLevel ?? LogLevel.debug] ?? false)
|
||||
? 1
|
||||
: 0,
|
||||
),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (log is DioRequestLog)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${log.requestOptions.method} ${log.displayMessage}',
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
),
|
||||
if (log.requestOptions.data != null)
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
title: Text('Payload').fontSize(13),
|
||||
minTileHeight: 0,
|
||||
tilePadding: EdgeInsets.zero,
|
||||
expandedCrossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
log.requestOptions.data.toString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else if (log is DioResponseLog)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${log.response.statusCode} ${log.displayMessage}',
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
),
|
||||
if (log.response.data != null)
|
||||
Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
title: Text('Payload').fontSize(13),
|
||||
minTileHeight: 0,
|
||||
tilePadding: EdgeInsets.zero,
|
||||
expandedCrossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
log.response.data.toString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
log.displayMessage,
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
),
|
||||
if (log.exception != null)
|
||||
Text(
|
||||
log.displayException,
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
).bold(),
|
||||
if (log.error != null)
|
||||
Text(
|
||||
log.displayException,
|
||||
style: GoogleFonts.robotoMono(fontSize: 13),
|
||||
).bold(),
|
||||
if (log.stackTrace != null)
|
||||
Text(
|
||||
log.displayStackTrace,
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
).padding(top: 4),
|
||||
],
|
||||
),
|
||||
subtitle: Text(
|
||||
'${(log.title?.replaceAll('-', ' ') ?? 'default').capitalizeEachWord()} · ${log.displayTime()}',
|
||||
).fontSize(11),
|
||||
onTap: () {
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: log.generateTextMessage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const Map<String, IconData> kNotificationTopicIcons = {
|
||||
'passport.security.otp': Symbols.password,
|
||||
'interactive.subscription': Symbols.subscriptions,
|
||||
'interactive.feedback': Symbols.add_reaction,
|
||||
'interactive.reply': Symbols.reply,
|
||||
'messaging.callStart': Symbols.call_received,
|
||||
'wallet.transaction.new': Symbols.receipt,
|
||||
};
|
||||
@@ -57,11 +58,12 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final nty = context.read<NotificationProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/notifications?take=10');
|
||||
_totalCount = resp.data['count'];
|
||||
_notifications.addAll(
|
||||
resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? [],
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/id/notifications',
|
||||
queryParameters: {'take': 10, 'offset': _notifications.length},
|
||||
);
|
||||
_totalCount = resp.data['count'];
|
||||
_notifications.addAll(resp.data['data']?.map((e) => SnNotification.fromJson(e)).cast<SnNotification>() ?? []);
|
||||
nty.updateTray();
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
@@ -96,9 +98,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
nty.clear();
|
||||
|
||||
if (!mounted) return;
|
||||
context.showSnackbar(
|
||||
'notificationMarkAllReadPrompt'.plural(resp.data['count']),
|
||||
);
|
||||
context.showSnackbar('notificationMarkAllReadPrompt'.plural(resp.data['count']));
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@@ -122,9 +122,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
_fetchNotifications();
|
||||
|
||||
if (!mounted) return;
|
||||
context.showSnackbar(
|
||||
'notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']),
|
||||
);
|
||||
context.showSnackbar('notificationMarkOneReadPrompt'.tr(args: ['#${notification.id}']));
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
@@ -145,13 +143,8 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
|
||||
if (!ua.isAuthorized) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenNotification').tr(),
|
||||
),
|
||||
body: Center(
|
||||
child: UnauthorizedHint(),
|
||||
),
|
||||
appBar: AppBar(leading: AutoAppBarLeading(), title: Text('screenNotification').tr()),
|
||||
body: Center(child: UnauthorizedHint()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -160,10 +153,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenNotification').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.checklist),
|
||||
onPressed: _isSubmitting ? null : _markAllAsRead,
|
||||
),
|
||||
IconButton(icon: const Icon(Symbols.checklist), onPressed: _isSubmitting ? null : _markAllAsRead),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
@@ -177,10 +167,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
return _fetchNotifications();
|
||||
},
|
||||
child: InfiniteList(
|
||||
padding: EdgeInsets.only(
|
||||
top: 16,
|
||||
bottom: math.max(MediaQuery.of(context).padding.bottom, 16),
|
||||
),
|
||||
padding: EdgeInsets.only(top: 16, bottom: math.max(MediaQuery.of(context).padding.bottom, 16)),
|
||||
itemCount: _notifications.length,
|
||||
onFetchData: () {
|
||||
_fetchNotifications();
|
||||
@@ -199,41 +186,26 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (nty.readAt == null)
|
||||
StyledWidget(Badge(
|
||||
label: Text('notificationUnread').tr(),
|
||||
)).padding(bottom: 4),
|
||||
Text(
|
||||
nty.title,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
StyledWidget(Badge(label: Text('notificationUnread').tr())).padding(bottom: 4),
|
||||
Text(nty.title, style: Theme.of(context).textTheme.titleMedium),
|
||||
if (nty.subtitle != null)
|
||||
Text(
|
||||
nty.subtitle!,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
Text(nty.subtitle!, style: Theme.of(context).textTheme.titleSmall),
|
||||
if (nty.subtitle != null) const Gap(4),
|
||||
SelectionArea(
|
||||
child: MarkdownTextContent(
|
||||
content: nty.body,
|
||||
isAutoWarp: true,
|
||||
),
|
||||
),
|
||||
if (['interactive.reply', 'interactive.feedback', 'interactive.subscription']
|
||||
.contains(nty.topic) &&
|
||||
SelectionArea(child: MarkdownTextContent(content: nty.body, isAutoWarp: true)),
|
||||
if ([
|
||||
'interactive.reply',
|
||||
'interactive.feedback',
|
||||
'interactive.subscription',
|
||||
].contains(nty.topic) &&
|
||||
nty.metadata['related_post'] != null)
|
||||
GestureDetector(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).dividerColor,
|
||||
width: 1,
|
||||
),
|
||||
border: Border.all(color: Theme.of(context).dividerColor, width: 1),
|
||||
),
|
||||
child: PostItem(
|
||||
data: SnPost.fromJson(
|
||||
nty.metadata['related_post']!,
|
||||
),
|
||||
data: SnPost.fromJson(nty.metadata['related_post']!),
|
||||
showComments: false,
|
||||
showReactions: false,
|
||||
showMenu: false,
|
||||
@@ -242,27 +214,18 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postDetail',
|
||||
pathParameters: {
|
||||
'slug': nty.metadata['related_post']!['id'].toString(),
|
||||
},
|
||||
pathParameters: {'slug': nty.metadata['related_post']!['id'].toString()},
|
||||
);
|
||||
},
|
||||
).padding(top: 8),
|
||||
const Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
DateFormat('yy/MM/dd').format(nty.createdAt),
|
||||
).fontSize(12),
|
||||
Text(DateFormat('yy/MM/dd').format(nty.createdAt)).fontSize(12),
|
||||
const Gap(4),
|
||||
Text(
|
||||
'·',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
Text('·', style: TextStyle(fontSize: 12)),
|
||||
const Gap(4),
|
||||
Text(
|
||||
RelativeTime(context).format(nty.createdAt),
|
||||
).fontSize(12),
|
||||
Text(RelativeTime(context).format(nty.createdAt)).fontSize(12),
|
||||
],
|
||||
).opacity(0.75),
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,17 +4,16 @@ import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/realm/realm_item.dart';
|
||||
import 'package:surface/widgets/unauthorized_hint.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
|
||||
class RealmScreen extends StatefulWidget {
|
||||
const RealmScreen({super.key});
|
||||
@@ -75,12 +74,12 @@ class _RealmScreenState extends State<RealmScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isCompactView = context.read<ConfigProvider>().realmCompactView;
|
||||
_fetchRealms();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
|
||||
if (!ua.isAuthorized) {
|
||||
@@ -110,6 +109,7 @@ class _RealmScreenState extends State<RealmScreen> {
|
||||
icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
|
||||
onPressed: () {
|
||||
setState(() => _isCompactView = !_isCompactView);
|
||||
context.read<ConfigProvider>().realmCompactView = _isCompactView;
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
@@ -134,129 +134,46 @@ class _RealmScreenState extends State<RealmScreen> {
|
||||
itemCount: _realms?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final realm = _realms![idx];
|
||||
if (_isCompactView) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: AccountImage(
|
||||
content: realm.avatar,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 20),
|
||||
),
|
||||
title: Text(realm.name),
|
||||
subtitle: Text(
|
||||
realm.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: PopupMenuButton(
|
||||
itemBuilder: (BuildContext context) => [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.edit),
|
||||
const Gap(16),
|
||||
Text('edit').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'realmManage',
|
||||
queryParameters: {'editing': realm.alias},
|
||||
).then((value) {
|
||||
if (value != null) {
|
||||
_fetchRealms();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.delete),
|
||||
const Gap(16),
|
||||
Text('delete').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_deleteRealm(realm);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'realmDetail',
|
||||
pathParameters: {'alias': realm.alias},
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
_fetchRealms();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(12),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
return RealmItemWidget(
|
||||
showPopularity: false,
|
||||
item: realm,
|
||||
isListView: _isCompactView,
|
||||
actionListView: [
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: (realm.banner?.isEmpty ?? true)
|
||||
? const SizedBox.shrink()
|
||||
: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(realm.banner!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -30,
|
||||
left: 18,
|
||||
child: AccountImage(
|
||||
content: realm.avatar,
|
||||
radius: 24,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(20 + 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
|
||||
Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
],
|
||||
).padding(horizontal: 24, bottom: 14),
|
||||
const Icon(Symbols.edit),
|
||||
const Gap(16),
|
||||
Text('edit').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'realmDetail',
|
||||
pathParameters: {'alias': realm.alias},
|
||||
'realmManage',
|
||||
queryParameters: {'editing': realm.alias},
|
||||
).then((value) {
|
||||
if (value == true) {
|
||||
if (value != null) {
|
||||
_fetchRealms();
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
).center();
|
||||
PopupMenuItem(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Symbols.delete),
|
||||
const Gap(16),
|
||||
Text('delete').tr(),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
_deleteRealm(realm);
|
||||
},
|
||||
),
|
||||
],
|
||||
onUpdate: _fetchRealms,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,16 +5,19 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/post.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/user_directory.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
import 'package:surface/types/post.dart';
|
||||
import 'package:surface/types/realm.dart';
|
||||
import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/account/account_select.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/post/post_item.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class RealmDetailScreen extends StatefulWidget {
|
||||
@@ -48,7 +51,8 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
Future<void> _fetchPublishers() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
|
||||
final resp =
|
||||
await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
|
||||
_publishers = List<SnPublisher>.from(
|
||||
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
|
||||
);
|
||||
@@ -60,31 +64,68 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
List<SnChannel>? _channels;
|
||||
|
||||
Future<void> _fetchChannels() async {
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp =
|
||||
await sn.client.get('/cgi/im/channels/${widget.alias}/public');
|
||||
_channels = List<SnChannel>.from(
|
||||
resp.data.map((e) => SnChannel.fromJson(e)).cast<SnChannel>(),
|
||||
);
|
||||
} catch (err) {
|
||||
if (mounted) context.showErrorDialog(err);
|
||||
rethrow;
|
||||
} finally {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchRealm().then((_) {
|
||||
_fetchPublishers();
|
||||
_fetchChannels();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 3,
|
||||
length: 4,
|
||||
child: AppScaffold(
|
||||
body: NestedScrollView(
|
||||
headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
|
||||
return <Widget>[
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
handle:
|
||||
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: SliverAppBar(
|
||||
title: Text(_realm?.name ?? 'loading'.tr()),
|
||||
bottom: TabBar(
|
||||
tabs: [
|
||||
Tab(icon: Icon(Symbols.home, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(icon: Icon(Symbols.group, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(icon: Icon(Symbols.settings, color: Theme.of(context).appBarTheme.foregroundColor)),
|
||||
Tab(
|
||||
icon: Icon(Symbols.home,
|
||||
color: Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor)),
|
||||
Tab(
|
||||
icon: Icon(Symbols.explore,
|
||||
color: Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor)),
|
||||
Tab(
|
||||
icon: Icon(Symbols.group,
|
||||
color: Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor)),
|
||||
Tab(
|
||||
icon: Icon(Symbols.settings,
|
||||
color: Theme.of(context)
|
||||
.appBarTheme
|
||||
.foregroundColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -93,7 +134,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
},
|
||||
body: TabBarView(
|
||||
children: [
|
||||
_RealmDetailHomeWidget(realm: _realm, publishers: _publishers),
|
||||
_RealmDetailHomeWidget(
|
||||
realm: _realm, publishers: _publishers, channels: _channels),
|
||||
_RealmPostListWidget(realm: _realm),
|
||||
_RealmMemberListWidget(realm: _realm),
|
||||
_RealmSettingsWidget(
|
||||
realm: _realm,
|
||||
@@ -112,8 +155,10 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
|
||||
class _RealmDetailHomeWidget extends StatelessWidget {
|
||||
final SnRealm? realm;
|
||||
final List<SnPublisher>? publishers;
|
||||
final List<SnChannel>? channels;
|
||||
|
||||
const _RealmDetailHomeWidget({required this.realm, this.publishers});
|
||||
const _RealmDetailHomeWidget(
|
||||
{required this.realm, this.publishers, this.channels});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -135,30 +180,78 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
||||
],
|
||||
).padding(horizontal: 24),
|
||||
const Gap(16),
|
||||
const Divider(),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: publishers?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final ele = publishers![idx];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
leading: AccountImage(
|
||||
content: ele.avatar,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
if (publishers?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Text('realmCommunityPublishersHint'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium)
|
||||
.padding(horizontal: 24, vertical: 8),
|
||||
),
|
||||
),
|
||||
title: Text(ele.nick),
|
||||
subtitle: Text('@${ele.name}'),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postPublisher',
|
||||
pathParameters: {'name': ele.name},
|
||||
SliverList.builder(
|
||||
itemCount: publishers?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final ele = publishers![idx];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
leading: AccountImage(
|
||||
content: ele.avatar,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||
),
|
||||
title: Text(ele.nick),
|
||||
subtitle: Text('@${ele.name}'),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postPublisher',
|
||||
pathParameters: {'name': ele.name},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (channels?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: Text('realmCommunityPublicChannelsHint'.tr(),
|
||||
style: Theme.of(context).textTheme.bodyMedium)
|
||||
.padding(horizontal: 24, vertical: 8),
|
||||
),
|
||||
),
|
||||
SliverList.builder(
|
||||
itemCount: channels?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final ele = channels![idx];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
leading: AccountImage(
|
||||
content: null,
|
||||
fallbackWidget: const Icon(Symbols.chat, size: 20),
|
||||
),
|
||||
title: Text(ele.name),
|
||||
subtitle: Text('#${ele.alias}'),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'chatRoom',
|
||||
pathParameters: {
|
||||
'scope': realm?.alias ?? 'global',
|
||||
'alias': ele.alias,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -166,6 +259,72 @@ class _RealmDetailHomeWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RealmPostListWidget extends StatefulWidget {
|
||||
final SnRealm? realm;
|
||||
|
||||
const _RealmPostListWidget({this.realm});
|
||||
|
||||
@override
|
||||
State<_RealmPostListWidget> createState() => _RealmPostListWidgetState();
|
||||
}
|
||||
|
||||
class _RealmPostListWidgetState extends State<_RealmPostListWidget> {
|
||||
bool _isBusy = false;
|
||||
int? _totalCount;
|
||||
final List<SnPost> _posts = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchPosts() async {
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final pt = context.read<SnPostContentProvider>();
|
||||
final out = await pt.listPosts(
|
||||
take: 10,
|
||||
offset: _posts.length,
|
||||
realm: widget.realm?.id.toString(),
|
||||
);
|
||||
_totalCount = out.$2;
|
||||
_posts.addAll(out.$1);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _fetchPosts,
|
||||
child: InfiniteList(
|
||||
itemCount: _posts.length,
|
||||
isLoading: _isBusy,
|
||||
hasReachedMax: _totalCount != null && _posts.length >= _totalCount!,
|
||||
onFetchData: _fetchPosts,
|
||||
itemBuilder: (context, idx) {
|
||||
final post = _posts[idx];
|
||||
return OpenablePostItem(
|
||||
data: post,
|
||||
maxWidth: 640,
|
||||
onChanged: (data) {
|
||||
setState(() => _posts[idx] = data);
|
||||
},
|
||||
onDeleted: () {
|
||||
setState(() => _posts.removeAt(idx));
|
||||
},
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => const Gap(8),
|
||||
),
|
||||
),
|
||||
).padding(top: 8);
|
||||
}
|
||||
}
|
||||
|
||||
class _RealmMemberListWidget extends StatefulWidget {
|
||||
final SnRealm? realm;
|
||||
|
||||
@@ -187,10 +346,12 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
|
||||
try {
|
||||
final ud = context.read<UserDirectoryProvider>();
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _members.length,
|
||||
});
|
||||
final resp = await sn.client.get(
|
||||
'/cgi/id/realms/${widget.realm!.alias}/members',
|
||||
queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _members.length,
|
||||
});
|
||||
|
||||
final out = List<SnRealmMember>.from(
|
||||
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
|
||||
@@ -296,14 +457,14 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(right: 24, left: 16),
|
||||
leading: AccountImage(
|
||||
content: ud.getAccountFromCache(member.accountId)?.avatar,
|
||||
content: ud.getFromCache(member.accountId)?.avatar,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||
),
|
||||
title: Text(
|
||||
ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
|
||||
ud.getFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
|
||||
),
|
||||
subtitle: Text(
|
||||
ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
||||
ud.getFromCache(member.accountId)?.name ?? 'unknown'.tr(),
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Symbols.person_remove),
|
||||
@@ -365,7 +526,7 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
try {
|
||||
await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}/members/me');
|
||||
await sn.client.delete('/cgi/id/realms/${widget.realm!.alias}/me');
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/chat.dart';
|
||||
@@ -12,7 +13,7 @@ import 'package:surface/widgets/account/account_image.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/universal_image.dart';
|
||||
import 'package:surface/widgets/realm/realm_item.dart';
|
||||
|
||||
class RealmDiscoveryScreen extends StatefulWidget {
|
||||
const RealmDiscoveryScreen({super.key});
|
||||
@@ -24,6 +25,7 @@ class RealmDiscoveryScreen extends StatefulWidget {
|
||||
class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
|
||||
List<SnRealm>? _realms;
|
||||
bool _isBusy = false;
|
||||
bool _isCompactView = false;
|
||||
|
||||
Future<void> _fetchRealms() async {
|
||||
try {
|
||||
@@ -44,16 +46,25 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isCompactView = context.read<ConfigProvider>().realmCompactView;
|
||||
_fetchRealms();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('screenRealmDiscovery').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: _isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
|
||||
onPressed: () {
|
||||
setState(() => _isCompactView = !_isCompactView);
|
||||
context.read<ConfigProvider>().realmCompactView = _isCompactView;
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
@@ -66,64 +77,16 @@ class _RealmDiscoveryScreenState extends State<RealmDiscoveryScreen> {
|
||||
itemCount: _realms?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
final realm = _realms![idx];
|
||||
return Container(
|
||||
constraints: BoxConstraints(maxWidth: 640),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(12),
|
||||
child: InkWell(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 16 / 7,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: Container(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
child: (realm.banner?.isEmpty ?? true)
|
||||
? const SizedBox.shrink()
|
||||
: AutoResizeUniversalImage(
|
||||
sn.getAttachmentUrl(realm.banner!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: -30,
|
||||
left: 18,
|
||||
child: AccountImage(
|
||||
content: realm.avatar,
|
||||
radius: 24,
|
||||
fallbackWidget: const Icon(Symbols.group, size: 24),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Gap(20 + 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
|
||||
Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
|
||||
],
|
||||
).padding(horizontal: 24, bottom: 14),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _RealmJoinPopup(realm: realm),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
).center();
|
||||
return RealmItemWidget(
|
||||
item: realm,
|
||||
isListView: _isCompactView,
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _RealmJoinPopup(realm: realm),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -235,6 +198,8 @@ class _RealmJoinPopupState extends State<_RealmJoinPopup> {
|
||||
),
|
||||
Text(
|
||||
widget.realm.description,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,8 +5,11 @@ import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_colorpicker/flutter_colorpicker.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -14,11 +17,15 @@ import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/config.dart';
|
||||
import 'package:surface/providers/database.dart';
|
||||
import 'package:surface/providers/notification.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/providers/theme.dart';
|
||||
import 'package:surface/theme.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:surface/widgets/updater.dart';
|
||||
|
||||
const Map<String, Color> kColorSchemes = {
|
||||
'colorSchemeIndigo': Colors.indigo,
|
||||
@@ -42,6 +49,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
late final SharedPreferences _prefs;
|
||||
String _docBasepath = '/';
|
||||
|
||||
final TextEditingController _customFontController = TextEditingController();
|
||||
final TextEditingController _serverUrlController = TextEditingController();
|
||||
|
||||
@override
|
||||
@@ -56,17 +64,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
final config = context.read<ConfigProvider>();
|
||||
_prefs = config.prefs;
|
||||
_serverUrlController.text = config.serverUrl;
|
||||
if (_prefs.getString(kAppCustomFonts) != null) {
|
||||
_customFontController.text = _prefs.getString(kAppCustomFonts) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_serverUrlController.dispose();
|
||||
_customFontController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final dt = context.read<DatabaseProvider>();
|
||||
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
@@ -81,7 +94,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settingsAppearance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('settingsAppearance')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
ListTile(
|
||||
title: Text('settingsDisplayLanguage').tr(),
|
||||
subtitle: Text('settingsDisplayLanguageDescription').tr(),
|
||||
@@ -91,15 +108,21 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
child: DropdownButton2<Locale?>(
|
||||
isExpanded: true,
|
||||
items: [
|
||||
...EasyLocalization.of(context)!.supportedLocales.mapIndexed((idx, ele) {
|
||||
...EasyLocalization.of(context)!
|
||||
.supportedLocales
|
||||
.mapIndexed((idx, ele) {
|
||||
return DropdownMenuItem<Locale?>(
|
||||
value: ele,
|
||||
child: Text('${ele.languageCode}-${ele.countryCode}').fontSize(14),
|
||||
child:
|
||||
Text('${ele.languageCode}-${ele.countryCode}')
|
||||
.fontSize(14),
|
||||
);
|
||||
}),
|
||||
DropdownMenuItem<Locale?>(
|
||||
value: null,
|
||||
child: Text('settingsDisplayLanguageSystem').tr().fontSize(14),
|
||||
child: Text('settingsDisplayLanguageSystem')
|
||||
.tr()
|
||||
.fontSize(14),
|
||||
),
|
||||
],
|
||||
value: EasyLocalization.of(context)!.currentLocale,
|
||||
@@ -132,10 +155,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
leading: const Icon(Symbols.image),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.gallery);
|
||||
final image = await ImagePicker()
|
||||
.pickImage(source: ImageSource.gallery);
|
||||
if (image == null) return;
|
||||
|
||||
await File(image.path).copy('$_docBasepath/app_background_image');
|
||||
await File(image.path)
|
||||
.copy('$_docBasepath/app_background_image');
|
||||
_prefs.setBool(kAppBackgroundStoreKey, true);
|
||||
|
||||
setState(() {});
|
||||
@@ -143,7 +168,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
),
|
||||
if (!kIsWeb)
|
||||
FutureBuilder<bool>(
|
||||
future: File('$_docBasepath/app_background_image').exists(),
|
||||
future:
|
||||
File('$_docBasepath/app_background_image').exists(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || !snapshot.data!) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -151,12 +177,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
|
||||
return ListTile(
|
||||
title: Text('settingsBackgroundImageClear').tr(),
|
||||
subtitle: Text('settingsBackgroundImageClearDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
subtitle:
|
||||
Text('settingsBackgroundImageClearDescription')
|
||||
.tr(),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.texture),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () {
|
||||
File('$_docBasepath/app_background_image').deleteSync();
|
||||
File('$_docBasepath/app_background_image')
|
||||
.deleteSync();
|
||||
_prefs.remove(kAppBackgroundStoreKey);
|
||||
setState(() {});
|
||||
},
|
||||
@@ -186,34 +216,35 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
Color pickerColor = Color(_prefs.getInt(kAppColorSchemeStoreKey) ?? Colors.indigo.value);
|
||||
Color pickerColor = Color(
|
||||
_prefs.getInt(kAppColorSchemeStoreKey) ??
|
||||
Colors.indigo.value);
|
||||
final color = await showDialog<Color?>(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
AlertDialog(
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
onColorChanged: (color) => pickerColor = color,
|
||||
enableAlpha: false,
|
||||
hexInputBar: true,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('dialogDismiss').tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('dialogConfirm').tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(pickerColor);
|
||||
},
|
||||
),
|
||||
],
|
||||
builder: (context) => AlertDialog(
|
||||
content: SingleChildScrollView(
|
||||
child: ColorPicker(
|
||||
pickerColor: pickerColor,
|
||||
onColorChanged: (color) => pickerColor = color,
|
||||
enableAlpha: false,
|
||||
hexInputBar: true,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('dialogDismiss').tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
TextButton(
|
||||
child: const Text('dialogConfirm').tr(),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(pickerColor);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (color == null || !context.mounted) return;
|
||||
@@ -248,16 +279,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
],
|
||||
value: _prefs.getInt(kAppColorSchemeStoreKey) == null
|
||||
? 1
|
||||
: kColorSchemes.values
|
||||
.toList()
|
||||
.indexWhere((ele) => ele.value == _prefs.getInt(kAppColorSchemeStoreKey)),
|
||||
: kColorSchemes.values.toList().indexWhere((ele) =>
|
||||
ele.value ==
|
||||
_prefs.getInt(kAppColorSchemeStoreKey)),
|
||||
onChanged: (int? value) {
|
||||
if (value != null && value != -1) {
|
||||
_prefs.setInt(kAppColorSchemeStoreKey, kColorSchemes.values
|
||||
.elementAt(value)
|
||||
.value);
|
||||
_prefs.setInt(kAppColorSchemeStoreKey,
|
||||
kColorSchemes.values.elementAt(value).value);
|
||||
final th = context.read<ThemeProvider>();
|
||||
th.reloadTheme(seedColorOverride: kColorSchemes.values.elementAt(value));
|
||||
th.reloadTheme(
|
||||
seedColorOverride:
|
||||
kColorSchemes.values.elementAt(value));
|
||||
setState(() {});
|
||||
|
||||
context.showSnackbar('colorSchemeApplied'.tr());
|
||||
@@ -293,7 +325,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Symbols.left_panel_close),
|
||||
title: Text('settingsDrawerPreferCollapse').tr(),
|
||||
subtitle: Text('settingsDrawerPreferCollapseDescription').tr(),
|
||||
subtitle:
|
||||
Text('settingsDrawerPreferCollapseDescription').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
value: _prefs.getBool(kAppDrawerPreferCollapse) ?? false,
|
||||
onChanged: (value) {
|
||||
@@ -303,12 +336,57 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.font_download),
|
||||
title: Text('settingsCustomFonts').tr(),
|
||||
subtitle: Text('settingsCustomFontsDescription').tr(),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 14),
|
||||
trailing: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_prefs.remove(kAppCustomFonts);
|
||||
context.showSnackbar('settingsCustomFontApplied'.tr());
|
||||
final theme = context.read<ThemeProvider>();
|
||||
_customFontController.clear();
|
||||
theme.reloadTheme();
|
||||
},
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: _customFontController,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: 'settingsCustomFontFamily'.tr(),
|
||||
helperText: 'settingsCustomFontFamilyHint'.tr(),
|
||||
prefixIcon: const Icon(Symbols.format_paint),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Symbols.save),
|
||||
onPressed: () {
|
||||
_prefs.setString(
|
||||
kAppCustomFonts,
|
||||
_customFontController.text,
|
||||
);
|
||||
context.showSnackbar('settingsCustomFontApplied'.tr());
|
||||
final theme = context.read<ThemeProvider>();
|
||||
theme.reloadTheme();
|
||||
},
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 16, top: 8, bottom: 4),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settingsFeatures').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('settingsFeatures')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
CheckboxListTile(
|
||||
secondary: const Icon(Symbols.vibration),
|
||||
contentPadding: const EdgeInsets.only(left: 24, right: 17),
|
||||
@@ -350,7 +428,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settingsNetwork').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('settingsNetwork')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
TextField(
|
||||
controller: _serverUrlController,
|
||||
decoration: InputDecoration(
|
||||
@@ -371,7 +453,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 16, top: 8, bottom: 4),
|
||||
ListTile(
|
||||
title: Text('settingsNetworkServerPreset').tr(),
|
||||
@@ -383,12 +466,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
isExpanded: true,
|
||||
items: [
|
||||
...kNetworkServerDirectory,
|
||||
if (!kNetworkServerDirectory.map((ele) => ele.$2).contains(_serverUrlController.text))
|
||||
if (!kNetworkServerDirectory
|
||||
.map((ele) => ele.$2)
|
||||
.contains(_serverUrlController.text))
|
||||
('Custom', _serverUrlController.text),
|
||||
]
|
||||
.map(
|
||||
(item) =>
|
||||
DropdownMenuItem<String>(
|
||||
(item) => DropdownMenuItem<String>(
|
||||
value: item.$2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
@@ -396,11 +480,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.$1).fontSize(14),
|
||||
Text(item.$2, overflow: TextOverflow.ellipsis).fontSize(11)
|
||||
Text(item.$2, overflow: TextOverflow.ellipsis)
|
||||
.fontSize(11)
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
.toList(),
|
||||
value: _serverUrlController.text,
|
||||
onChanged: (String? value) {
|
||||
@@ -442,7 +527,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settingsPerformance').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('settingsPerformance')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
ListTile(
|
||||
title: Text('settingsImageQuality').tr(),
|
||||
subtitle: Text('settingsImageQualityDescription').tr(),
|
||||
@@ -450,21 +539,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
leading: const Icon(Symbols.image),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<FilterQuality>(
|
||||
value: kImageQualityLevel.values.elementAtOrNull(_prefs.getInt('app_image_quality') ?? 3) ??
|
||||
value: kImageQualityLevel.values.elementAtOrNull(
|
||||
_prefs.getInt('app_image_quality') ?? 3) ??
|
||||
FilterQuality.high,
|
||||
isExpanded: true,
|
||||
items: kImageQualityLevel.entries
|
||||
.map(
|
||||
(item) =>
|
||||
DropdownMenuItem<FilterQuality>(
|
||||
(item) => DropdownMenuItem<FilterQuality>(
|
||||
value: item.value,
|
||||
child: Text(item.key).tr().fontSize(14),
|
||||
),
|
||||
)
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (FilterQuality? value) {
|
||||
if (value == null) return;
|
||||
_prefs.setInt('app_image_quality', kImageQualityLevel.values.toList().indexOf(value));
|
||||
_prefs.setInt('app_image_quality',
|
||||
kImageQualityLevel.values.toList().indexOf(value));
|
||||
setState(() {});
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
@@ -486,7 +576,136 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('settingsMisc').bold().fontSize(17).tr().padding(horizontal: 20, bottom: 4),
|
||||
Text('settingsMisc')
|
||||
.bold()
|
||||
.fontSize(17)
|
||||
.tr()
|
||||
.padding(horizontal: 20, bottom: 4),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.home_storage),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('cacheSize').tr(),
|
||||
subtitle: FutureBuilder(
|
||||
future: DefaultCacheManager().store.getCacheSize(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || kIsWeb) {
|
||||
return Text('unknown').tr();
|
||||
}
|
||||
return Text(
|
||||
snapshot.data!.formatBytes(),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.cleaning_services),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('cacheDelete').tr(),
|
||||
subtitle: Text('cacheDeleteDescription').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
await DefaultCacheManager().emptyCache();
|
||||
if (!context.mounted) return;
|
||||
HapticFeedback.heavyImpact();
|
||||
context.showSnackbar('cacheDeleted'.tr());
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.database),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('databaseSize').tr(),
|
||||
subtitle: FutureBuilder(
|
||||
future: dt.getDatabaseSize(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || kIsWeb) {
|
||||
return Text('unknown').tr();
|
||||
}
|
||||
return Text(
|
||||
snapshot.data!.formatBytes(),
|
||||
style: GoogleFonts.robotoMono(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.database_off),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
title: Text('databaseDelete').tr(),
|
||||
subtitle: Text('databaseDeleteDescription').tr(),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
await dt.removeDatabase();
|
||||
if (!context.mounted) return;
|
||||
HapticFeedback.heavyImpact();
|
||||
context.showSnackbar('databaseDeleted'.tr());
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.notifications),
|
||||
title: Text('settingsEnablePushNotifications').tr(),
|
||||
subtitle:
|
||||
Text('settingsEnablePushNotificationsDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
final nty = context.read<NotificationProvider>();
|
||||
try {
|
||||
await nty.registerPushNotifications();
|
||||
if (!context.mounted) return;
|
||||
HapticFeedback.heavyImpact();
|
||||
context.showSnackbar(
|
||||
'settingsEnabledPushNotifications'.tr());
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.refresh),
|
||||
title: Text('stickersReload').tr(),
|
||||
subtitle: Text('stickersReloadDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
final stickers = context.read<SnStickerProvider>();
|
||||
try {
|
||||
await stickers.listSticker();
|
||||
if (!context.mounted) return;
|
||||
HapticFeedback.heavyImpact();
|
||||
context.showSnackbar('stickersReloaded'.tr());
|
||||
} catch (err) {
|
||||
if (!context.mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('forceUpdate').tr(),
|
||||
subtitle: Text('forceUpdateDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.update),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => VersionUpdatePopup(),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('runtimeLogsOpen').tr(),
|
||||
subtitle: Text('runtimeLogsDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.receipt_long),
|
||||
trailing: const Icon(Symbols.chevron_right),
|
||||
onTap: () async {
|
||||
GoRouter.of(context).pushNamed('debugLogging');
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
title: Text('settingsMiscAbout').tr(),
|
||||
subtitle: Text('settingsMiscAboutDescription').tr(),
|
||||
|
||||
@@ -51,26 +51,35 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
leading: Icon(Icons.post_add),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
title: Text('shareIntentPostStory').tr(),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'postEditor',
|
||||
pathParameters: {
|
||||
queryParameters: {
|
||||
'mode': 'stories',
|
||||
},
|
||||
extra: PostEditorExtra(
|
||||
text: value
|
||||
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
|
||||
.where((e) => [
|
||||
SharedMediaType.text,
|
||||
SharedMediaType.url
|
||||
].contains(e.type))
|
||||
.map((e) => e.path)
|
||||
.join('\n'),
|
||||
attachments: value
|
||||
.where((e) => [SharedMediaType.video, SharedMediaType.file, SharedMediaType.image]
|
||||
.contains(e.type))
|
||||
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
|
||||
.where((e) => [
|
||||
SharedMediaType.video,
|
||||
SharedMediaType.file,
|
||||
SharedMediaType.image
|
||||
].contains(e.type))
|
||||
.map((e) =>
|
||||
PostWriteMedia.fromFile(XFile(e.path)))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
@@ -78,15 +87,18 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8)),
|
||||
leading: Icon(Icons.chat_outlined),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
title: Text('shareIntentSendChannel').tr(),
|
||||
onTap: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _ShareIntentChannelSelect(value: value),
|
||||
builder: (context) =>
|
||||
_ShareIntentChannelSelect(value: value),
|
||||
).then((val) {
|
||||
if (!context.mounted) return;
|
||||
if (val == true) Navigator.pop(context);
|
||||
@@ -110,7 +122,8 @@ class _AppSharingListenerState extends State<AppSharingListener> {
|
||||
}
|
||||
|
||||
void _initialize() async {
|
||||
_shareIntentSubscription = ReceiveSharingIntent.instance.getMediaStream().listen((value) {
|
||||
_shareIntentSubscription =
|
||||
ReceiveSharingIntent.instance.getMediaStream().listen((value) {
|
||||
if (value.isEmpty) return;
|
||||
if (mounted) {
|
||||
_gotoPost(value);
|
||||
@@ -157,7 +170,8 @@ class _ShareIntentChannelSelect extends StatefulWidget {
|
||||
const _ShareIntentChannelSelect({required this.value});
|
||||
|
||||
@override
|
||||
State<_ShareIntentChannelSelect> createState() => _ShareIntentChannelSelectState();
|
||||
State<_ShareIntentChannelSelect> createState() =>
|
||||
_ShareIntentChannelSelectState();
|
||||
}
|
||||
|
||||
class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||
@@ -178,8 +192,11 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||
final lastMessages = await chan.getLastMessages(channels);
|
||||
_lastMessages = {for (final val in lastMessages) val.channelId: val};
|
||||
channels.sort((a, b) {
|
||||
if (_lastMessages!.containsKey(a.id) && _lastMessages!.containsKey(b.id)) {
|
||||
return _lastMessages![b.id]!.createdAt.compareTo(_lastMessages![a.id]!.createdAt);
|
||||
if (_lastMessages!.containsKey(a.id) &&
|
||||
_lastMessages!.containsKey(b.id)) {
|
||||
return _lastMessages![b.id]!
|
||||
.createdAt
|
||||
.compareTo(_lastMessages![a.id]!.createdAt);
|
||||
}
|
||||
if (_lastMessages!.containsKey(a.id)) return -1;
|
||||
if (_lastMessages!.containsKey(b.id)) return 1;
|
||||
@@ -232,7 +249,9 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||
children: [
|
||||
const Icon(Symbols.chat, size: 24),
|
||||
const Gap(16),
|
||||
Text('shareIntentSendChannel', style: Theme.of(context).textTheme.titleLarge).tr(),
|
||||
Text('shareIntentSendChannel',
|
||||
style: Theme.of(context).textTheme.titleLarge)
|
||||
.tr(),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
@@ -249,29 +268,34 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||
final lastMessage = _lastMessages?[channel.id];
|
||||
|
||||
if (channel.type == 1) {
|
||||
final otherMember = channel.members?.cast<SnChannelMember?>().firstWhere(
|
||||
(ele) => ele?.accountId != ua.user?.id,
|
||||
orElse: () => null,
|
||||
);
|
||||
final otherMember =
|
||||
channel.members?.cast<SnChannelMember?>().firstWhere(
|
||||
(ele) => ele?.accountId != ua.user?.id,
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
return ListTile(
|
||||
title: Text(ud.getAccountFromCache(otherMember?.accountId)?.nick ?? channel.name),
|
||||
title: Text(
|
||||
ud.getFromCache(otherMember?.accountId)?.nick ??
|
||||
channel.name),
|
||||
subtitle: lastMessage != null
|
||||
? Text(
|
||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||
'${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
: Text(
|
||||
'channelDirectMessageDescription'.tr(args: [
|
||||
'@${ud.getAccountFromCache(otherMember?.accountId)?.name}',
|
||||
'@${ud.getFromCache(otherMember?.accountId)?.name}',
|
||||
]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16),
|
||||
leading: AccountImage(
|
||||
content: ud.getAccountFromCache(otherMember?.accountId)?.avatar,
|
||||
content:
|
||||
ud.getFromCache(otherMember?.accountId)?.avatar,
|
||||
),
|
||||
onTap: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
@@ -291,7 +315,7 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||
title: Text(channel.name),
|
||||
subtitle: lastMessage != null
|
||||
? Text(
|
||||
'${ud.getAccountFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||
'${ud.getFromCache(lastMessage.sender.accountId)?.nick}: ${lastMessage.body['text'] ?? 'Unable preview'}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
@@ -316,13 +340,20 @@ class _ShareIntentChannelSelectState extends State<_ShareIntentChannelSelect> {
|
||||
},
|
||||
extra: ChatRoomScreenExtra(
|
||||
initialText: widget.value
|
||||
.where((e) => [SharedMediaType.text, SharedMediaType.url].contains(e.type))
|
||||
.where((e) => [
|
||||
SharedMediaType.text,
|
||||
SharedMediaType.url
|
||||
].contains(e.type))
|
||||
.map((e) => e.path)
|
||||
.join('\n'),
|
||||
initialAttachments: widget.value
|
||||
.where((e) =>
|
||||
[SharedMediaType.video, SharedMediaType.file, SharedMediaType.image].contains(e.type))
|
||||
.map((e) => PostWriteMedia.fromFile(XFile(e.path)))
|
||||
.where((e) => [
|
||||
SharedMediaType.video,
|
||||
SharedMediaType.file,
|
||||
SharedMediaType.image
|
||||
].contains(e.type))
|
||||
.map(
|
||||
(e) => PostWriteMedia.fromFile(XFile(e.path)))
|
||||
.toList(),
|
||||
),
|
||||
)
|
||||
|
||||
469
lib/screens/stickers.dart
Normal file
469
lib/screens/stickers.dart
Normal file
@@ -0,0 +1,469 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/providers/sn_sticker.dart';
|
||||
import 'package:surface/providers/userinfo.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/app_bar_leading.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
|
||||
|
||||
class StickerScreen extends StatefulWidget {
|
||||
const StickerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<StickerScreen> createState() => _StickerScreenState();
|
||||
}
|
||||
|
||||
class _StickerScreenState extends State<StickerScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final TabController _tabController =
|
||||
TabController(length: 3, vsync: this);
|
||||
|
||||
bool _isBusy = false;
|
||||
int? _totalCount;
|
||||
final List<SnStickerPack> _packs = List.empty(growable: true);
|
||||
|
||||
Future<void> _fetchPacks() async {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final ua = context.read<UserProvider>();
|
||||
final resp = await sn.client.get(
|
||||
_tabController.index == 1
|
||||
? '/cgi/uc/stickers/packs/own'
|
||||
: '/cgi/uc/stickers/packs',
|
||||
queryParameters: {
|
||||
'take': 10,
|
||||
'offset': _packs.length,
|
||||
if (_tabController.index == 2) 'author': ua.user?.id,
|
||||
},
|
||||
);
|
||||
if (resp.data is Map<String, dynamic>) {
|
||||
_totalCount = resp.data['count'] as int?;
|
||||
final out = List<SnStickerPack>.from(
|
||||
resp.data['data'].map((ele) => SnStickerPack.fromJson(ele)),
|
||||
);
|
||||
_packs.addAll(out);
|
||||
} else {
|
||||
_totalCount = 0;
|
||||
final out = List<SnStickerPack>.from(
|
||||
resp.data.map((ele) => SnStickerPack.fromJson(ele)),
|
||||
);
|
||||
_packs.addAll(out);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removePack(SnStickerPack pack) async {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}/own');
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('stickersRemoved'.tr());
|
||||
_refreshPacks();
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deletePack(SnStickerPack pack) async {
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'stickersPackDelete'.tr(args: [pack.name]),
|
||||
'stickersPackDeleteDescription'.tr(),
|
||||
);
|
||||
if (!confirm) return;
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/uc/stickers/packs/${pack.id}');
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('stickersDeleted'.tr());
|
||||
_refreshPacks();
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshPacks() async {
|
||||
_packs.clear();
|
||||
_totalCount = null;
|
||||
await _fetchPacks();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPacks();
|
||||
_tabController.addListener(() {
|
||||
if (_tabController.indexIsChanging) {
|
||||
_refreshPacks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
leading: AutoAppBarLeading(),
|
||||
title: Text('screenStickers').tr(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Symbols.add_circle),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _StickerPackCreateDialog(),
|
||||
).then((value) {
|
||||
if (value == true) _refreshPacks();
|
||||
});
|
||||
},
|
||||
),
|
||||
const Gap(8),
|
||||
],
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
tabs: [
|
||||
Tab(
|
||||
child: Text('stickersDiscovery'.tr()).textColor(
|
||||
Theme.of(context).appBarTheme.foregroundColor,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text('stickersOwned'.tr()).textColor(
|
||||
Theme.of(context).appBarTheme.foregroundColor,
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: Text('stickersCreated'.tr()).textColor(
|
||||
Theme.of(context).appBarTheme.foregroundColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeTop: true,
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _refreshPacks,
|
||||
child: InfiniteList(
|
||||
itemCount: _packs.length,
|
||||
onFetchData: _fetchPacks,
|
||||
hasReachedMax:
|
||||
(_totalCount != null && _packs.length >= _totalCount!) ||
|
||||
_tabController.index == 2,
|
||||
isLoading: _isBusy,
|
||||
itemBuilder: (context, idx) {
|
||||
final pack = _packs[idx];
|
||||
return ListTile(
|
||||
title: Text(pack.name),
|
||||
subtitle: Text(
|
||||
pack.description,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
trailing: _tabController.index == 1
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_removePack(pack);
|
||||
},
|
||||
icon: const Icon(Symbols.remove),
|
||||
)
|
||||
: _tabController.index == 2
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
_deletePack(pack);
|
||||
},
|
||||
icon: const Icon(Symbols.delete),
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
if (_tabController.index == 0) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => _StickerPackAddPopup(pack: pack),
|
||||
).then((value) {
|
||||
if (value == true && _tabController.index == 1) {
|
||||
_refreshPacks();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
GoRouter.of(context).pushNamed(
|
||||
'stickerPack',
|
||||
pathParameters: {
|
||||
'id': pack.id.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StickerPackAddPopup extends StatefulWidget {
|
||||
final SnStickerPack pack;
|
||||
const _StickerPackAddPopup({required this.pack});
|
||||
|
||||
@override
|
||||
State<_StickerPackAddPopup> createState() => _StickerPackAddPopupState();
|
||||
}
|
||||
|
||||
class _StickerPackAddPopupState extends State<_StickerPackAddPopup> {
|
||||
SnStickerPack? _pack;
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _fetchPack() async {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp =
|
||||
await sn.client.get('/cgi/uc/stickers/packs/${widget.pack.id}');
|
||||
_pack = SnStickerPack.fromJson(resp.data);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPack();
|
||||
}
|
||||
|
||||
bool _isAdding = false;
|
||||
|
||||
Future<void> _addPack() async {
|
||||
if (_pack == null) return;
|
||||
|
||||
try {
|
||||
setState(() => _isAdding = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final stickers = context.read<SnStickerProvider>();
|
||||
await sn.client.post(
|
||||
'/cgi/uc/stickers/packs/${widget.pack.id}/own',
|
||||
);
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('stickersAdded'.tr());
|
||||
if (_pack?.stickers != null) {
|
||||
stickers.putSticker(
|
||||
_pack!.stickers!.map((ele) => ele.copyWith(pack: _pack!)));
|
||||
}
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isAdding = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Symbols.add, size: 24),
|
||||
const Gap(16),
|
||||
Text('stickersAdd', style: Theme.of(context).textTheme.titleLarge)
|
||||
.tr(),
|
||||
],
|
||||
).padding(horizontal: 20, top: 16, bottom: 12),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.pack.name).bold(),
|
||||
Text(
|
||||
widget.pack.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: _isAdding ? null : _addPack,
|
||||
child: Text('add').tr(),
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24),
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
if (_pack?.stickers != null)
|
||||
Expanded(
|
||||
child: GridView.extent(
|
||||
padding: EdgeInsets.only(left: 20, right: 20, top: 8),
|
||||
maxCrossAxisExtent: 48,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
children: _pack!.stickers!
|
||||
.map(
|
||||
(ele) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color:
|
||||
Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
child: AttachmentItem(
|
||||
data: ele.attachment,
|
||||
heroTag: 'sticker-pack-${ele.attachment.rid}',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StickerPackCreateDialog extends StatefulWidget {
|
||||
const _StickerPackCreateDialog();
|
||||
|
||||
@override
|
||||
State<_StickerPackCreateDialog> createState() =>
|
||||
_StickerPackCreateDialogState();
|
||||
}
|
||||
|
||||
class _StickerPackCreateDialogState extends State<_StickerPackCreateDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _prefixController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _createPack() async {
|
||||
if (_nameController.text.isEmpty ||
|
||||
_prefixController.text.isEmpty ||
|
||||
_descriptionController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post(
|
||||
'/cgi/uc/stickers/packs',
|
||||
data: {
|
||||
'name': _nameController.text,
|
||||
'prefix': _prefixController.text,
|
||||
'description': _descriptionController.text,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_prefixController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('stickersPackNew').tr(),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldStickerPackName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _prefixController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldStickerPackPrefix'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldStickerPackDescription'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _createPack(),
|
||||
child: Text('dialogConfirm').tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
266
lib/screens/stickers/pack_detail.dart
Normal file
266
lib/screens/stickers/pack_detail.dart
Normal file
@@ -0,0 +1,266 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:styled_widget/styled_widget.dart';
|
||||
import 'package:surface/providers/sn_network.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_input.dart';
|
||||
import 'package:surface/widgets/attachment/attachment_item.dart';
|
||||
import 'package:surface/widgets/dialog.dart';
|
||||
import 'package:surface/widgets/loading_indicator.dart';
|
||||
import 'package:surface/widgets/navigation/app_scaffold.dart';
|
||||
|
||||
class StickerPackScreen extends StatefulWidget {
|
||||
final int id;
|
||||
const StickerPackScreen({super.key, required this.id});
|
||||
|
||||
@override
|
||||
State<StickerPackScreen> createState() => _StickerPackScreenState();
|
||||
}
|
||||
|
||||
class _StickerPackScreenState extends State<StickerPackScreen> {
|
||||
SnStickerPack? _pack;
|
||||
|
||||
Future<void> _fetchPack() async {
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
final resp = await sn.client.get('/cgi/uc/stickers/packs/${widget.id}');
|
||||
_pack = SnStickerPack.fromJson(resp.data);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
Future<void> _deleteSticker(SnSticker sticker) async {
|
||||
final confirm = await context.showConfirmDialog(
|
||||
'stickersDelete'.tr(args: [sticker.name]),
|
||||
'stickersDeleteDescription'.tr(),
|
||||
);
|
||||
if (!confirm) return;
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
setState(() => _isBusy = true);
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.delete('/cgi/uc/stickers/${sticker.id}');
|
||||
if (!mounted) return;
|
||||
context.showSnackbar('stickersDeleted'.tr());
|
||||
_fetchPack();
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
} finally {
|
||||
setState(() => _isBusy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_fetchPack();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_pack?.name ?? 'loading'.tr()),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
LoadingIndicator(isActive: _isBusy),
|
||||
if (_pack != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_pack!.name).bold(),
|
||||
Text(
|
||||
_pack!.description,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
).padding(horizontal: 24, vertical: 16),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.add),
|
||||
title: Text('stickersNew').tr(),
|
||||
subtitle: Text('stickersNewDescription').tr(),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _StickerCreateDialog(pack: _pack!),
|
||||
).then((value) {
|
||||
if (value) _fetchPack();
|
||||
});
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
if (_pack?.stickers != null)
|
||||
Expanded(
|
||||
child: GridView.extent(
|
||||
padding: EdgeInsets.only(left: 20, right: 20, top: 16),
|
||||
maxCrossAxisExtent: 48,
|
||||
mainAxisSpacing: 8,
|
||||
crossAxisSpacing: 8,
|
||||
children: _pack!.stickers!
|
||||
.map(
|
||||
(ele) => GestureDetector(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
child: AttachmentItem(
|
||||
data: ele.attachment,
|
||||
heroTag: 'sticker-pack-${ele.attachment.rid}',
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
_deleteSticker(ele);
|
||||
},
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StickerCreateDialog extends StatefulWidget {
|
||||
final SnStickerPack pack;
|
||||
const _StickerCreateDialog({required this.pack});
|
||||
|
||||
@override
|
||||
State<_StickerCreateDialog> createState() => _StickerCreateDialogState();
|
||||
}
|
||||
|
||||
class _StickerCreateDialogState extends State<_StickerCreateDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
final TextEditingController _aliasController = TextEditingController();
|
||||
final TextEditingController _attachmentController = TextEditingController();
|
||||
|
||||
bool _isBusy = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_aliasController.dispose();
|
||||
_attachmentController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _createSticker() async {
|
||||
if (_nameController.text.isEmpty ||
|
||||
_aliasController.text.isEmpty ||
|
||||
_attachmentController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isBusy = true);
|
||||
|
||||
try {
|
||||
final sn = context.read<SnNetworkProvider>();
|
||||
await sn.client.post(
|
||||
'/cgi/uc/stickers',
|
||||
data: {
|
||||
'name': _nameController.text,
|
||||
'alias': _aliasController.text,
|
||||
'attachment_id': _attachmentController.text,
|
||||
'pack_id': widget.pack.id,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.pop(context, true);
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
context.showErrorDialog(err);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('stickersNew'.tr()),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldStickerName'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _aliasController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldStickerAlias'.tr(),
|
||||
helperText: 'fieldStickerAliasHint'.tr(),
|
||||
),
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: _attachmentController,
|
||||
decoration: InputDecoration(
|
||||
border: const UnderlineInputBorder(),
|
||||
labelText: 'fieldStickerAttachment'.tr(),
|
||||
),
|
||||
readOnly: true,
|
||||
onTap: () async {
|
||||
final attachment = await showDialog<SnAttachment?>(
|
||||
context: context,
|
||||
builder: (context) => AttachmentInputDialog(
|
||||
title: 'fieldStickerAttachment'.tr(),
|
||||
pool: 'sticker',
|
||||
mediaType: SnMediaType.image,
|
||||
),
|
||||
);
|
||||
if (attachment != null) {
|
||||
setState(() {
|
||||
_attachmentController.text = attachment.rid;
|
||||
});
|
||||
}
|
||||
},
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isBusy
|
||||
? null
|
||||
: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: Text('dialogDismiss').tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _isBusy ? null : () => _createSticker(),
|
||||
child: Text('dialogConfirm').tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,19 @@ class ThemeSet {
|
||||
ThemeSet({required this.light, required this.dark});
|
||||
}
|
||||
|
||||
Future<ThemeSet> createAppThemeSet({Color? seedColorOverride, bool? useMaterial3}) async {
|
||||
Future<ThemeSet> createAppThemeSet(
|
||||
{Color? seedColorOverride, bool? useMaterial3, String? customFonts}) async {
|
||||
return ThemeSet(
|
||||
light: await createAppTheme(Brightness.light, useMaterial3: useMaterial3),
|
||||
dark: await createAppTheme(Brightness.dark, useMaterial3: useMaterial3),
|
||||
light: await createAppTheme(
|
||||
Brightness.light,
|
||||
useMaterial3: useMaterial3,
|
||||
customFonts: customFonts,
|
||||
),
|
||||
dark: await createAppTheme(
|
||||
Brightness.dark,
|
||||
useMaterial3: useMaterial3,
|
||||
customFonts: customFonts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,24 +31,36 @@ Future<ThemeData> createAppTheme(
|
||||
Brightness brightness, {
|
||||
Color? seedColorOverride,
|
||||
bool? useMaterial3,
|
||||
String? customFonts,
|
||||
}) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final seedColorString = prefs.getInt(kAppColorSchemeStoreKey);
|
||||
final seedColor = seedColorString != null ? Color(seedColorString) : Colors.indigo;
|
||||
final seedColor =
|
||||
seedColorString != null ? Color(seedColorString) : Colors.indigo;
|
||||
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: seedColorOverride ?? seedColor,
|
||||
brightness: brightness,
|
||||
);
|
||||
|
||||
final hasAppBarTransparent = prefs.getBool(kAppbarTransparentStoreKey) ?? false;
|
||||
final useM3 = useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
|
||||
final hasAppBarTransparent =
|
||||
prefs.getBool(kAppbarTransparentStoreKey) ?? false;
|
||||
final useM3 =
|
||||
useMaterial3 ?? (prefs.getBool(kMaterialYouToggleStoreKey) ?? true);
|
||||
|
||||
final inUseFonts = (customFonts ?? prefs.getString(kAppCustomFonts))
|
||||
?.split(',')
|
||||
.map((ele) => ele.trim())
|
||||
.toList() ??
|
||||
['Nunito'];
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: useM3,
|
||||
colorScheme: colorScheme,
|
||||
brightness: brightness,
|
||||
fontFamily: inUseFonts.firstOrNull,
|
||||
fontFamilyFallback: inUseFonts.sublist(1),
|
||||
iconTheme: IconThemeData(
|
||||
fill: 0,
|
||||
weight: 400,
|
||||
@@ -52,8 +73,10 @@ Future<ThemeData> createAppTheme(
|
||||
appBarTheme: AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: hasAppBarTransparent ? 0 : null,
|
||||
backgroundColor: hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
|
||||
foregroundColor: hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
|
||||
backgroundColor:
|
||||
hasAppBarTransparent ? Colors.transparent : colorScheme.primary,
|
||||
foregroundColor:
|
||||
hasAppBarTransparent ? colorScheme.onSurface : colorScheme.onPrimary,
|
||||
),
|
||||
pageTransitionsTheme: PageTransitionsTheme(
|
||||
builders: {
|
||||
@@ -67,3 +90,20 @@ Future<ThemeData> createAppTheme(
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
extension HexColor on Color {
|
||||
/// String is in the format "aabbcc" or "ffaabbcc" with an optional leading "#".
|
||||
static Color fromHex(String hexString) {
|
||||
final buffer = StringBuffer();
|
||||
if (hexString.length == 6 || hexString.length == 7) buffer.write('ff');
|
||||
buffer.write(hexString.replaceFirst('#', ''));
|
||||
return Color(int.parse(buffer.toString(), radix: 16));
|
||||
}
|
||||
|
||||
/// Prefixes a hash sign if [leadingHashSign] is set to `true` (default is `true`).
|
||||
String toHex({bool leadingHashSign = true}) => '${leadingHashSign ? '#' : ''}'
|
||||
'${alpha.toRadixString(16).padLeft(2, '0')}'
|
||||
'${red.toRadixString(16).padLeft(2, '0')}'
|
||||
'${green.toRadixString(16).padLeft(2, '0')}'
|
||||
'${blue.toRadixString(16).padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'account.freezed.dart';
|
||||
part 'account.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnAccount with _$SnAccount {
|
||||
abstract class SnAccount with _$SnAccount {
|
||||
const SnAccount._();
|
||||
|
||||
const factory SnAccount({
|
||||
@HiveField(0) required int id,
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
@@ -17,7 +16,6 @@ class SnAccount with _$SnAccount {
|
||||
required List<SnAccountContact>? contacts,
|
||||
@Default("") String avatar,
|
||||
@Default("") String banner,
|
||||
required String description,
|
||||
required String name,
|
||||
required String nick,
|
||||
@Default({}) Map<String, dynamic> permNodes,
|
||||
@@ -36,7 +34,7 @@ class SnAccount with _$SnAccount {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountContact with _$SnAccountContact {
|
||||
abstract class SnAccountContact with _$SnAccountContact {
|
||||
const factory SnAccountContact({
|
||||
required int accountId,
|
||||
required String content,
|
||||
@@ -55,18 +53,24 @@ class SnAccountContact with _$SnAccountContact {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountProfile with _$SnAccountProfile {
|
||||
abstract class SnAccountProfile with _$SnAccountProfile {
|
||||
const factory SnAccountProfile({
|
||||
required int id,
|
||||
required int accountId,
|
||||
required DateTime? birthday,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int experience,
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String description,
|
||||
required String timeZone,
|
||||
required String location,
|
||||
required String pronouns,
|
||||
required String gender,
|
||||
@Default({}) Map<String, String> links,
|
||||
required int experience,
|
||||
required DateTime? lastSeenAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? birthday,
|
||||
required int accountId,
|
||||
}) = _SnAccountProfile;
|
||||
|
||||
factory SnAccountProfile.fromJson(Map<String, Object?> json) =>
|
||||
@@ -74,7 +78,7 @@ class SnAccountProfile with _$SnAccountProfile {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnRelationship with _$SnRelationship {
|
||||
abstract class SnRelationship with _$SnRelationship {
|
||||
const factory SnRelationship({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -93,7 +97,7 @@ class SnRelationship with _$SnRelationship {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountBadge with _$SnAccountBadge {
|
||||
abstract class SnAccountBadge with _$SnAccountBadge {
|
||||
const factory SnAccountBadge({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -101,6 +105,7 @@ class SnAccountBadge with _$SnAccountBadge {
|
||||
required dynamic deletedAt,
|
||||
required String type,
|
||||
required int accountId,
|
||||
@Default(false) bool isActive,
|
||||
@Default({}) Map<String, dynamic> metadata,
|
||||
}) = _SnAccountBadge;
|
||||
|
||||
@@ -109,7 +114,7 @@ class SnAccountBadge with _$SnAccountBadge {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAccountStatusInfo with _$SnAccountStatusInfo {
|
||||
abstract class SnAccountStatusInfo with _$SnAccountStatusInfo {
|
||||
const factory SnAccountStatusInfo({
|
||||
required bool isDisturbable,
|
||||
required bool isOnline,
|
||||
@@ -122,7 +127,7 @@ class SnAccountStatusInfo with _$SnAccountStatusInfo {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAbuseReport with _$SnAbuseReport {
|
||||
abstract class SnAbuseReport with _$SnAbuseReport {
|
||||
const factory SnAbuseReport({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,7 @@ part of 'account.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAccountImpl(
|
||||
_SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -22,7 +21,6 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
.toList(),
|
||||
avatar: json['avatar'] as String? ?? "",
|
||||
banner: json['banner'] as String? ?? "",
|
||||
description: json['description'] as String,
|
||||
name: json['name'] as String,
|
||||
nick: json['nick'] as String,
|
||||
permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
|
||||
@@ -43,7 +41,7 @@ _$SnAccountImpl _$$SnAccountImplFromJson(Map<String, dynamic> json) =>
|
||||
automatedId: (json['automated_id'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
|
||||
Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -53,7 +51,6 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
|
||||
'contacts': instance.contacts?.map((e) => e.toJson()).toList(),
|
||||
'avatar': instance.avatar,
|
||||
'banner': instance.banner,
|
||||
'description': instance.description,
|
||||
'name': instance.name,
|
||||
'nick': instance.nick,
|
||||
'perm_nodes': instance.permNodes,
|
||||
@@ -67,9 +64,8 @@ Map<String, dynamic> _$$SnAccountImplToJson(_$SnAccountImpl instance) =>
|
||||
'automated_id': instance.automatedId,
|
||||
};
|
||||
|
||||
_$SnAccountContactImpl _$$SnAccountContactImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountContactImpl(
|
||||
_SnAccountContact _$SnAccountContactFromJson(Map<String, dynamic> json) =>
|
||||
_SnAccountContact(
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
content: json['content'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
@@ -86,8 +82,7 @@ _$SnAccountContactImpl _$$SnAccountContactImplFromJson(
|
||||
: DateTime.parse(json['verified_at'] as String),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountContactImplToJson(
|
||||
_$SnAccountContactImpl instance) =>
|
||||
Map<String, dynamic> _$SnAccountContactToJson(_SnAccountContact instance) =>
|
||||
<String, dynamic>{
|
||||
'account_id': instance.accountId,
|
||||
'content': instance.content,
|
||||
@@ -101,44 +96,57 @@ Map<String, dynamic> _$$SnAccountContactImplToJson(
|
||||
'verified_at': instance.verifiedAt?.toIso8601String(),
|
||||
};
|
||||
|
||||
_$SnAccountProfileImpl _$$SnAccountProfileImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountProfileImpl(
|
||||
_SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) =>
|
||||
_SnAccountProfile(
|
||||
id: (json['id'] as num).toInt(),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['deleted_at'] as String),
|
||||
experience: (json['experience'] as num).toInt(),
|
||||
firstName: json['first_name'] as String,
|
||||
lastName: json['last_name'] as String,
|
||||
description: json['description'] as String,
|
||||
timeZone: json['time_zone'] as String,
|
||||
location: json['location'] as String,
|
||||
pronouns: json['pronouns'] as String,
|
||||
gender: json['gender'] as String,
|
||||
links: (json['links'] as Map<String, dynamic>?)?.map(
|
||||
(k, e) => MapEntry(k, e as String),
|
||||
) ??
|
||||
const {},
|
||||
experience: (json['experience'] as num).toInt(),
|
||||
lastSeenAt: json['last_seen_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['last_seen_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
birthday: json['birthday'] == null
|
||||
? null
|
||||
: DateTime.parse(json['birthday'] as String),
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountProfileImplToJson(
|
||||
_$SnAccountProfileImpl instance) =>
|
||||
Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'account_id': instance.accountId,
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||
'experience': instance.experience,
|
||||
'first_name': instance.firstName,
|
||||
'last_name': instance.lastName,
|
||||
'description': instance.description,
|
||||
'time_zone': instance.timeZone,
|
||||
'location': instance.location,
|
||||
'pronouns': instance.pronouns,
|
||||
'gender': instance.gender,
|
||||
'links': instance.links,
|
||||
'experience': instance.experience,
|
||||
'last_seen_at': instance.lastSeenAt?.toIso8601String(),
|
||||
'updated_at': instance.updatedAt.toIso8601String(),
|
||||
'birthday': instance.birthday?.toIso8601String(),
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnRelationshipImpl(
|
||||
_SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
|
||||
_SnRelationship(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -157,8 +165,7 @@ _$SnRelationshipImpl _$$SnRelationshipImplFromJson(Map<String, dynamic> json) =>
|
||||
permNodes: json['perm_nodes'] as Map<String, dynamic>? ?? const {},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnRelationshipImplToJson(
|
||||
_$SnRelationshipImpl instance) =>
|
||||
Map<String, dynamic> _$SnRelationshipToJson(_SnRelationship instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -172,19 +179,19 @@ Map<String, dynamic> _$$SnRelationshipImplToJson(
|
||||
'perm_nodes': instance.permNodes,
|
||||
};
|
||||
|
||||
_$SnAccountBadgeImpl _$$SnAccountBadgeImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAccountBadgeImpl(
|
||||
_SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) =>
|
||||
_SnAccountBadge(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
deletedAt: json['deleted_at'],
|
||||
type: json['type'] as String,
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
isActive: json['is_active'] as bool? ?? false,
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountBadgeImplToJson(
|
||||
_$SnAccountBadgeImpl instance) =>
|
||||
Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -192,12 +199,12 @@ Map<String, dynamic> _$$SnAccountBadgeImplToJson(
|
||||
'deleted_at': instance.deletedAt,
|
||||
'type': instance.type,
|
||||
'account_id': instance.accountId,
|
||||
'is_active': instance.isActive,
|
||||
'metadata': instance.metadata,
|
||||
};
|
||||
|
||||
_$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAccountStatusInfoImpl(
|
||||
_SnAccountStatusInfo _$SnAccountStatusInfoFromJson(Map<String, dynamic> json) =>
|
||||
_SnAccountStatusInfo(
|
||||
isDisturbable: json['is_disturbable'] as bool,
|
||||
isOnline: json['is_online'] as bool,
|
||||
lastSeenAt: json['last_seen_at'] == null
|
||||
@@ -206,8 +213,8 @@ _$SnAccountStatusInfoImpl _$$SnAccountStatusInfoImplFromJson(
|
||||
status: json['status'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
|
||||
_$SnAccountStatusInfoImpl instance) =>
|
||||
Map<String, dynamic> _$SnAccountStatusInfoToJson(
|
||||
_SnAccountStatusInfo instance) =>
|
||||
<String, dynamic>{
|
||||
'is_disturbable': instance.isDisturbable,
|
||||
'is_online': instance.isOnline,
|
||||
@@ -215,8 +222,8 @@ Map<String, dynamic> _$$SnAccountStatusInfoImplToJson(
|
||||
'status': instance.status,
|
||||
};
|
||||
|
||||
_$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAbuseReportImpl(
|
||||
_SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
|
||||
_SnAbuseReport(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -229,7 +236,7 @@ _$SnAbuseReportImpl _$$SnAbuseReportImplFromJson(Map<String, dynamic> json) =>
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAbuseReportImplToJson(_$SnAbuseReportImpl instance) =>
|
||||
Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
|
||||
@@ -12,7 +12,7 @@ enum SnMediaType {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachment with _$SnAttachment {
|
||||
abstract class SnAttachment with _$SnAttachment {
|
||||
const SnAttachment._();
|
||||
|
||||
const factory SnAttachment({
|
||||
@@ -65,7 +65,7 @@ class SnAttachment with _$SnAttachment {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentFragment with _$SnAttachmentFragment {
|
||||
abstract class SnAttachmentFragment with _$SnAttachmentFragment {
|
||||
const SnAttachmentFragment._();
|
||||
|
||||
const factory SnAttachmentFragment({
|
||||
@@ -96,7 +96,7 @@ class SnAttachmentFragment with _$SnAttachmentFragment {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentPool with _$SnAttachmentPool {
|
||||
abstract class SnAttachmentPool with _$SnAttachmentPool {
|
||||
const factory SnAttachmentPool({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -113,7 +113,7 @@ class SnAttachmentPool with _$SnAttachmentPool {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentDestination with _$SnAttachmentDestination {
|
||||
abstract class SnAttachmentDestination with _$SnAttachmentDestination {
|
||||
const factory SnAttachmentDestination({
|
||||
@Default(0) int id,
|
||||
required String type,
|
||||
@@ -126,7 +126,7 @@ class SnAttachmentDestination with _$SnAttachmentDestination {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAttachmentBoost with _$SnAttachmentBoost {
|
||||
abstract class SnAttachmentBoost with _$SnAttachmentBoost {
|
||||
const factory SnAttachmentBoost({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -143,7 +143,7 @@ class SnAttachmentBoost with _$SnAttachmentBoost {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnSticker with _$SnSticker {
|
||||
abstract class SnSticker with _$SnSticker {
|
||||
const factory SnSticker({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -162,7 +162,7 @@ class SnSticker with _$SnSticker {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnStickerPack with _$SnStickerPack {
|
||||
abstract class SnStickerPack with _$SnStickerPack {
|
||||
const factory SnStickerPack({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -177,3 +177,14 @@ class SnStickerPack with _$SnStickerPack {
|
||||
|
||||
factory SnStickerPack.fromJson(Map<String, Object?> json) => _$SnStickerPackFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class SnAttachmentBilling with _$SnAttachmentBilling {
|
||||
const factory SnAttachmentBilling({
|
||||
required int currentBytes,
|
||||
required int discountFileSize,
|
||||
required double includedRatio,
|
||||
}) = _SnAttachmentBilling;
|
||||
|
||||
factory SnAttachmentBilling.fromJson(Map<String, Object?> json) => _$SnAttachmentBillingFromJson(json);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ part of 'attachment.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAttachmentImpl(
|
||||
_SnAttachment _$SnAttachmentFromJson(Map<String, dynamic> json) =>
|
||||
_SnAttachment(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -57,7 +57,7 @@ _$SnAttachmentImpl _$$SnAttachmentImplFromJson(Map<String, dynamic> json) =>
|
||||
metadata: json['metadata'] as Map<String, dynamic>? ?? const {},
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
|
||||
Map<String, dynamic> _$SnAttachmentToJson(_SnAttachment instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -92,9 +92,9 @@ Map<String, dynamic> _$$SnAttachmentImplToJson(_$SnAttachmentImpl instance) =>
|
||||
'metadata': instance.metadata,
|
||||
};
|
||||
|
||||
_$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
|
||||
_SnAttachmentFragment _$SnAttachmentFragmentFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentFragmentImpl(
|
||||
_SnAttachmentFragment(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -119,8 +119,8 @@ _$SnAttachmentFragmentImpl _$$SnAttachmentFragmentImplFromJson(
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
|
||||
_$SnAttachmentFragmentImpl instance) =>
|
||||
Map<String, dynamic> _$SnAttachmentFragmentToJson(
|
||||
_SnAttachmentFragment instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -138,9 +138,8 @@ Map<String, dynamic> _$$SnAttachmentFragmentImplToJson(
|
||||
'file_chunks_missing': instance.fileChunksMissing,
|
||||
};
|
||||
|
||||
_$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentPoolImpl(
|
||||
_SnAttachmentPool _$SnAttachmentPoolFromJson(Map<String, dynamic> json) =>
|
||||
_SnAttachmentPool(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -154,8 +153,7 @@ _$SnAttachmentPoolImpl _$$SnAttachmentPoolImplFromJson(
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
|
||||
_$SnAttachmentPoolImpl instance) =>
|
||||
Map<String, dynamic> _$SnAttachmentPoolToJson(_SnAttachmentPool instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -168,9 +166,9 @@ Map<String, dynamic> _$$SnAttachmentPoolImplToJson(
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
|
||||
_SnAttachmentDestination _$SnAttachmentDestinationFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentDestinationImpl(
|
||||
_SnAttachmentDestination(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
type: json['type'] as String,
|
||||
label: json['label'] as String,
|
||||
@@ -178,8 +176,8 @@ _$SnAttachmentDestinationImpl _$$SnAttachmentDestinationImplFromJson(
|
||||
isBoost: json['is_boost'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
|
||||
_$SnAttachmentDestinationImpl instance) =>
|
||||
Map<String, dynamic> _$SnAttachmentDestinationToJson(
|
||||
_SnAttachmentDestination instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'type': instance.type,
|
||||
@@ -188,9 +186,8 @@ Map<String, dynamic> _$$SnAttachmentDestinationImplToJson(
|
||||
'is_boost': instance.isBoost,
|
||||
};
|
||||
|
||||
_$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnAttachmentBoostImpl(
|
||||
_SnAttachmentBoost _$SnAttachmentBoostFromJson(Map<String, dynamic> json) =>
|
||||
_SnAttachmentBoost(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -205,8 +202,7 @@ _$SnAttachmentBoostImpl _$$SnAttachmentBoostImplFromJson(
|
||||
account: (json['account'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
|
||||
_$SnAttachmentBoostImpl instance) =>
|
||||
Map<String, dynamic> _$SnAttachmentBoostToJson(_SnAttachmentBoost instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -219,8 +215,7 @@ Map<String, dynamic> _$$SnAttachmentBoostImplToJson(
|
||||
'account': instance.account,
|
||||
};
|
||||
|
||||
_$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnStickerImpl(
|
||||
_SnSticker _$SnStickerFromJson(Map<String, dynamic> json) => _SnSticker(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -237,7 +232,7 @@ _$SnStickerImpl _$$SnStickerImplFromJson(Map<String, dynamic> json) =>
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
|
||||
Map<String, dynamic> _$SnStickerToJson(_SnSticker instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -252,8 +247,8 @@ Map<String, dynamic> _$$SnStickerImplToJson(_$SnStickerImpl instance) =>
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnStickerPackImpl(
|
||||
_SnStickerPack _$SnStickerPackFromJson(Map<String, dynamic> json) =>
|
||||
_SnStickerPack(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -269,7 +264,7 @@ _$SnStickerPackImpl _$$SnStickerPackImplFromJson(Map<String, dynamic> json) =>
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
|
||||
Map<String, dynamic> _$SnStickerPackToJson(_SnStickerPack instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -281,3 +276,18 @@ Map<String, dynamic> _$$SnStickerPackImplToJson(_$SnStickerPackImpl instance) =>
|
||||
'stickers': instance.stickers?.map((e) => e.toJson()).toList(),
|
||||
'account_id': instance.accountId,
|
||||
};
|
||||
|
||||
_SnAttachmentBilling _$SnAttachmentBillingFromJson(Map<String, dynamic> json) =>
|
||||
_SnAttachmentBilling(
|
||||
currentBytes: (json['current_bytes'] as num).toInt(),
|
||||
discountFileSize: (json['discount_file_size'] as num).toInt(),
|
||||
includedRatio: (json['included_ratio'] as num).toDouble(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnAttachmentBillingToJson(
|
||||
_SnAttachmentBilling instance) =>
|
||||
<String, dynamic>{
|
||||
'current_bytes': instance.currentBytes,
|
||||
'discount_file_size': instance.discountFileSize,
|
||||
'included_ratio': instance.includedRatio,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ part 'auth.freezed.dart';
|
||||
part 'auth.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnAuthResult with _$SnAuthResult {
|
||||
abstract class SnAuthResult with _$SnAuthResult {
|
||||
const factory SnAuthResult({
|
||||
required bool isFinished,
|
||||
required SnAuthTicket? ticket,
|
||||
@@ -15,7 +15,7 @@ class SnAuthResult with _$SnAuthResult {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAuthTicket with _$SnAuthTicket {
|
||||
abstract class SnAuthTicket with _$SnAuthTicket {
|
||||
const factory SnAuthTicket({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
@@ -41,7 +41,7 @@ class SnAuthTicket with _$SnAuthTicket {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnAuthFactor with _$SnAuthFactor {
|
||||
abstract class SnAuthFactor with _$SnAuthFactor {
|
||||
const factory SnAuthFactor({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,22 +6,22 @@ part of 'auth.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnAuthResultImpl _$$SnAuthResultImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthResultImpl(
|
||||
_SnAuthResult _$SnAuthResultFromJson(Map<String, dynamic> json) =>
|
||||
_SnAuthResult(
|
||||
isFinished: json['is_finished'] as bool,
|
||||
ticket: json['ticket'] == null
|
||||
? null
|
||||
: SnAuthTicket.fromJson(json['ticket'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthResultImplToJson(_$SnAuthResultImpl instance) =>
|
||||
Map<String, dynamic> _$SnAuthResultToJson(_SnAuthResult instance) =>
|
||||
<String, dynamic>{
|
||||
'is_finished': instance.isFinished,
|
||||
'ticket': instance.ticket?.toJson(),
|
||||
};
|
||||
|
||||
_$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthTicketImpl(
|
||||
_SnAuthTicket _$SnAuthTicketFromJson(Map<String, dynamic> json) =>
|
||||
_SnAuthTicket(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -52,7 +52,7 @@ _$SnAuthTicketImpl _$$SnAuthTicketImplFromJson(Map<String, dynamic> json) =>
|
||||
const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
|
||||
Map<String, dynamic> _$SnAuthTicketToJson(_SnAuthTicket instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -73,8 +73,8 @@ Map<String, dynamic> _$$SnAuthTicketImplToJson(_$SnAuthTicketImpl instance) =>
|
||||
'factor_trail': instance.factorTrail,
|
||||
};
|
||||
|
||||
_$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnAuthFactorImpl(
|
||||
_SnAuthFactor _$SnAuthFactorFromJson(Map<String, dynamic> json) =>
|
||||
_SnAuthFactor(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -86,7 +86,7 @@ _$SnAuthFactorImpl _$$SnAuthFactorImplFromJson(Map<String, dynamic> json) =>
|
||||
accountId: (json['account_id'] as num?)?.toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnAuthFactorImplToJson(_$SnAuthFactorImpl instance) =>
|
||||
Map<String, dynamic> _$SnAuthFactorToJson(_SnAuthFactor instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:livekit_client/livekit_client.dart';
|
||||
import 'package:surface/types/account.dart';
|
||||
import 'package:surface/types/attachment.dart';
|
||||
@@ -9,26 +8,25 @@ part 'chat.freezed.dart';
|
||||
part 'chat.g.dart';
|
||||
|
||||
@freezed
|
||||
class SnChannel with _$SnChannel {
|
||||
abstract class SnChannel with _$SnChannel {
|
||||
const SnChannel._();
|
||||
|
||||
@HiveType(typeId: 2)
|
||||
const factory SnChannel({
|
||||
@HiveField(0) required int id,
|
||||
@HiveField(1) required DateTime createdAt,
|
||||
@HiveField(2) required DateTime updatedAt,
|
||||
@HiveField(3) required dynamic deletedAt,
|
||||
@HiveField(4) required String alias,
|
||||
@HiveField(5) required String name,
|
||||
@HiveField(6) required String description,
|
||||
@HiveField(7) required List<SnChannelMember>? members,
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required dynamic deletedAt,
|
||||
required String alias,
|
||||
required String name,
|
||||
required String description,
|
||||
required List<SnChannelMember>? members,
|
||||
List<SnChatMessage>? messages,
|
||||
@HiveField(8) required int type,
|
||||
@HiveField(9) required int accountId,
|
||||
@HiveField(10) required SnRealm? realm,
|
||||
@HiveField(11) required int? realmId,
|
||||
@HiveField(12) required bool isPublic,
|
||||
@HiveField(13) required bool isCommunity,
|
||||
required int type,
|
||||
required int accountId,
|
||||
required SnRealm? realm,
|
||||
required int? realmId,
|
||||
required bool isPublic,
|
||||
required bool isCommunity,
|
||||
}) = _SnChannel;
|
||||
|
||||
factory SnChannel.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -39,22 +37,21 @@ class SnChannel with _$SnChannel {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnChannelMember with _$SnChannelMember {
|
||||
abstract class SnChannelMember with _$SnChannelMember {
|
||||
const SnChannelMember._();
|
||||
|
||||
@HiveType(typeId: 3)
|
||||
const factory SnChannelMember({
|
||||
@HiveField(0) required int id,
|
||||
@HiveField(1) required DateTime createdAt,
|
||||
@HiveField(2) required DateTime updatedAt,
|
||||
@HiveField(3) required DateTime? deletedAt,
|
||||
@HiveField(4) required int channelId,
|
||||
@HiveField(5) required int accountId,
|
||||
@HiveField(6) required String? nick,
|
||||
@HiveField(7) required SnChannel? channel,
|
||||
@HiveField(8) required SnAccount? account,
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required int channelId,
|
||||
required int accountId,
|
||||
required String? nick,
|
||||
required SnChannel? channel,
|
||||
required SnAccount? account,
|
||||
@Default(0) int notify,
|
||||
@HiveField(9) required int powerLevel,
|
||||
required int powerLevel,
|
||||
dynamic calls,
|
||||
dynamic events,
|
||||
}) = _SnChannelMember;
|
||||
@@ -64,24 +61,23 @@ class SnChannelMember with _$SnChannelMember {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnChatMessage with _$SnChatMessage {
|
||||
abstract class SnChatMessage with _$SnChatMessage {
|
||||
const SnChatMessage._();
|
||||
|
||||
@HiveType(typeId: 4)
|
||||
const factory SnChatMessage({
|
||||
@HiveField(0) required int id,
|
||||
@HiveField(1) required DateTime createdAt,
|
||||
@HiveField(2) required DateTime updatedAt,
|
||||
@HiveField(3) required DateTime? deletedAt,
|
||||
@HiveField(4) required String uuid,
|
||||
@HiveField(5) @Default({}) Map<String, dynamic> body,
|
||||
@HiveField(6) required String type,
|
||||
@HiveField(7) required SnChannel channel,
|
||||
@HiveField(8) required SnChannelMember sender,
|
||||
@HiveField(9) required int channelId,
|
||||
@HiveField(10) required int senderId,
|
||||
@HiveField(11) required int? quoteEventId,
|
||||
@HiveField(12) required int? relatedEventId,
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
required DateTime updatedAt,
|
||||
required DateTime? deletedAt,
|
||||
required String uuid,
|
||||
@Default({}) Map<String, dynamic> body,
|
||||
required String type,
|
||||
required SnChannel channel,
|
||||
required SnChannelMember sender,
|
||||
required int channelId,
|
||||
required int senderId,
|
||||
required int? quoteEventId,
|
||||
required int? relatedEventId,
|
||||
SnChatMessagePreload? preload,
|
||||
}) = _SnChatMessage;
|
||||
|
||||
@@ -90,7 +86,7 @@ class SnChatMessage with _$SnChatMessage {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnChatMessagePreload with _$SnChatMessagePreload {
|
||||
abstract class SnChatMessagePreload with _$SnChatMessagePreload {
|
||||
const SnChatMessagePreload._();
|
||||
|
||||
const factory SnChatMessagePreload({
|
||||
@@ -103,7 +99,7 @@ class SnChatMessagePreload with _$SnChatMessagePreload {
|
||||
}
|
||||
|
||||
@freezed
|
||||
class SnChatCall with _$SnChatCall {
|
||||
abstract class SnChatCall with _$SnChatCall {
|
||||
const factory SnChatCall({
|
||||
required int id,
|
||||
required DateTime createdAt,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,220 +2,11 @@
|
||||
|
||||
part of 'chat.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SnChannelImplAdapter extends TypeAdapter<_$SnChannelImpl> {
|
||||
@override
|
||||
final int typeId = 2;
|
||||
|
||||
@override
|
||||
_$SnChannelImpl read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return _$SnChannelImpl(
|
||||
id: fields[0] as int,
|
||||
createdAt: fields[1] as DateTime,
|
||||
updatedAt: fields[2] as DateTime,
|
||||
deletedAt: fields[3] as dynamic,
|
||||
alias: fields[4] as String,
|
||||
name: fields[5] as String,
|
||||
description: fields[6] as String,
|
||||
members: (fields[7] as List?)?.cast<SnChannelMember>(),
|
||||
type: fields[8] as int,
|
||||
accountId: fields[9] as int,
|
||||
realm: fields[10] as SnRealm?,
|
||||
realmId: fields[11] as int?,
|
||||
isPublic: fields[12] as bool,
|
||||
isCommunity: fields[13] as bool,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, _$SnChannelImpl obj) {
|
||||
writer
|
||||
..writeByte(14)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(2)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(3)
|
||||
..write(obj.deletedAt)
|
||||
..writeByte(4)
|
||||
..write(obj.alias)
|
||||
..writeByte(5)
|
||||
..write(obj.name)
|
||||
..writeByte(6)
|
||||
..write(obj.description)
|
||||
..writeByte(8)
|
||||
..write(obj.type)
|
||||
..writeByte(9)
|
||||
..write(obj.accountId)
|
||||
..writeByte(10)
|
||||
..write(obj.realm)
|
||||
..writeByte(11)
|
||||
..write(obj.realmId)
|
||||
..writeByte(12)
|
||||
..write(obj.isPublic)
|
||||
..writeByte(13)
|
||||
..write(obj.isCommunity)
|
||||
..writeByte(7)
|
||||
..write(obj.members);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SnChannelImplAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SnChannelMemberImplAdapter extends TypeAdapter<_$SnChannelMemberImpl> {
|
||||
@override
|
||||
final int typeId = 3;
|
||||
|
||||
@override
|
||||
_$SnChannelMemberImpl read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return _$SnChannelMemberImpl(
|
||||
id: fields[0] as int,
|
||||
createdAt: fields[1] as DateTime,
|
||||
updatedAt: fields[2] as DateTime,
|
||||
deletedAt: fields[3] as DateTime?,
|
||||
channelId: fields[4] as int,
|
||||
accountId: fields[5] as int,
|
||||
nick: fields[6] as String?,
|
||||
channel: fields[7] as SnChannel?,
|
||||
account: fields[8] as SnAccount?,
|
||||
powerLevel: fields[9] as int,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, _$SnChannelMemberImpl obj) {
|
||||
writer
|
||||
..writeByte(10)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(2)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(3)
|
||||
..write(obj.deletedAt)
|
||||
..writeByte(4)
|
||||
..write(obj.channelId)
|
||||
..writeByte(5)
|
||||
..write(obj.accountId)
|
||||
..writeByte(6)
|
||||
..write(obj.nick)
|
||||
..writeByte(7)
|
||||
..write(obj.channel)
|
||||
..writeByte(8)
|
||||
..write(obj.account)
|
||||
..writeByte(9)
|
||||
..write(obj.powerLevel);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SnChannelMemberImplAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
class SnChatMessageImplAdapter extends TypeAdapter<_$SnChatMessageImpl> {
|
||||
@override
|
||||
final int typeId = 4;
|
||||
|
||||
@override
|
||||
_$SnChatMessageImpl read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return _$SnChatMessageImpl(
|
||||
id: fields[0] as int,
|
||||
createdAt: fields[1] as DateTime,
|
||||
updatedAt: fields[2] as DateTime,
|
||||
deletedAt: fields[3] as DateTime?,
|
||||
uuid: fields[4] as String,
|
||||
body: (fields[5] as Map).cast<String, dynamic>(),
|
||||
type: fields[6] as String,
|
||||
channel: fields[7] as SnChannel,
|
||||
sender: fields[8] as SnChannelMember,
|
||||
channelId: fields[9] as int,
|
||||
senderId: fields[10] as int,
|
||||
quoteEventId: fields[11] as int?,
|
||||
relatedEventId: fields[12] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, _$SnChatMessageImpl obj) {
|
||||
writer
|
||||
..writeByte(13)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.createdAt)
|
||||
..writeByte(2)
|
||||
..write(obj.updatedAt)
|
||||
..writeByte(3)
|
||||
..write(obj.deletedAt)
|
||||
..writeByte(4)
|
||||
..write(obj.uuid)
|
||||
..writeByte(6)
|
||||
..write(obj.type)
|
||||
..writeByte(7)
|
||||
..write(obj.channel)
|
||||
..writeByte(8)
|
||||
..write(obj.sender)
|
||||
..writeByte(9)
|
||||
..write(obj.channelId)
|
||||
..writeByte(10)
|
||||
..write(obj.senderId)
|
||||
..writeByte(11)
|
||||
..write(obj.quoteEventId)
|
||||
..writeByte(12)
|
||||
..write(obj.relatedEventId)
|
||||
..writeByte(5)
|
||||
..write(obj.body);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SnChatMessageImplAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnChannelImpl(
|
||||
_SnChannel _$SnChannelFromJson(Map<String, dynamic> json) => _SnChannel(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -239,7 +30,7 @@ _$SnChannelImpl _$$SnChannelImplFromJson(Map<String, dynamic> json) =>
|
||||
isCommunity: json['is_community'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl instance) =>
|
||||
Map<String, dynamic> _$SnChannelToJson(_SnChannel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -258,9 +49,8 @@ Map<String, dynamic> _$$SnChannelImplToJson(_$SnChannelImpl instance) =>
|
||||
'is_community': instance.isCommunity,
|
||||
};
|
||||
|
||||
_$SnChannelMemberImpl _$$SnChannelMemberImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnChannelMemberImpl(
|
||||
_SnChannelMember _$SnChannelMemberFromJson(Map<String, dynamic> json) =>
|
||||
_SnChannelMember(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -282,8 +72,7 @@ _$SnChannelMemberImpl _$$SnChannelMemberImplFromJson(
|
||||
events: json['events'],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChannelMemberImplToJson(
|
||||
_$SnChannelMemberImpl instance) =>
|
||||
Map<String, dynamic> _$SnChannelMemberToJson(_SnChannelMember instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -300,8 +89,8 @@ Map<String, dynamic> _$$SnChannelMemberImplToJson(
|
||||
'events': instance.events,
|
||||
};
|
||||
|
||||
_$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnChatMessageImpl(
|
||||
_SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) =>
|
||||
_SnChatMessage(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -323,7 +112,7 @@ _$SnChatMessageImpl _$$SnChatMessageImplFromJson(Map<String, dynamic> json) =>
|
||||
json['preload'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
|
||||
Map<String, dynamic> _$SnChatMessageToJson(_SnChatMessage instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
@@ -341,9 +130,9 @@ Map<String, dynamic> _$$SnChatMessageImplToJson(_$SnChatMessageImpl instance) =>
|
||||
'preload': instance.preload?.toJson(),
|
||||
};
|
||||
|
||||
_$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
|
||||
_SnChatMessagePreload _$SnChatMessagePreloadFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnChatMessagePreloadImpl(
|
||||
_SnChatMessagePreload(
|
||||
attachments: (json['attachments'] as List<dynamic>?)
|
||||
?.map((e) => e == null
|
||||
? null
|
||||
@@ -354,15 +143,14 @@ _$SnChatMessagePreloadImpl _$$SnChatMessagePreloadImplFromJson(
|
||||
: SnChatMessage.fromJson(json['quote_event'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChatMessagePreloadImplToJson(
|
||||
_$SnChatMessagePreloadImpl instance) =>
|
||||
Map<String, dynamic> _$SnChatMessagePreloadToJson(
|
||||
_SnChatMessagePreload instance) =>
|
||||
<String, dynamic>{
|
||||
'attachments': instance.attachments?.map((e) => e?.toJson()).toList(),
|
||||
'quote_event': instance.quoteEvent?.toJson(),
|
||||
};
|
||||
|
||||
_$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
|
||||
_$SnChatCallImpl(
|
||||
_SnChatCall _$SnChatCallFromJson(Map<String, dynamic> json) => _SnChatCall(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -380,7 +168,7 @@ _$SnChatCallImpl _$$SnChatCallImplFromJson(Map<String, dynamic> json) =>
|
||||
participants: json['participants'] as List<dynamic>? ?? const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnChatCallImplToJson(_$SnChatCallImpl instance) =>
|
||||
Map<String, dynamic> _$SnChatCallToJson(_SnChatCall instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
|
||||
@@ -14,7 +14,7 @@ final List<String> kCheckInResultTierSymbols = [
|
||||
].map((e) => e.tr()).toList();
|
||||
|
||||
@freezed
|
||||
class SnCheckInRecord with _$SnCheckInRecord {
|
||||
abstract class SnCheckInRecord with _$SnCheckInRecord {
|
||||
const SnCheckInRecord._();
|
||||
|
||||
const factory SnCheckInRecord({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// dart format width=80
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
@@ -9,128 +10,81 @@ part of 'check_in.dart';
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) {
|
||||
return _SnCheckInRecord.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnCheckInRecord {
|
||||
int get id => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
DateTime get updatedAt => throw _privateConstructorUsedError;
|
||||
DateTime? get deletedAt => throw _privateConstructorUsedError;
|
||||
int get resultTier => throw _privateConstructorUsedError;
|
||||
int get resultExperience => throw _privateConstructorUsedError;
|
||||
double get resultCoin => throw _privateConstructorUsedError;
|
||||
List<int> get resultModifiers => throw _privateConstructorUsedError;
|
||||
int get accountId => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SnCheckInRecord to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
int get id;
|
||||
DateTime get createdAt;
|
||||
DateTime get updatedAt;
|
||||
DateTime? get deletedAt;
|
||||
int get resultTier;
|
||||
int get resultExperience;
|
||||
double get resultCoin;
|
||||
List<int> get resultModifiers;
|
||||
int get accountId;
|
||||
|
||||
/// Create a copy of SnCheckInRecord
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
$SnCheckInRecordCopyWith<SnCheckInRecord> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SnCheckInRecordCopyWith<$Res> {
|
||||
factory $SnCheckInRecordCopyWith(
|
||||
SnCheckInRecord value, $Res Function(SnCheckInRecord) then) =
|
||||
_$SnCheckInRecordCopyWithImpl<$Res, SnCheckInRecord>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
int resultTier,
|
||||
int resultExperience,
|
||||
double resultCoin,
|
||||
List<int> resultModifiers,
|
||||
int accountId});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SnCheckInRecordCopyWithImpl<$Res, $Val extends SnCheckInRecord>
|
||||
implements $SnCheckInRecordCopyWith<$Res> {
|
||||
_$SnCheckInRecordCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SnCheckInRecord
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnCheckInRecordCopyWith<SnCheckInRecord> get copyWith =>
|
||||
_$SnCheckInRecordCopyWithImpl<SnCheckInRecord>(
|
||||
this as SnCheckInRecord, _$identity);
|
||||
|
||||
/// Serializes this SnCheckInRecord to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? resultTier = null,
|
||||
Object? resultExperience = null,
|
||||
Object? resultCoin = null,
|
||||
Object? resultModifiers = null,
|
||||
Object? accountId = null,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _value.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
resultTier: null == resultTier
|
||||
? _value.resultTier
|
||||
: resultTier // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resultExperience: null == resultExperience
|
||||
? _value.resultExperience
|
||||
: resultExperience // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resultCoin: null == resultCoin
|
||||
? _value.resultCoin
|
||||
: resultCoin // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
resultModifiers: null == resultModifiers
|
||||
? _value.resultModifiers
|
||||
: resultModifiers // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
accountId: null == accountId
|
||||
? _value.accountId
|
||||
: accountId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
) as $Val);
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is SnCheckInRecord &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.updatedAt, updatedAt) ||
|
||||
other.updatedAt == updatedAt) &&
|
||||
(identical(other.deletedAt, deletedAt) ||
|
||||
other.deletedAt == deletedAt) &&
|
||||
(identical(other.resultTier, resultTier) ||
|
||||
other.resultTier == resultTier) &&
|
||||
(identical(other.resultExperience, resultExperience) ||
|
||||
other.resultExperience == resultExperience) &&
|
||||
(identical(other.resultCoin, resultCoin) ||
|
||||
other.resultCoin == resultCoin) &&
|
||||
const DeepCollectionEquality()
|
||||
.equals(other.resultModifiers, resultModifiers) &&
|
||||
(identical(other.accountId, accountId) ||
|
||||
other.accountId == accountId));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
resultTier,
|
||||
resultExperience,
|
||||
resultCoin,
|
||||
const DeepCollectionEquality().hash(resultModifiers),
|
||||
accountId);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$SnCheckInRecordImplCopyWith<$Res>
|
||||
implements $SnCheckInRecordCopyWith<$Res> {
|
||||
factory _$$SnCheckInRecordImplCopyWith(_$SnCheckInRecordImpl value,
|
||||
$Res Function(_$SnCheckInRecordImpl) then) =
|
||||
__$$SnCheckInRecordImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
abstract mixin class $SnCheckInRecordCopyWith<$Res> {
|
||||
factory $SnCheckInRecordCopyWith(
|
||||
SnCheckInRecord value, $Res Function(SnCheckInRecord) _then) =
|
||||
_$SnCheckInRecordCopyWithImpl;
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
@@ -145,12 +99,12 @@ abstract class _$$SnCheckInRecordImplCopyWith<$Res>
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$SnCheckInRecordImplCopyWithImpl<$Res>
|
||||
extends _$SnCheckInRecordCopyWithImpl<$Res, _$SnCheckInRecordImpl>
|
||||
implements _$$SnCheckInRecordImplCopyWith<$Res> {
|
||||
__$$SnCheckInRecordImplCopyWithImpl(
|
||||
_$SnCheckInRecordImpl _value, $Res Function(_$SnCheckInRecordImpl) _then)
|
||||
: super(_value, _then);
|
||||
class _$SnCheckInRecordCopyWithImpl<$Res>
|
||||
implements $SnCheckInRecordCopyWith<$Res> {
|
||||
_$SnCheckInRecordCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnCheckInRecord _self;
|
||||
final $Res Function(SnCheckInRecord) _then;
|
||||
|
||||
/// Create a copy of SnCheckInRecord
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -167,41 +121,41 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
|
||||
Object? resultModifiers = null,
|
||||
Object? accountId = null,
|
||||
}) {
|
||||
return _then(_$SnCheckInRecordImpl(
|
||||
return _then(_self.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
? _self.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
? _self.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _value.deletedAt
|
||||
? _self.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
resultTier: null == resultTier
|
||||
? _value.resultTier
|
||||
? _self.resultTier
|
||||
: resultTier // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resultExperience: null == resultExperience
|
||||
? _value.resultExperience
|
||||
? _self.resultExperience
|
||||
: resultExperience // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resultCoin: null == resultCoin
|
||||
? _value.resultCoin
|
||||
? _self.resultCoin
|
||||
: resultCoin // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
resultModifiers: null == resultModifiers
|
||||
? _value._resultModifiers
|
||||
? _self.resultModifiers
|
||||
: resultModifiers // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
accountId: null == accountId
|
||||
? _value.accountId
|
||||
? _self.accountId
|
||||
: accountId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
@@ -210,8 +164,8 @@ class __$$SnCheckInRecordImplCopyWithImpl<$Res>
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$SnCheckInRecordImpl extends _SnCheckInRecord {
|
||||
const _$SnCheckInRecordImpl(
|
||||
class _SnCheckInRecord extends SnCheckInRecord {
|
||||
const _SnCheckInRecord(
|
||||
{required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
@@ -223,9 +177,8 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
|
||||
required this.accountId})
|
||||
: _resultModifiers = resultModifiers,
|
||||
super._();
|
||||
|
||||
factory _$SnCheckInRecordImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$SnCheckInRecordImplFromJson(json);
|
||||
factory _SnCheckInRecord.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnCheckInRecordFromJson(json);
|
||||
|
||||
@override
|
||||
final int id;
|
||||
@@ -252,16 +205,26 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
|
||||
@override
|
||||
final int accountId;
|
||||
|
||||
/// Create a copy of SnCheckInRecord
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnCheckInRecordCopyWith<_SnCheckInRecord> get copyWith =>
|
||||
__$SnCheckInRecordCopyWithImpl<_SnCheckInRecord>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnCheckInRecordToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$SnCheckInRecordImpl &&
|
||||
other is _SnCheckInRecord &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
@@ -295,62 +258,94 @@ class _$SnCheckInRecordImpl extends _SnCheckInRecord {
|
||||
const DeepCollectionEquality().hash(_resultModifiers),
|
||||
accountId);
|
||||
|
||||
/// Create a copy of SnCheckInRecord
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
|
||||
__$$SnCheckInRecordImplCopyWithImpl<_$SnCheckInRecordImpl>(
|
||||
this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$SnCheckInRecordImplToJson(
|
||||
this,
|
||||
);
|
||||
String toString() {
|
||||
return 'SnCheckInRecord(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, resultTier: $resultTier, resultExperience: $resultExperience, resultCoin: $resultCoin, resultModifiers: $resultModifiers, accountId: $accountId)';
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _SnCheckInRecord extends SnCheckInRecord {
|
||||
const factory _SnCheckInRecord(
|
||||
{required final int id,
|
||||
required final DateTime createdAt,
|
||||
required final DateTime updatedAt,
|
||||
required final DateTime? deletedAt,
|
||||
required final int resultTier,
|
||||
required final int resultExperience,
|
||||
required final double resultCoin,
|
||||
required final List<int> resultModifiers,
|
||||
required final int accountId}) = _$SnCheckInRecordImpl;
|
||||
const _SnCheckInRecord._() : super._();
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnCheckInRecordCopyWith<$Res>
|
||||
implements $SnCheckInRecordCopyWith<$Res> {
|
||||
factory _$SnCheckInRecordCopyWith(
|
||||
_SnCheckInRecord value, $Res Function(_SnCheckInRecord) _then) =
|
||||
__$SnCheckInRecordCopyWithImpl;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
int resultTier,
|
||||
int resultExperience,
|
||||
double resultCoin,
|
||||
List<int> resultModifiers,
|
||||
int accountId});
|
||||
}
|
||||
|
||||
factory _SnCheckInRecord.fromJson(Map<String, dynamic> json) =
|
||||
_$SnCheckInRecordImpl.fromJson;
|
||||
/// @nodoc
|
||||
class __$SnCheckInRecordCopyWithImpl<$Res>
|
||||
implements _$SnCheckInRecordCopyWith<$Res> {
|
||||
__$SnCheckInRecordCopyWithImpl(this._self, this._then);
|
||||
|
||||
@override
|
||||
int get id;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
@override
|
||||
DateTime get updatedAt;
|
||||
@override
|
||||
DateTime? get deletedAt;
|
||||
@override
|
||||
int get resultTier;
|
||||
@override
|
||||
int get resultExperience;
|
||||
@override
|
||||
double get resultCoin;
|
||||
@override
|
||||
List<int> get resultModifiers;
|
||||
@override
|
||||
int get accountId;
|
||||
final _SnCheckInRecord _self;
|
||||
final $Res Function(_SnCheckInRecord) _then;
|
||||
|
||||
/// Create a copy of SnCheckInRecord
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SnCheckInRecordImplCopyWith<_$SnCheckInRecordImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
@pragma('vm:prefer-inline')
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? resultTier = null,
|
||||
Object? resultExperience = null,
|
||||
Object? resultCoin = null,
|
||||
Object? resultModifiers = null,
|
||||
Object? accountId = null,
|
||||
}) {
|
||||
return _then(_SnCheckInRecord(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _self.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _self.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _self.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
resultTier: null == resultTier
|
||||
? _self.resultTier
|
||||
: resultTier // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resultExperience: null == resultExperience
|
||||
? _self.resultExperience
|
||||
: resultExperience // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
resultCoin: null == resultCoin
|
||||
? _self.resultCoin
|
||||
: resultCoin // ignore: cast_nullable_to_non_nullable
|
||||
as double,
|
||||
resultModifiers: null == resultModifiers
|
||||
? _self._resultModifiers
|
||||
: resultModifiers // ignore: cast_nullable_to_non_nullable
|
||||
as List<int>,
|
||||
accountId: null == accountId
|
||||
? _self.accountId
|
||||
: accountId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
|
||||
@@ -6,9 +6,8 @@ part of 'check_in.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
|
||||
Map<String, dynamic> json) =>
|
||||
_$SnCheckInRecordImpl(
|
||||
_SnCheckInRecord _$SnCheckInRecordFromJson(Map<String, dynamic> json) =>
|
||||
_SnCheckInRecord(
|
||||
id: (json['id'] as num).toInt(),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
@@ -24,8 +23,7 @@ _$SnCheckInRecordImpl _$$SnCheckInRecordImplFromJson(
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$SnCheckInRecordImplToJson(
|
||||
_$SnCheckInRecordImpl instance) =>
|
||||
Map<String, dynamic> _$SnCheckInRecordToJson(_SnCheckInRecord instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'created_at': instance.createdAt.toIso8601String(),
|
||||
|
||||
18
lib/types/keypair.dart
Normal file
18
lib/types/keypair.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'keypair.freezed.dart';
|
||||
part 'keypair.g.dart';
|
||||
|
||||
@freezed
|
||||
abstract class SnKeyPair with _$SnKeyPair {
|
||||
const factory SnKeyPair({
|
||||
required String id,
|
||||
required int accountId,
|
||||
required String publicKey,
|
||||
bool? isActive,
|
||||
String? privateKey,
|
||||
}) = _SnKeyPair;
|
||||
|
||||
factory SnKeyPair.fromJson(Map<String, Object?> json) =>
|
||||
_$SnKeyPairFromJson(json);
|
||||
}
|
||||
241
lib/types/keypair.freezed.dart
Normal file
241
lib/types/keypair.freezed.dart
Normal file
@@ -0,0 +1,241 @@
|
||||
// dart format width=80
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||
|
||||
part of 'keypair.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnKeyPair {
|
||||
String get id;
|
||||
int get accountId;
|
||||
String get publicKey;
|
||||
bool? get isActive;
|
||||
String? get privateKey;
|
||||
|
||||
/// Create a copy of SnKeyPair
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnKeyPairCopyWith<SnKeyPair> get copyWith =>
|
||||
_$SnKeyPairCopyWithImpl<SnKeyPair>(this as SnKeyPair, _$identity);
|
||||
|
||||
/// Serializes this SnKeyPair to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is SnKeyPair &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.accountId, accountId) ||
|
||||
other.accountId == accountId) &&
|
||||
(identical(other.publicKey, publicKey) ||
|
||||
other.publicKey == publicKey) &&
|
||||
(identical(other.isActive, isActive) ||
|
||||
other.isActive == isActive) &&
|
||||
(identical(other.privateKey, privateKey) ||
|
||||
other.privateKey == privateKey));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, id, accountId, publicKey, isActive, privateKey);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnKeyPair(id: $id, accountId: $accountId, publicKey: $publicKey, isActive: $isActive, privateKey: $privateKey)';
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class $SnKeyPairCopyWith<$Res> {
|
||||
factory $SnKeyPairCopyWith(SnKeyPair value, $Res Function(SnKeyPair) _then) =
|
||||
_$SnKeyPairCopyWithImpl;
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
int accountId,
|
||||
String publicKey,
|
||||
bool? isActive,
|
||||
String? privateKey});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SnKeyPairCopyWithImpl<$Res> implements $SnKeyPairCopyWith<$Res> {
|
||||
_$SnKeyPairCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnKeyPair _self;
|
||||
final $Res Function(SnKeyPair) _then;
|
||||
|
||||
/// Create a copy of SnKeyPair
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? accountId = null,
|
||||
Object? publicKey = null,
|
||||
Object? isActive = freezed,
|
||||
Object? privateKey = freezed,
|
||||
}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
accountId: null == accountId
|
||||
? _self.accountId
|
||||
: accountId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
publicKey: null == publicKey
|
||||
? _self.publicKey
|
||||
: publicKey // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
isActive: freezed == isActive
|
||||
? _self.isActive
|
||||
: isActive // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,
|
||||
privateKey: freezed == privateKey
|
||||
? _self.privateKey
|
||||
: privateKey // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _SnKeyPair implements SnKeyPair {
|
||||
const _SnKeyPair(
|
||||
{required this.id,
|
||||
required this.accountId,
|
||||
required this.publicKey,
|
||||
this.isActive,
|
||||
this.privateKey});
|
||||
factory _SnKeyPair.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnKeyPairFromJson(json);
|
||||
|
||||
@override
|
||||
final String id;
|
||||
@override
|
||||
final int accountId;
|
||||
@override
|
||||
final String publicKey;
|
||||
@override
|
||||
final bool? isActive;
|
||||
@override
|
||||
final String? privateKey;
|
||||
|
||||
/// Create a copy of SnKeyPair
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnKeyPairCopyWith<_SnKeyPair> get copyWith =>
|
||||
__$SnKeyPairCopyWithImpl<_SnKeyPair>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnKeyPairToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _SnKeyPair &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.accountId, accountId) ||
|
||||
other.accountId == accountId) &&
|
||||
(identical(other.publicKey, publicKey) ||
|
||||
other.publicKey == publicKey) &&
|
||||
(identical(other.isActive, isActive) ||
|
||||
other.isActive == isActive) &&
|
||||
(identical(other.privateKey, privateKey) ||
|
||||
other.privateKey == privateKey));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode =>
|
||||
Object.hash(runtimeType, id, accountId, publicKey, isActive, privateKey);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnKeyPair(id: $id, accountId: $accountId, publicKey: $publicKey, isActive: $isActive, privateKey: $privateKey)';
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnKeyPairCopyWith<$Res>
|
||||
implements $SnKeyPairCopyWith<$Res> {
|
||||
factory _$SnKeyPairCopyWith(
|
||||
_SnKeyPair value, $Res Function(_SnKeyPair) _then) =
|
||||
__$SnKeyPairCopyWithImpl;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{String id,
|
||||
int accountId,
|
||||
String publicKey,
|
||||
bool? isActive,
|
||||
String? privateKey});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$SnKeyPairCopyWithImpl<$Res> implements _$SnKeyPairCopyWith<$Res> {
|
||||
__$SnKeyPairCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnKeyPair _self;
|
||||
final $Res Function(_SnKeyPair) _then;
|
||||
|
||||
/// Create a copy of SnKeyPair
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? accountId = null,
|
||||
Object? publicKey = null,
|
||||
Object? isActive = freezed,
|
||||
Object? privateKey = freezed,
|
||||
}) {
|
||||
return _then(_SnKeyPair(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
accountId: null == accountId
|
||||
? _self.accountId
|
||||
: accountId // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
publicKey: null == publicKey
|
||||
? _self.publicKey
|
||||
: publicKey // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
isActive: freezed == isActive
|
||||
? _self.isActive
|
||||
: isActive // ignore: cast_nullable_to_non_nullable
|
||||
as bool?,
|
||||
privateKey: freezed == privateKey
|
||||
? _self.privateKey
|
||||
: privateKey // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
24
lib/types/keypair.g.dart
Normal file
24
lib/types/keypair.g.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'keypair.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
_SnKeyPair _$SnKeyPairFromJson(Map<String, dynamic> json) => _SnKeyPair(
|
||||
id: json['id'] as String,
|
||||
accountId: (json['account_id'] as num).toInt(),
|
||||
publicKey: json['public_key'] as String,
|
||||
isActive: json['is_active'] as bool?,
|
||||
privateKey: json['private_key'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$SnKeyPairToJson(_SnKeyPair instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'account_id': instance.accountId,
|
||||
'public_key': instance.publicKey,
|
||||
'is_active': instance.isActive,
|
||||
'private_key': instance.privateKey,
|
||||
};
|
||||
@@ -4,7 +4,7 @@ part 'link.g.dart';
|
||||
part 'link.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class SnLinkMeta with _$SnLinkMeta {
|
||||
abstract class SnLinkMeta with _$SnLinkMeta {
|
||||
const SnLinkMeta._();
|
||||
|
||||
const factory SnLinkMeta({
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// dart format width=80
|
||||
// coverage:ignore-file
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint
|
||||
@@ -9,332 +10,41 @@ part of 'link.dart';
|
||||
// FreezedGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// dart format off
|
||||
T _$identity<T>(T value) => value;
|
||||
|
||||
final _privateConstructorUsedError = UnsupportedError(
|
||||
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
|
||||
|
||||
SnLinkMeta _$SnLinkMetaFromJson(Map<String, dynamic> json) {
|
||||
return _SnLinkMeta.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
mixin _$SnLinkMeta {
|
||||
int get id => throw _privateConstructorUsedError;
|
||||
DateTime get createdAt => throw _privateConstructorUsedError;
|
||||
DateTime get updatedAt => throw _privateConstructorUsedError;
|
||||
DateTime? get deletedAt => throw _privateConstructorUsedError;
|
||||
String get entryId => throw _privateConstructorUsedError;
|
||||
String? get icon => throw _privateConstructorUsedError;
|
||||
String get url => throw _privateConstructorUsedError;
|
||||
String? get title => throw _privateConstructorUsedError;
|
||||
String? get image => throw _privateConstructorUsedError;
|
||||
String? get video => throw _privateConstructorUsedError;
|
||||
String? get audio => throw _privateConstructorUsedError;
|
||||
String? get description => throw _privateConstructorUsedError;
|
||||
String? get siteName => throw _privateConstructorUsedError;
|
||||
String? get type => throw _privateConstructorUsedError;
|
||||
|
||||
/// Serializes this SnLinkMeta to a JSON map.
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
int get id;
|
||||
DateTime get createdAt;
|
||||
DateTime get updatedAt;
|
||||
DateTime? get deletedAt;
|
||||
String get entryId;
|
||||
String? get icon;
|
||||
String get url;
|
||||
String? get title;
|
||||
String? get image;
|
||||
String? get video;
|
||||
String? get audio;
|
||||
String? get description;
|
||||
String? get siteName;
|
||||
String? get type;
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@pragma('vm:prefer-inline')
|
||||
$SnLinkMetaCopyWith<SnLinkMeta> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
_$SnLinkMetaCopyWithImpl<SnLinkMeta>(this as SnLinkMeta, _$identity);
|
||||
|
||||
/// @nodoc
|
||||
abstract class $SnLinkMetaCopyWith<$Res> {
|
||||
factory $SnLinkMetaCopyWith(
|
||||
SnLinkMeta value, $Res Function(SnLinkMeta) then) =
|
||||
_$SnLinkMetaCopyWithImpl<$Res, SnLinkMeta>;
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String entryId,
|
||||
String? icon,
|
||||
String url,
|
||||
String? title,
|
||||
String? image,
|
||||
String? video,
|
||||
String? audio,
|
||||
String? description,
|
||||
String? siteName,
|
||||
String? type});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$SnLinkMetaCopyWithImpl<$Res, $Val extends SnLinkMeta>
|
||||
implements $SnLinkMetaCopyWith<$Res> {
|
||||
_$SnLinkMetaCopyWithImpl(this._value, this._then);
|
||||
|
||||
// ignore: unused_field
|
||||
final $Val _value;
|
||||
// ignore: unused_field
|
||||
final $Res Function($Val) _then;
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? entryId = null,
|
||||
Object? icon = freezed,
|
||||
Object? url = null,
|
||||
Object? title = freezed,
|
||||
Object? image = freezed,
|
||||
Object? video = freezed,
|
||||
Object? audio = freezed,
|
||||
Object? description = freezed,
|
||||
Object? siteName = freezed,
|
||||
Object? type = freezed,
|
||||
}) {
|
||||
return _then(_value.copyWith(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _value.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
entryId: null == entryId
|
||||
? _value.entryId
|
||||
: entryId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
icon: freezed == icon
|
||||
? _value.icon
|
||||
: icon // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
url: null == url
|
||||
? _value.url
|
||||
: url // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: freezed == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
image: freezed == image
|
||||
? _value.image
|
||||
: image // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
video: freezed == video
|
||||
? _value.video
|
||||
: video // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
audio: freezed == audio
|
||||
? _value.audio
|
||||
: audio // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
description: freezed == description
|
||||
? _value.description
|
||||
: description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
siteName: freezed == siteName
|
||||
? _value.siteName
|
||||
: siteName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
type: freezed == type
|
||||
? _value.type
|
||||
: type // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
) as $Val);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract class _$$SnLinkMetaImplCopyWith<$Res>
|
||||
implements $SnLinkMetaCopyWith<$Res> {
|
||||
factory _$$SnLinkMetaImplCopyWith(
|
||||
_$SnLinkMetaImpl value, $Res Function(_$SnLinkMetaImpl) then) =
|
||||
__$$SnLinkMetaImplCopyWithImpl<$Res>;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String entryId,
|
||||
String? icon,
|
||||
String url,
|
||||
String? title,
|
||||
String? image,
|
||||
String? video,
|
||||
String? audio,
|
||||
String? description,
|
||||
String? siteName,
|
||||
String? type});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$$SnLinkMetaImplCopyWithImpl<$Res>
|
||||
extends _$SnLinkMetaCopyWithImpl<$Res, _$SnLinkMetaImpl>
|
||||
implements _$$SnLinkMetaImplCopyWith<$Res> {
|
||||
__$$SnLinkMetaImplCopyWithImpl(
|
||||
_$SnLinkMetaImpl _value, $Res Function(_$SnLinkMetaImpl) _then)
|
||||
: super(_value, _then);
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? entryId = null,
|
||||
Object? icon = freezed,
|
||||
Object? url = null,
|
||||
Object? title = freezed,
|
||||
Object? image = freezed,
|
||||
Object? video = freezed,
|
||||
Object? audio = freezed,
|
||||
Object? description = freezed,
|
||||
Object? siteName = freezed,
|
||||
Object? type = freezed,
|
||||
}) {
|
||||
return _then(_$SnLinkMetaImpl(
|
||||
id: null == id
|
||||
? _value.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _value.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _value.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _value.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
entryId: null == entryId
|
||||
? _value.entryId
|
||||
: entryId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
icon: freezed == icon
|
||||
? _value.icon
|
||||
: icon // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
url: null == url
|
||||
? _value.url
|
||||
: url // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: freezed == title
|
||||
? _value.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
image: freezed == image
|
||||
? _value.image
|
||||
: image // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
video: freezed == video
|
||||
? _value.video
|
||||
: video // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
audio: freezed == audio
|
||||
? _value.audio
|
||||
: audio // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
description: freezed == description
|
||||
? _value.description
|
||||
: description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
siteName: freezed == siteName
|
||||
? _value.siteName
|
||||
: siteName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
type: freezed == type
|
||||
? _value.type
|
||||
: type // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _$SnLinkMetaImpl extends _SnLinkMeta {
|
||||
const _$SnLinkMetaImpl(
|
||||
{required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deletedAt,
|
||||
required this.entryId,
|
||||
required this.icon,
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.image,
|
||||
required this.video,
|
||||
required this.audio,
|
||||
required this.description,
|
||||
required this.siteName,
|
||||
required this.type})
|
||||
: super._();
|
||||
|
||||
factory _$SnLinkMetaImpl.fromJson(Map<String, dynamic> json) =>
|
||||
_$$SnLinkMetaImplFromJson(json);
|
||||
|
||||
@override
|
||||
final int id;
|
||||
@override
|
||||
final DateTime createdAt;
|
||||
@override
|
||||
final DateTime updatedAt;
|
||||
@override
|
||||
final DateTime? deletedAt;
|
||||
@override
|
||||
final String entryId;
|
||||
@override
|
||||
final String? icon;
|
||||
@override
|
||||
final String url;
|
||||
@override
|
||||
final String? title;
|
||||
@override
|
||||
final String? image;
|
||||
@override
|
||||
final String? video;
|
||||
@override
|
||||
final String? audio;
|
||||
@override
|
||||
final String? description;
|
||||
@override
|
||||
final String? siteName;
|
||||
@override
|
||||
final String? type;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
|
||||
}
|
||||
/// Serializes this SnLinkMeta to a JSON map.
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _$SnLinkMetaImpl &&
|
||||
other is SnLinkMeta &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
@@ -375,76 +85,351 @@ class _$SnLinkMetaImpl extends _SnLinkMeta {
|
||||
siteName,
|
||||
type);
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
_$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith =>
|
||||
__$$SnLinkMetaImplCopyWithImpl<_$SnLinkMetaImpl>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$SnLinkMetaImplToJson(
|
||||
this,
|
||||
);
|
||||
String toString() {
|
||||
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _SnLinkMeta extends SnLinkMeta {
|
||||
const factory _SnLinkMeta(
|
||||
{required final int id,
|
||||
required final DateTime createdAt,
|
||||
required final DateTime updatedAt,
|
||||
required final DateTime? deletedAt,
|
||||
required final String entryId,
|
||||
required final String? icon,
|
||||
required final String url,
|
||||
required final String? title,
|
||||
required final String? image,
|
||||
required final String? video,
|
||||
required final String? audio,
|
||||
required final String? description,
|
||||
required final String? siteName,
|
||||
required final String? type}) = _$SnLinkMetaImpl;
|
||||
const _SnLinkMeta._() : super._();
|
||||
/// @nodoc
|
||||
abstract mixin class $SnLinkMetaCopyWith<$Res> {
|
||||
factory $SnLinkMetaCopyWith(
|
||||
SnLinkMeta value, $Res Function(SnLinkMeta) _then) =
|
||||
_$SnLinkMetaCopyWithImpl;
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String entryId,
|
||||
String? icon,
|
||||
String url,
|
||||
String? title,
|
||||
String? image,
|
||||
String? video,
|
||||
String? audio,
|
||||
String? description,
|
||||
String? siteName,
|
||||
String? type});
|
||||
}
|
||||
|
||||
factory _SnLinkMeta.fromJson(Map<String, dynamic> json) =
|
||||
_$SnLinkMetaImpl.fromJson;
|
||||
/// @nodoc
|
||||
class _$SnLinkMetaCopyWithImpl<$Res> implements $SnLinkMetaCopyWith<$Res> {
|
||||
_$SnLinkMetaCopyWithImpl(this._self, this._then);
|
||||
|
||||
final SnLinkMeta _self;
|
||||
final $Res Function(SnLinkMeta) _then;
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@pragma('vm:prefer-inline')
|
||||
@override
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? entryId = null,
|
||||
Object? icon = freezed,
|
||||
Object? url = null,
|
||||
Object? title = freezed,
|
||||
Object? image = freezed,
|
||||
Object? video = freezed,
|
||||
Object? audio = freezed,
|
||||
Object? description = freezed,
|
||||
Object? siteName = freezed,
|
||||
Object? type = freezed,
|
||||
}) {
|
||||
return _then(_self.copyWith(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _self.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _self.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _self.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
entryId: null == entryId
|
||||
? _self.entryId
|
||||
: entryId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
icon: freezed == icon
|
||||
? _self.icon
|
||||
: icon // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
url: null == url
|
||||
? _self.url
|
||||
: url // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: freezed == title
|
||||
? _self.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
image: freezed == image
|
||||
? _self.image
|
||||
: image // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
video: freezed == video
|
||||
? _self.video
|
||||
: video // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
audio: freezed == audio
|
||||
? _self.audio
|
||||
: audio // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
description: freezed == description
|
||||
? _self.description
|
||||
: description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
siteName: freezed == siteName
|
||||
? _self.siteName
|
||||
: siteName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
type: freezed == type
|
||||
? _self.type
|
||||
: type // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@JsonSerializable()
|
||||
class _SnLinkMeta extends SnLinkMeta {
|
||||
const _SnLinkMeta(
|
||||
{required this.id,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
required this.deletedAt,
|
||||
required this.entryId,
|
||||
required this.icon,
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.image,
|
||||
required this.video,
|
||||
required this.audio,
|
||||
required this.description,
|
||||
required this.siteName,
|
||||
required this.type})
|
||||
: super._();
|
||||
factory _SnLinkMeta.fromJson(Map<String, dynamic> json) =>
|
||||
_$SnLinkMetaFromJson(json);
|
||||
|
||||
@override
|
||||
int get id;
|
||||
final int id;
|
||||
@override
|
||||
DateTime get createdAt;
|
||||
final DateTime createdAt;
|
||||
@override
|
||||
DateTime get updatedAt;
|
||||
final DateTime updatedAt;
|
||||
@override
|
||||
DateTime? get deletedAt;
|
||||
final DateTime? deletedAt;
|
||||
@override
|
||||
String get entryId;
|
||||
final String entryId;
|
||||
@override
|
||||
String? get icon;
|
||||
final String? icon;
|
||||
@override
|
||||
String get url;
|
||||
final String url;
|
||||
@override
|
||||
String? get title;
|
||||
final String? title;
|
||||
@override
|
||||
String? get image;
|
||||
final String? image;
|
||||
@override
|
||||
String? get video;
|
||||
final String? video;
|
||||
@override
|
||||
String? get audio;
|
||||
final String? audio;
|
||||
@override
|
||||
String? get description;
|
||||
final String? description;
|
||||
@override
|
||||
String? get siteName;
|
||||
final String? siteName;
|
||||
@override
|
||||
String? get type;
|
||||
final String? type;
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
_$$SnLinkMetaImplCopyWith<_$SnLinkMetaImpl> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
@pragma('vm:prefer-inline')
|
||||
_$SnLinkMetaCopyWith<_SnLinkMeta> get copyWith =>
|
||||
__$SnLinkMetaCopyWithImpl<_SnLinkMeta>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$SnLinkMetaToJson(
|
||||
this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return identical(this, other) ||
|
||||
(other.runtimeType == runtimeType &&
|
||||
other is _SnLinkMeta &&
|
||||
(identical(other.id, id) || other.id == id) &&
|
||||
(identical(other.createdAt, createdAt) ||
|
||||
other.createdAt == createdAt) &&
|
||||
(identical(other.updatedAt, updatedAt) ||
|
||||
other.updatedAt == updatedAt) &&
|
||||
(identical(other.deletedAt, deletedAt) ||
|
||||
other.deletedAt == deletedAt) &&
|
||||
(identical(other.entryId, entryId) || other.entryId == entryId) &&
|
||||
(identical(other.icon, icon) || other.icon == icon) &&
|
||||
(identical(other.url, url) || other.url == url) &&
|
||||
(identical(other.title, title) || other.title == title) &&
|
||||
(identical(other.image, image) || other.image == image) &&
|
||||
(identical(other.video, video) || other.video == video) &&
|
||||
(identical(other.audio, audio) || other.audio == audio) &&
|
||||
(identical(other.description, description) ||
|
||||
other.description == description) &&
|
||||
(identical(other.siteName, siteName) ||
|
||||
other.siteName == siteName) &&
|
||||
(identical(other.type, type) || other.type == type));
|
||||
}
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
runtimeType,
|
||||
id,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
deletedAt,
|
||||
entryId,
|
||||
icon,
|
||||
url,
|
||||
title,
|
||||
image,
|
||||
video,
|
||||
audio,
|
||||
description,
|
||||
siteName,
|
||||
type);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'SnLinkMeta(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, entryId: $entryId, icon: $icon, url: $url, title: $title, image: $image, video: $video, audio: $audio, description: $description, siteName: $siteName, type: $type)';
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
abstract mixin class _$SnLinkMetaCopyWith<$Res>
|
||||
implements $SnLinkMetaCopyWith<$Res> {
|
||||
factory _$SnLinkMetaCopyWith(
|
||||
_SnLinkMeta value, $Res Function(_SnLinkMeta) _then) =
|
||||
__$SnLinkMetaCopyWithImpl;
|
||||
@override
|
||||
@useResult
|
||||
$Res call(
|
||||
{int id,
|
||||
DateTime createdAt,
|
||||
DateTime updatedAt,
|
||||
DateTime? deletedAt,
|
||||
String entryId,
|
||||
String? icon,
|
||||
String url,
|
||||
String? title,
|
||||
String? image,
|
||||
String? video,
|
||||
String? audio,
|
||||
String? description,
|
||||
String? siteName,
|
||||
String? type});
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class __$SnLinkMetaCopyWithImpl<$Res> implements _$SnLinkMetaCopyWith<$Res> {
|
||||
__$SnLinkMetaCopyWithImpl(this._self, this._then);
|
||||
|
||||
final _SnLinkMeta _self;
|
||||
final $Res Function(_SnLinkMeta) _then;
|
||||
|
||||
/// Create a copy of SnLinkMeta
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@override
|
||||
@pragma('vm:prefer-inline')
|
||||
$Res call({
|
||||
Object? id = null,
|
||||
Object? createdAt = null,
|
||||
Object? updatedAt = null,
|
||||
Object? deletedAt = freezed,
|
||||
Object? entryId = null,
|
||||
Object? icon = freezed,
|
||||
Object? url = null,
|
||||
Object? title = freezed,
|
||||
Object? image = freezed,
|
||||
Object? video = freezed,
|
||||
Object? audio = freezed,
|
||||
Object? description = freezed,
|
||||
Object? siteName = freezed,
|
||||
Object? type = freezed,
|
||||
}) {
|
||||
return _then(_SnLinkMeta(
|
||||
id: null == id
|
||||
? _self.id
|
||||
: id // ignore: cast_nullable_to_non_nullable
|
||||
as int,
|
||||
createdAt: null == createdAt
|
||||
? _self.createdAt
|
||||
: createdAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
updatedAt: null == updatedAt
|
||||
? _self.updatedAt
|
||||
: updatedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime,
|
||||
deletedAt: freezed == deletedAt
|
||||
? _self.deletedAt
|
||||
: deletedAt // ignore: cast_nullable_to_non_nullable
|
||||
as DateTime?,
|
||||
entryId: null == entryId
|
||||
? _self.entryId
|
||||
: entryId // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
icon: freezed == icon
|
||||
? _self.icon
|
||||
: icon // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
url: null == url
|
||||
? _self.url
|
||||
: url // ignore: cast_nullable_to_non_nullable
|
||||
as String,
|
||||
title: freezed == title
|
||||
? _self.title
|
||||
: title // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
image: freezed == image
|
||||
? _self.image
|
||||
: image // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
video: freezed == video
|
||||
? _self.video
|
||||
: video // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
audio: freezed == audio
|
||||
? _self.audio
|
||||
: audio // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
description: freezed == description
|
||||
? _self.description
|
||||
: description // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
siteName: freezed == siteName
|
||||
? _self.siteName
|
||||
: siteName // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
type: freezed == type
|
||||
? _self.type
|
||||
: type // ignore: cast_nullable_to_non_nullable
|
||||
as String?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// dart format on
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user