Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
24791b3293
|
|||
|
3ac263d483
|
|||
|
2445d8adf8
|
|||
|
d4f95bbbf4
|
|||
|
943e4b7b5c
|
|||
|
7edc02a1d3
|
|||
|
3f9881e943
|
|||
|
50c25e919c
|
|||
|
99fb08dd55
|
|||
|
e43bc6b8a8
|
|||
|
c247cdf81c
|
|||
|
3ffa730505
|
|||
|
1cc34d3073
|
|||
|
96a919cc4e
|
|||
|
e7e3bfcadf
|
|||
|
a8617a5040
|
|||
|
d94f8d004f
|
|||
|
d93b066979
|
|||
|
320664a547
|
|||
|
98f4698d5b
|
|||
|
82397dd087
|
|||
|
4ec10ceb47
|
|||
|
4b03b45a0d
|
|||
|
7a72d32649
|
|||
|
5152dd13ea
|
|||
|
fd377aa7af
|
|||
|
67044148f1
|
|||
|
92bc43e4df
|
|||
|
a1a7b34c86
|
|||
|
40c0e052cf
|
|||
|
9a75228e38
|
|||
|
a9fd75cc45
|
|||
|
a713b30d93
|
|||
|
e516f0a862
|
|||
|
429b966c4b
|
|||
|
f14da0d3a2
|
|||
|
d201182bd2
|
|||
|
6f6422c15e
|
|||
|
9f6ae639ee
|
|||
|
35f4d7d885
|
|||
|
a9c8f49797
|
|||
|
5e9341a19c
|
|||
|
645a6dca93
|
|||
|
ea8e7ead2d
|
|||
|
5f2f083d72
|
|||
|
5cf40e27de
|
|||
|
1ab7295918
|
|||
|
07f191171c
|
|||
|
4a5dac248e
|
|||
|
3b983a6444
|
|||
|
4607b77355
|
|||
|
7957e4894a
|
|||
|
f94f80c375
|
|||
|
74fa2215a6
|
|||
|
0d11435feb
|
|||
|
e22598b0a6
|
|||
|
84cfe643f5
|
|||
|
05ac04e9a2
|
|||
|
66f283d6e8
|
|||
|
c779c7523c
|
|||
|
ac7cb29afe
|
|||
|
935aa77223
|
|||
|
24e5b3b824
|
|||
|
0391893b32
|
|||
|
b8d24876c8
|
|||
|
0493661f9a
|
|||
|
b40afde00f
|
|||
|
78a4022531
|
|||
|
8a291c80b7
|
|||
|
1395d65b76
|
|||
|
eb4942e0ed
|
|||
|
f254cfa81e
|
|||
|
4927795260
|
|||
|
e4019dadc8
|
|||
|
5e7d77e1a1
|
|||
|
bfcbed035c
|
|||
|
5ebefae961
|
|||
|
d4758674bb
|
|||
|
f5f1ddc0ea
|
|||
|
2720b59485
|
|||
|
29b1ac7fce
|
|||
|
83ca5551ad
|
|||
| 611cb024a9 | |||
|
74fb56891d
|
@@ -43,6 +43,16 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- App protocol -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<!-- Accepts URIs that begin with YOUR_SCHEME://YOUR_HOST -->
|
||||||
|
<data android:scheme="solian" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Deeplinking -->
|
<!-- Deeplinking -->
|
||||||
<intent-filter android:autoVerify="true">
|
<intent-filter android:autoVerify="true">
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -136,6 +136,7 @@
|
|||||||
"reactionNegative": "Negative",
|
"reactionNegative": "Negative",
|
||||||
"reactionNeutral": "Neutral",
|
"reactionNeutral": "Neutral",
|
||||||
"customReaction": "Custom Reaction",
|
"customReaction": "Custom Reaction",
|
||||||
|
"customReactionHint": "Custom Reaction allow you to use user uploaded stickers as the symbol of the reaction for the post. Exclusive for Stellar Program members.",
|
||||||
"customReactions": "Custom Reactions",
|
"customReactions": "Custom Reactions",
|
||||||
"stickerPlaceholder": "Sticker Placeholder",
|
"stickerPlaceholder": "Sticker Placeholder",
|
||||||
"reactionAttitude": "Reaction Attitude",
|
"reactionAttitude": "Reaction Attitude",
|
||||||
@@ -163,6 +164,7 @@
|
|||||||
"accountConnectionProviderDiscord": "Discord",
|
"accountConnectionProviderDiscord": "Discord",
|
||||||
"accountConnectionProviderAfdian": "Afdian",
|
"accountConnectionProviderAfdian": "Afdian",
|
||||||
"accountConnectionProviderSpotify": "Spotify",
|
"accountConnectionProviderSpotify": "Spotify",
|
||||||
|
"accountConnectionProviderSteam": "Steam",
|
||||||
"checkIn": "Check In",
|
"checkIn": "Check In",
|
||||||
"checkInNone": "Not checked-in yet",
|
"checkInNone": "Not checked-in yet",
|
||||||
"checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",
|
"checkInNoneHint": "Get your fortune tips and daily rewards by checking in.",
|
||||||
@@ -1086,6 +1088,7 @@
|
|||||||
"levelingStage10": "Immortal",
|
"levelingStage10": "Immortal",
|
||||||
"levelingStage11": "Divine",
|
"levelingStage11": "Divine",
|
||||||
"levelingStage12": "Transcendent",
|
"levelingStage12": "Transcendent",
|
||||||
|
"uploadTasks": "Upload Tasks",
|
||||||
"uploadAttachment": "Upload Attachment",
|
"uploadAttachment": "Upload Attachment",
|
||||||
"attachmentPreview": "Attachment Preview",
|
"attachmentPreview": "Attachment Preview",
|
||||||
"selectPool": "Select Pool",
|
"selectPool": "Select Pool",
|
||||||
@@ -1300,7 +1303,9 @@
|
|||||||
"thoughtInputHint": "Ask sn-chan anything...",
|
"thoughtInputHint": "Ask sn-chan anything...",
|
||||||
"thoughtNewConversation": "Start New Conversation",
|
"thoughtNewConversation": "Start New Conversation",
|
||||||
"thoughtParseError": "Failed to parse AI response",
|
"thoughtParseError": "Failed to parse AI response",
|
||||||
"thoughtFunctionCall": "Function Call",
|
"thoughtFunctionCall": "Use {}",
|
||||||
|
"thoughtFunctionCallBegin": "Calling tool {}",
|
||||||
|
"thoughtFunctionCallFinish": "{} responded",
|
||||||
"aiThought": "AI Thought",
|
"aiThought": "AI Thought",
|
||||||
"aiThoughtTitle": "Let sn-chan think",
|
"aiThoughtTitle": "Let sn-chan think",
|
||||||
"postReferenceUnavailable": "Referenced post is unavailable",
|
"postReferenceUnavailable": "Referenced post is unavailable",
|
||||||
@@ -1319,5 +1324,17 @@
|
|||||||
"popularity": "Popularity",
|
"popularity": "Popularity",
|
||||||
"descendingOrder": "Descending Order",
|
"descendingOrder": "Descending Order",
|
||||||
"selectDate": "Select Date",
|
"selectDate": "Select Date",
|
||||||
"pinnedPosts": "Pinned Posts"
|
"pinnedPosts": "Pinned Posts",
|
||||||
|
"thoughtUnpaidHint": "Thinking unavaiable due to unpaid orders",
|
||||||
|
"more": "More",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"pollConfirmDiscard": "Are you sure you want to leave? All the poll data you're editing will not be saved.",
|
||||||
|
"discard": "Discard",
|
||||||
|
"fund": "Fund",
|
||||||
|
"fundsRecent": "Recent Funds",
|
||||||
|
"fundCreateNew": "Create New",
|
||||||
|
"fundCreateNewHint": "Create a new fund for your message. Select recipients and amount.",
|
||||||
|
"amountOfSplits": "Amount of Splits",
|
||||||
|
"enterNumberOfSplits": "Enter Splits Amount",
|
||||||
|
"orCreateWith": "Or\ncreate with"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,10 +251,10 @@
|
|||||||
"translatorBadgeName": "翻译者",
|
"translatorBadgeName": "翻译者",
|
||||||
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
|
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
|
||||||
"wallet": "钱包",
|
"wallet": "钱包",
|
||||||
"walletCurrencyPoints": "新太阳点",
|
"walletCurrencyPoints": "源能点",
|
||||||
"walletCurrencyShortPoints": "NSP",
|
"walletCurrencyShortPoints": "NSP",
|
||||||
"walletCurrencyGolds": "太阳币",
|
"walletCurrencyGolds": "星辰碎片",
|
||||||
"walletCurrencyShortGolds": "TSD",
|
"walletCurrencyShortGolds": "SHD",
|
||||||
"retry": "重试",
|
"retry": "重试",
|
||||||
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
|
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
|
||||||
"relationships": "关系",
|
"relationships": "关系",
|
||||||
@@ -1090,5 +1090,6 @@
|
|||||||
"thoughtNewConversation": "开始新对话",
|
"thoughtNewConversation": "开始新对话",
|
||||||
"thoughtParseError": "解析 AI 响应失败",
|
"thoughtParseError": "解析 AI 响应失败",
|
||||||
"aiThought": "寻思",
|
"aiThought": "寻思",
|
||||||
"aiThoughtTitle": "让 SN 酱寻思寻思"
|
"aiThoughtTitle": "让 SN 酱寻思寻思",
|
||||||
|
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/icons/icon-tray.png
Normal file
BIN
assets/icons/icon-tray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
1
assets/images/oidc/steam.svg
Normal file
1
assets/images/oidc/steam.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="2471" height="2500" viewBox="0 0 256 259" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M127.779 0C60.42 0 5.24 52.412 0 119.014l68.724 28.674a35.812 35.812 0 0 1 20.426-6.366c.682 0 1.356.019 2.02.056l30.566-44.71v-.626c0-26.903 21.69-48.796 48.353-48.796 26.662 0 48.352 21.893 48.352 48.796 0 26.902-21.69 48.804-48.352 48.804-.37 0-.73-.009-1.098-.018l-43.593 31.377c.028.582.046 1.163.046 1.735 0 20.204-16.283 36.636-36.294 36.636-17.566 0-32.263-12.658-35.584-29.412L4.41 164.654c15.223 54.313 64.673 94.132 123.369 94.132 70.818 0 128.221-57.938 128.221-129.393C256 57.93 198.597 0 127.779 0zM80.352 196.332l-15.749-6.568c2.787 5.867 7.621 10.775 14.033 13.47 13.857 5.83 29.836-.803 35.612-14.799a27.555 27.555 0 0 0 .046-21.035c-2.768-6.79-7.999-12.086-14.706-14.909-6.67-2.795-13.811-2.694-20.085-.304l16.275 6.79c10.222 4.3 15.056 16.145 10.794 26.46-4.253 10.314-15.998 15.195-26.22 10.895zm121.957-100.29c0-17.925-14.457-32.52-32.217-32.52-17.769 0-32.226 14.595-32.226 32.52 0 17.926 14.457 32.512 32.226 32.512 17.76 0 32.217-14.586 32.217-32.512zm-56.37-.055c0-13.488 10.84-24.42 24.2-24.42 13.368 0 24.208 10.932 24.208 24.42 0 13.488-10.84 24.421-24.209 24.421-13.359 0-24.2-10.933-24.2-24.42z" fill="#1A1918"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,3 +1,6 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
|
- drift: true
|
||||||
|
- provider: true
|
||||||
|
- shared_preferences: true
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Alamofire (5.10.2)
|
- Alamofire (5.10.2)
|
||||||
- app_links (6.4.1):
|
|
||||||
- Flutter
|
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- croppy (0.0.1):
|
- croppy (0.0.1):
|
||||||
@@ -52,18 +50,18 @@ PODS:
|
|||||||
- Firebase/Messaging (12.4.0):
|
- Firebase/Messaging (12.4.0):
|
||||||
- Firebase/CoreOnly
|
- Firebase/CoreOnly
|
||||||
- FirebaseMessaging (~> 12.4.0)
|
- FirebaseMessaging (~> 12.4.0)
|
||||||
- firebase_analytics (12.0.3):
|
- firebase_analytics (12.0.4):
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- FirebaseAnalytics (= 12.4.0)
|
- FirebaseAnalytics (= 12.4.0)
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_core (4.2.0):
|
- firebase_core (4.2.1):
|
||||||
- Firebase/CoreOnly (= 12.4.0)
|
- Firebase/CoreOnly (= 12.4.0)
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_crashlytics (5.0.3):
|
- firebase_crashlytics (5.0.4):
|
||||||
- Firebase/Crashlytics (= 12.4.0)
|
- Firebase/Crashlytics (= 12.4.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
- firebase_messaging (16.0.3):
|
- firebase_messaging (16.0.4):
|
||||||
- Firebase/Messaging (= 12.4.0)
|
- Firebase/Messaging (= 12.4.0)
|
||||||
- firebase_core
|
- firebase_core
|
||||||
- Flutter
|
- Flutter
|
||||||
@@ -265,6 +263,8 @@ PODS:
|
|||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- PromisesSwift (2.4.0):
|
- PromisesSwift (2.4.0):
|
||||||
- PromisesObjC (= 2.4.0)
|
- PromisesObjC (= 2.4.0)
|
||||||
|
- protocol_handler_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
- receive_sharing_intent (1.8.1):
|
- receive_sharing_intent (1.8.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- record_ios (1.1.0):
|
- record_ios (1.1.0):
|
||||||
@@ -323,7 +323,6 @@ PODS:
|
|||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Alamofire
|
- Alamofire
|
||||||
- app_links (from `.symlinks/plugins/app_links/ios`)
|
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- croppy (from `.symlinks/plugins/croppy/ios`)
|
- croppy (from `.symlinks/plugins/croppy/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
@@ -358,6 +357,7 @@ DEPENDENCIES:
|
|||||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
|
||||||
|
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
|
||||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||||
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
- record_ios (from `.symlinks/plugins/record_ios/ios`)
|
||||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
@@ -404,8 +404,6 @@ SPEC REPOS:
|
|||||||
- WebRTC-SDK
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
app_links:
|
|
||||||
:path: ".symlinks/plugins/app_links/ios"
|
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
croppy:
|
croppy:
|
||||||
@@ -470,6 +468,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
pointer_interceptor_ios:
|
pointer_interceptor_ios:
|
||||||
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
|
||||||
|
protocol_handler_ios:
|
||||||
|
:path: ".symlinks/plugins/protocol_handler_ios/ios"
|
||||||
receive_sharing_intent:
|
receive_sharing_intent:
|
||||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||||
record_ios:
|
record_ios:
|
||||||
@@ -497,7 +497,6 @@ EXTERNAL SOURCES:
|
|||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||||
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
|
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
@@ -506,10 +505,10 @@ SPEC CHECKSUMS:
|
|||||||
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
|
||||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||||
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
|
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
|
||||||
firebase_analytics: 1d024068b1d4707d5ba7a42a12976ddf3316d835
|
firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf
|
||||||
firebase_core: 744984dbbed8b3036abf34f0b98d80f130a7e464
|
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
|
||||||
firebase_crashlytics: f3a9a4338ab99b67042f64e9e22e1bf349cb44ed
|
firebase_crashlytics: 83c7467d7534975a4d779af43bd226d0a4616464
|
||||||
firebase_messaging: 82c70650c426a0a14873e1acdb9ec2b443c4e8b4
|
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
|
||||||
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
|
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
|
||||||
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
|
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
|
||||||
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
|
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
|
||||||
@@ -553,6 +552,7 @@ SPEC CHECKSUMS:
|
|||||||
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
|
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
|
||||||
|
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
|
||||||
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
|
||||||
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
|
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
|
|||||||
@@ -1,108 +1,111 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>AppGroupId</key>
|
<key>AppGroupId</key>
|
||||||
<string>$(CUSTOM_GROUP_ID)</string>
|
<string>$(CUSTOM_GROUP_ID)</string>
|
||||||
<key>BUNDLE_ID</key>
|
<key>BUNDLE_ID</key>
|
||||||
<string>dev.solsynth.solian</string>
|
<string>dev.solsynth.solian</string>
|
||||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
<true/>
|
<true />
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Solian</string>
|
<string>Solian</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>solian</string>
|
<string>solian</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Editor</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleTypeRole</key>
|
<key>CFBundleTypeRole</key>
|
||||||
<string>Viewer</string>
|
<string>Editor</string>
|
||||||
<key>CFBundleURLSchemes</key>
|
<key>CFBundleURLName</key>
|
||||||
<array>
|
<string></string>
|
||||||
<string>solian</string>
|
<key>CFBundleURLSchemes</key>
|
||||||
</array>
|
<array>
|
||||||
</dict>
|
<string>solian</string>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
</dict>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
</array>
|
||||||
<key>CLIENT_ID</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>CLIENT_ID</key>
|
||||||
<false/>
|
<string>961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig.apps.googleusercontent.com</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<true/>
|
<false />
|
||||||
<key>NSCalendarsUsageDescription</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<string>Grant access to Calander help us to shows Solar Calander with your own events.</string>
|
<true />
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCalendarsUsageDescription</key>
|
||||||
<string>Grant access to Camera will allow Solian take photo or video for your post.</string>
|
<string>Grant access to Calander help us to shows Solar Calander with your own events.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Allow the Solar Network verify your ownership of the logged in account and continue your action quickly.</string>
|
<string>Grant access to Camera will allow Solian take photo or video for your post.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
<string>Grant access to Microphone will allow Solian record audio for your post.</string>
|
<string>Allow the Solar Network verify your ownership of the logged in account and continue
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
your action quickly.</string>
|
||||||
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<string>Grant access to Microphone will allow Solian record audio for your post.</string>
|
||||||
<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
<key>NSUserActivityTypes</key>
|
<string>Grant access to Photo Library will allow Solian download photo to album for you.</string>
|
||||||
<array>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>INStartCallIntent</string>
|
<string>Grant access to Photo Library will allow Solian upload photo or video for your post.</string>
|
||||||
<string>INSendMessageIntent</string>
|
<key>NSUserActivityTypes</key>
|
||||||
</array>
|
<array>
|
||||||
<key>PLIST_VERSION</key>
|
<string>INStartCallIntent</string>
|
||||||
<string>1</string>
|
<string>INSendMessageIntent</string>
|
||||||
<key>REVERSED_CLIENT_ID</key>
|
</array>
|
||||||
<string>com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig</string>
|
<key>PLIST_VERSION</key>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<string>1</string>
|
||||||
<true/>
|
<key>REVERSED_CLIENT_ID</key>
|
||||||
<key>UIBackgroundModes</key>
|
<string>com.googleusercontent.apps.961776991058-stt7et4qvn3cpscl4r61gl1hnlatqkig</string>
|
||||||
<array>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<string>fetch</string>
|
<true />
|
||||||
<string>audio</string>
|
<key>UIBackgroundModes</key>
|
||||||
<string>remote-notification</string>
|
<array>
|
||||||
<string>voip</string>
|
<string>fetch</string>
|
||||||
</array>
|
<string>audio</string>
|
||||||
<key>UILaunchStoryboardName</key>
|
<string>remote-notification</string>
|
||||||
<string>LaunchScreen</string>
|
<string>voip</string>
|
||||||
<key>UIMainStoryboardFile</key>
|
</array>
|
||||||
<string>Main</string>
|
<key>UILaunchStoryboardName</key>
|
||||||
<key>UIStatusBarHidden</key>
|
<string>LaunchScreen</string>
|
||||||
<false/>
|
<key>UIMainStoryboardFile</key>
|
||||||
<key>UISupportedInterfaceOrientations</key>
|
<string>Main</string>
|
||||||
<array>
|
<key>UIStatusBarHidden</key>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<false />
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<array>
|
||||||
</array>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<key>WKCompanionAppBundleIdentifier</key>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
</array>
|
||||||
<array>
|
<key>WKCompanionAppBundleIdentifier</key>
|
||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||||
<string>UIInterfaceOrientationPortrait</string>
|
<array>
|
||||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
</array>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</dict>
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -48,3 +48,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Placeholder Implementations for Preview ---
|
||||||
|
|
||||||
|
struct ContentView_Previews: PreviewProvider {
|
||||||
|
static var previews: some View {
|
||||||
|
ContentView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import 'package:talker_flutter/talker_flutter.dart';
|
|||||||
import 'package:talker_riverpod_logger/talker_riverpod_logger.dart';
|
import 'package:talker_riverpod_logger/talker_riverpod_logger.dart';
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
import 'package:protocol_handler/protocol_handler.dart';
|
||||||
|
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
@@ -50,8 +51,16 @@ void main() async {
|
|||||||
GoRouter.optionURLReflectsImperativeAPIs = true;
|
GoRouter.optionURLReflectsImperativeAPIs = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows)) {
|
||||||
|
talker.info("[SplashScreen] Initializing desktop window manager...");
|
||||||
|
await protocolHandler.register('myprotocol');
|
||||||
|
talker.info("[SplashScreen] Desktop window manager is ready!");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await EasyLocalization.ensureInitialized();
|
await EasyLocalization.ensureInitialized();
|
||||||
|
// Disable logs
|
||||||
|
EasyLocalization.logger.enableBuildModes = [];
|
||||||
|
|
||||||
if (kIsWeb || !Platform.isLinux) {
|
if (kIsWeb || !Platform.isLinux) {
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:island/models/activity.dart';
|
||||||
import 'package:island/models/auth.dart';
|
import 'package:island/models/auth.dart';
|
||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/models/wallet.dart';
|
import 'package:island/models/wallet.dart';
|
||||||
@@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
|
|||||||
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
|
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnSocialCreditRecordFromJson(json);
|
_$SnSocialCreditRecordFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnFriendOverviewItem with _$SnFriendOverviewItem {
|
||||||
|
const factory SnFriendOverviewItem({
|
||||||
|
required SnAccount account,
|
||||||
|
required SnAccountStatus status,
|
||||||
|
required List<SnPresenceActivity> activities,
|
||||||
|
}) = _SnFriendOverviewItem;
|
||||||
|
|
||||||
|
factory SnFriendOverviewItem.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnFriendOverviewItemFromJson(json);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3912,4 +3912,309 @@ as DateTime?,
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnFriendOverviewItem {
|
||||||
|
|
||||||
|
SnAccount get account; SnAccountStatus get status; List<SnPresenceActivity> get activities;
|
||||||
|
/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnFriendOverviewItemCopyWith<SnFriendOverviewItem> get copyWith => _$SnFriendOverviewItemCopyWithImpl<SnFriendOverviewItem>(this as SnFriendOverviewItem, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this SnFriendOverviewItem to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other.activities, activities));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(activities));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $SnFriendOverviewItemCopyWith<$Res> {
|
||||||
|
factory $SnFriendOverviewItemCopyWith(SnFriendOverviewItem value, $Res Function(SnFriendOverviewItem) _then) = _$SnFriendOverviewItemCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$SnAccountCopyWith<$Res> get account;$SnAccountStatusCopyWith<$Res> get status;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$SnFriendOverviewItemCopyWithImpl<$Res>
|
||||||
|
implements $SnFriendOverviewItemCopyWith<$Res> {
|
||||||
|
_$SnFriendOverviewItemCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final SnFriendOverviewItem _self;
|
||||||
|
final $Res Function(SnFriendOverviewItem) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? account = null,Object? status = null,Object? activities = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnAccountStatus,activities: null == activities ? _self.activities : activities // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<SnPresenceActivity>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnAccountCopyWith<$Res> get account {
|
||||||
|
|
||||||
|
return $SnAccountCopyWith<$Res>(_self.account, (value) {
|
||||||
|
return _then(_self.copyWith(account: value));
|
||||||
|
});
|
||||||
|
}/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnAccountStatusCopyWith<$Res> get status {
|
||||||
|
|
||||||
|
return $SnAccountStatusCopyWith<$Res>(_self.status, (value) {
|
||||||
|
return _then(_self.copyWith(status: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [SnFriendOverviewItem].
|
||||||
|
extension SnFriendOverviewItemPatterns on SnFriendOverviewItem {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnFriendOverviewItem value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnFriendOverviewItem() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnFriendOverviewItem value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnFriendOverviewItem():
|
||||||
|
return $default(_that);}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnFriendOverviewItem value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnFriendOverviewItem() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnFriendOverviewItem() when $default != null:
|
||||||
|
return $default(_that.account,_that.status,_that.activities);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnFriendOverviewItem():
|
||||||
|
return $default(_that.account,_that.status,_that.activities);}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnFriendOverviewItem() when $default != null:
|
||||||
|
return $default(_that.account,_that.status,_that.activities);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _SnFriendOverviewItem implements SnFriendOverviewItem {
|
||||||
|
const _SnFriendOverviewItem({required this.account, required this.status, required final List<SnPresenceActivity> activities}): _activities = activities;
|
||||||
|
factory _SnFriendOverviewItem.fromJson(Map<String, dynamic> json) => _$SnFriendOverviewItemFromJson(json);
|
||||||
|
|
||||||
|
@override final SnAccount account;
|
||||||
|
@override final SnAccountStatus status;
|
||||||
|
final List<SnPresenceActivity> _activities;
|
||||||
|
@override List<SnPresenceActivity> get activities {
|
||||||
|
if (_activities is EqualUnmodifiableListView) return _activities;
|
||||||
|
// ignore: implicit_dynamic_type
|
||||||
|
return EqualUnmodifiableListView(_activities);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$SnFriendOverviewItemCopyWith<_SnFriendOverviewItem> get copyWith => __$SnFriendOverviewItemCopyWithImpl<_SnFriendOverviewItem>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$SnFriendOverviewItemToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnFriendOverviewItem&&(identical(other.account, account) || other.account == account)&&(identical(other.status, status) || other.status == status)&&const DeepCollectionEquality().equals(other._activities, _activities));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,account,status,const DeepCollectionEquality().hash(_activities));
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnFriendOverviewItem(account: $account, status: $status, activities: $activities)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$SnFriendOverviewItemCopyWith<$Res> implements $SnFriendOverviewItemCopyWith<$Res> {
|
||||||
|
factory _$SnFriendOverviewItemCopyWith(_SnFriendOverviewItem value, $Res Function(_SnFriendOverviewItem) _then) = __$SnFriendOverviewItemCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
SnAccount account, SnAccountStatus status, List<SnPresenceActivity> activities
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@override $SnAccountCopyWith<$Res> get account;@override $SnAccountStatusCopyWith<$Res> get status;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$SnFriendOverviewItemCopyWithImpl<$Res>
|
||||||
|
implements _$SnFriendOverviewItemCopyWith<$Res> {
|
||||||
|
__$SnFriendOverviewItemCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _SnFriendOverviewItem _self;
|
||||||
|
final $Res Function(_SnFriendOverviewItem) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? account = null,Object? status = null,Object? activities = null,}) {
|
||||||
|
return _then(_SnFriendOverviewItem(
|
||||||
|
account: null == account ? _self.account : account // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnAccount,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnAccountStatus,activities: null == activities ? _self._activities : activities // ignore: cast_nullable_to_non_nullable
|
||||||
|
as List<SnPresenceActivity>,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnAccountCopyWith<$Res> get account {
|
||||||
|
|
||||||
|
return $SnAccountCopyWith<$Res>(_self.account, (value) {
|
||||||
|
return _then(_self.copyWith(account: value));
|
||||||
|
});
|
||||||
|
}/// Create a copy of SnFriendOverviewItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnAccountStatusCopyWith<$Res> get status {
|
||||||
|
|
||||||
|
return $SnAccountStatusCopyWith<$Res>(_self.status, (value) {
|
||||||
|
return _then(_self.copyWith(status: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dart format on
|
// dart format on
|
||||||
|
|||||||
@@ -449,3 +449,22 @@ Map<String, dynamic> _$SnSocialCreditRecordToJson(
|
|||||||
'updated_at': instance.updatedAt.toIso8601String(),
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_SnFriendOverviewItem _$SnFriendOverviewItemFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _SnFriendOverviewItem(
|
||||||
|
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
|
||||||
|
status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
|
||||||
|
activities:
|
||||||
|
(json['activities'] as List<dynamic>)
|
||||||
|
.map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnFriendOverviewItemToJson(
|
||||||
|
_SnFriendOverviewItem instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'account': instance.account.toJson(),
|
||||||
|
'status': instance.status.toJson(),
|
||||||
|
'activities': instance.activities.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|||||||
57
lib/models/drive_task.dart
Normal file
57
lib/models/drive_task.dart
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
|
||||||
|
part 'drive_task.freezed.dart';
|
||||||
|
part 'drive_task.g.dart';
|
||||||
|
|
||||||
|
enum DriveTaskStatus {
|
||||||
|
pending,
|
||||||
|
inProgress,
|
||||||
|
paused,
|
||||||
|
completed,
|
||||||
|
failed,
|
||||||
|
expired,
|
||||||
|
cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class DriveTask with _$DriveTask {
|
||||||
|
const DriveTask._();
|
||||||
|
|
||||||
|
const factory DriveTask({
|
||||||
|
required String id,
|
||||||
|
required String taskId,
|
||||||
|
required String fileName,
|
||||||
|
required String contentType,
|
||||||
|
required int fileSize,
|
||||||
|
required int uploadedBytes,
|
||||||
|
required int totalChunks,
|
||||||
|
required int uploadedChunks,
|
||||||
|
required DriveTaskStatus status,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
required String type, // Task type (e.g., 'FileUpload')
|
||||||
|
double? transmissionProgress, // Local file upload progress (0.0-1.0)
|
||||||
|
String? errorMessage,
|
||||||
|
String? statusMessage,
|
||||||
|
SnCloudFile? result,
|
||||||
|
String? poolId,
|
||||||
|
String? bundleId,
|
||||||
|
String? encryptPassword,
|
||||||
|
String? expiredAt,
|
||||||
|
}) = _DriveTask;
|
||||||
|
|
||||||
|
factory DriveTask.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$DriveTaskFromJson(json);
|
||||||
|
|
||||||
|
double get progress => totalChunks > 0 ? uploadedChunks / totalChunks : 0.0;
|
||||||
|
|
||||||
|
Duration get estimatedTimeRemaining {
|
||||||
|
if (uploadedBytes == 0 || fileSize == 0) return Duration.zero;
|
||||||
|
final remainingBytes = fileSize - uploadedBytes;
|
||||||
|
final uploadRate =
|
||||||
|
uploadedBytes / createdAt.difference(DateTime.now()).inSeconds.abs();
|
||||||
|
if (uploadRate == 0) return Duration.zero;
|
||||||
|
return Duration(seconds: (remainingBytes / uploadRate).round());
|
||||||
|
}
|
||||||
|
}
|
||||||
356
lib/models/drive_task.freezed.dart
Normal file
356
lib/models/drive_task.freezed.dart
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// 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 'drive_task.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$DriveTask {
|
||||||
|
|
||||||
|
String get id; String get taskId; String get fileName; String get contentType; int get fileSize; int get uploadedBytes; int get totalChunks; int get uploadedChunks; DriveTaskStatus get status; DateTime get createdAt; DateTime get updatedAt; String get type;// Task type (e.g., 'FileUpload')
|
||||||
|
double? get transmissionProgress;// Local file upload progress (0.0-1.0)
|
||||||
|
String? get errorMessage; String? get statusMessage; SnCloudFile? get result; String? get poolId; String? get bundleId; String? get encryptPassword; String? get expiredAt;
|
||||||
|
/// Create a copy of DriveTask
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$DriveTaskCopyWith<DriveTask> get copyWith => _$DriveTaskCopyWithImpl<DriveTask>(this as DriveTask, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this DriveTask to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is DriveTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.type, type) || other.type == type)&&(identical(other.transmissionProgress, transmissionProgress) || other.transmissionProgress == transmissionProgress)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.statusMessage, statusMessage) || other.statusMessage == statusMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll([runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,type,transmissionProgress,errorMessage,statusMessage,result,poolId,bundleId,encryptPassword,expiredAt]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriveTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, type: $type, transmissionProgress: $transmissionProgress, errorMessage: $errorMessage, statusMessage: $statusMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $DriveTaskCopyWith<$Res> {
|
||||||
|
factory $DriveTaskCopyWith(DriveTask value, $Res Function(DriveTask) _then) = _$DriveTaskCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$SnCloudFileCopyWith<$Res>? get result;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$DriveTaskCopyWithImpl<$Res>
|
||||||
|
implements $DriveTaskCopyWith<$Res> {
|
||||||
|
_$DriveTaskCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final DriveTask _self;
|
||||||
|
final $Res Function(DriveTask) _then;
|
||||||
|
|
||||||
|
/// Create a copy of DriveTask
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? type = null,Object? transmissionProgress = freezed,Object? errorMessage = freezed,Object? statusMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,fileName: null == fileName ? _self.fileName : fileName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,contentType: null == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,fileSize: null == fileSize ? _self.fileSize : fileSize // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,uploadedBytes: null == uploadedBytes ? _self.uploadedBytes : uploadedBytes // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,totalChunks: null == totalChunks ? _self.totalChunks : totalChunks // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedChunks // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DriveTaskStatus,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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,transmissionProgress: freezed == transmissionProgress ? _self.transmissionProgress : transmissionProgress // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,statusMessage: freezed == statusMessage ? _self.statusMessage : statusMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,encryptPassword: freezed == encryptPassword ? _self.encryptPassword : encryptPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
/// Create a copy of DriveTask
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileCopyWith<$Res>? get result {
|
||||||
|
if (_self.result == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $SnCloudFileCopyWith<$Res>(_self.result!, (value) {
|
||||||
|
return _then(_self.copyWith(result: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [DriveTask].
|
||||||
|
extension DriveTaskPatterns on DriveTask {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _DriveTask value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _DriveTask() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _DriveTask value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _DriveTask():
|
||||||
|
return $default(_that);}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _DriveTask value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _DriveTask() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _DriveTask() when $default != null:
|
||||||
|
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.type,_that.transmissionProgress,_that.errorMessage,_that.statusMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _DriveTask():
|
||||||
|
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.type,_that.transmissionProgress,_that.errorMessage,_that.statusMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _DriveTask() when $default != null:
|
||||||
|
return $default(_that.id,_that.taskId,_that.fileName,_that.contentType,_that.fileSize,_that.uploadedBytes,_that.totalChunks,_that.uploadedChunks,_that.status,_that.createdAt,_that.updatedAt,_that.type,_that.transmissionProgress,_that.errorMessage,_that.statusMessage,_that.result,_that.poolId,_that.bundleId,_that.encryptPassword,_that.expiredAt);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _DriveTask extends DriveTask {
|
||||||
|
const _DriveTask({required this.id, required this.taskId, required this.fileName, required this.contentType, required this.fileSize, required this.uploadedBytes, required this.totalChunks, required this.uploadedChunks, required this.status, required this.createdAt, required this.updatedAt, required this.type, this.transmissionProgress, this.errorMessage, this.statusMessage, this.result, this.poolId, this.bundleId, this.encryptPassword, this.expiredAt}): super._();
|
||||||
|
factory _DriveTask.fromJson(Map<String, dynamic> json) => _$DriveTaskFromJson(json);
|
||||||
|
|
||||||
|
@override final String id;
|
||||||
|
@override final String taskId;
|
||||||
|
@override final String fileName;
|
||||||
|
@override final String contentType;
|
||||||
|
@override final int fileSize;
|
||||||
|
@override final int uploadedBytes;
|
||||||
|
@override final int totalChunks;
|
||||||
|
@override final int uploadedChunks;
|
||||||
|
@override final DriveTaskStatus status;
|
||||||
|
@override final DateTime createdAt;
|
||||||
|
@override final DateTime updatedAt;
|
||||||
|
@override final String type;
|
||||||
|
// Task type (e.g., 'FileUpload')
|
||||||
|
@override final double? transmissionProgress;
|
||||||
|
// Local file upload progress (0.0-1.0)
|
||||||
|
@override final String? errorMessage;
|
||||||
|
@override final String? statusMessage;
|
||||||
|
@override final SnCloudFile? result;
|
||||||
|
@override final String? poolId;
|
||||||
|
@override final String? bundleId;
|
||||||
|
@override final String? encryptPassword;
|
||||||
|
@override final String? expiredAt;
|
||||||
|
|
||||||
|
/// Create a copy of DriveTask
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$DriveTaskCopyWith<_DriveTask> get copyWith => __$DriveTaskCopyWithImpl<_DriveTask>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$DriveTaskToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _DriveTask&&(identical(other.id, id) || other.id == id)&&(identical(other.taskId, taskId) || other.taskId == taskId)&&(identical(other.fileName, fileName) || other.fileName == fileName)&&(identical(other.contentType, contentType) || other.contentType == contentType)&&(identical(other.fileSize, fileSize) || other.fileSize == fileSize)&&(identical(other.uploadedBytes, uploadedBytes) || other.uploadedBytes == uploadedBytes)&&(identical(other.totalChunks, totalChunks) || other.totalChunks == totalChunks)&&(identical(other.uploadedChunks, uploadedChunks) || other.uploadedChunks == uploadedChunks)&&(identical(other.status, status) || other.status == status)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.type, type) || other.type == type)&&(identical(other.transmissionProgress, transmissionProgress) || other.transmissionProgress == transmissionProgress)&&(identical(other.errorMessage, errorMessage) || other.errorMessage == errorMessage)&&(identical(other.statusMessage, statusMessage) || other.statusMessage == statusMessage)&&(identical(other.result, result) || other.result == result)&&(identical(other.poolId, poolId) || other.poolId == poolId)&&(identical(other.bundleId, bundleId) || other.bundleId == bundleId)&&(identical(other.encryptPassword, encryptPassword) || other.encryptPassword == encryptPassword)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hashAll([runtimeType,id,taskId,fileName,contentType,fileSize,uploadedBytes,totalChunks,uploadedChunks,status,createdAt,updatedAt,type,transmissionProgress,errorMessage,statusMessage,result,poolId,bundleId,encryptPassword,expiredAt]);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'DriveTask(id: $id, taskId: $taskId, fileName: $fileName, contentType: $contentType, fileSize: $fileSize, uploadedBytes: $uploadedBytes, totalChunks: $totalChunks, uploadedChunks: $uploadedChunks, status: $status, createdAt: $createdAt, updatedAt: $updatedAt, type: $type, transmissionProgress: $transmissionProgress, errorMessage: $errorMessage, statusMessage: $statusMessage, result: $result, poolId: $poolId, bundleId: $bundleId, encryptPassword: $encryptPassword, expiredAt: $expiredAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$DriveTaskCopyWith<$Res> implements $DriveTaskCopyWith<$Res> {
|
||||||
|
factory _$DriveTaskCopyWith(_DriveTask value, $Res Function(_DriveTask) _then) = __$DriveTaskCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String taskId, String fileName, String contentType, int fileSize, int uploadedBytes, int totalChunks, int uploadedChunks, DriveTaskStatus status, DateTime createdAt, DateTime updatedAt, String type, double? transmissionProgress, String? errorMessage, String? statusMessage, SnCloudFile? result, String? poolId, String? bundleId, String? encryptPassword, String? expiredAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@override $SnCloudFileCopyWith<$Res>? get result;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$DriveTaskCopyWithImpl<$Res>
|
||||||
|
implements _$DriveTaskCopyWith<$Res> {
|
||||||
|
__$DriveTaskCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _DriveTask _self;
|
||||||
|
final $Res Function(_DriveTask) _then;
|
||||||
|
|
||||||
|
/// Create a copy of DriveTask
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? taskId = null,Object? fileName = null,Object? contentType = null,Object? fileSize = null,Object? uploadedBytes = null,Object? totalChunks = null,Object? uploadedChunks = null,Object? status = null,Object? createdAt = null,Object? updatedAt = null,Object? type = null,Object? transmissionProgress = freezed,Object? errorMessage = freezed,Object? statusMessage = freezed,Object? result = freezed,Object? poolId = freezed,Object? bundleId = freezed,Object? encryptPassword = freezed,Object? expiredAt = freezed,}) {
|
||||||
|
return _then(_DriveTask(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,taskId: null == taskId ? _self.taskId : taskId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,fileName: null == fileName ? _self.fileName : fileName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,contentType: null == contentType ? _self.contentType : contentType // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,fileSize: null == fileSize ? _self.fileSize : fileSize // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,uploadedBytes: null == uploadedBytes ? _self.uploadedBytes : uploadedBytes // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,totalChunks: null == totalChunks ? _self.totalChunks : totalChunks // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,uploadedChunks: null == uploadedChunks ? _self.uploadedChunks : uploadedChunks // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||||
|
as DriveTaskStatus,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,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,transmissionProgress: freezed == transmissionProgress ? _self.transmissionProgress : transmissionProgress // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double?,errorMessage: freezed == errorMessage ? _self.errorMessage : errorMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,statusMessage: freezed == statusMessage ? _self.statusMessage : statusMessage // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,result: freezed == result ? _self.result : result // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnCloudFile?,poolId: freezed == poolId ? _self.poolId : poolId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,bundleId: freezed == bundleId ? _self.bundleId : bundleId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,encryptPassword: freezed == encryptPassword ? _self.encryptPassword : encryptPassword // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of DriveTask
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileCopyWith<$Res>? get result {
|
||||||
|
if (_self.result == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $SnCloudFileCopyWith<$Res>(_self.result!, (value) {
|
||||||
|
return _then(_self.copyWith(result: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
67
lib/models/drive_task.g.dart
Normal file
67
lib/models/drive_task.g.dart
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'drive_task.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_DriveTask _$DriveTaskFromJson(Map<String, dynamic> json) => _DriveTask(
|
||||||
|
id: json['id'] as String,
|
||||||
|
taskId: json['task_id'] as String,
|
||||||
|
fileName: json['file_name'] as String,
|
||||||
|
contentType: json['content_type'] as String,
|
||||||
|
fileSize: (json['file_size'] as num).toInt(),
|
||||||
|
uploadedBytes: (json['uploaded_bytes'] as num).toInt(),
|
||||||
|
totalChunks: (json['total_chunks'] as num).toInt(),
|
||||||
|
uploadedChunks: (json['uploaded_chunks'] as num).toInt(),
|
||||||
|
status: $enumDecode(_$DriveTaskStatusEnumMap, json['status']),
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
type: json['type'] as String,
|
||||||
|
transmissionProgress: (json['transmission_progress'] as num?)?.toDouble(),
|
||||||
|
errorMessage: json['error_message'] as String?,
|
||||||
|
statusMessage: json['status_message'] as String?,
|
||||||
|
result:
|
||||||
|
json['result'] == null
|
||||||
|
? null
|
||||||
|
: SnCloudFile.fromJson(json['result'] as Map<String, dynamic>),
|
||||||
|
poolId: json['pool_id'] as String?,
|
||||||
|
bundleId: json['bundle_id'] as String?,
|
||||||
|
encryptPassword: json['encrypt_password'] as String?,
|
||||||
|
expiredAt: json['expired_at'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$DriveTaskToJson(_DriveTask instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'task_id': instance.taskId,
|
||||||
|
'file_name': instance.fileName,
|
||||||
|
'content_type': instance.contentType,
|
||||||
|
'file_size': instance.fileSize,
|
||||||
|
'uploaded_bytes': instance.uploadedBytes,
|
||||||
|
'total_chunks': instance.totalChunks,
|
||||||
|
'uploaded_chunks': instance.uploadedChunks,
|
||||||
|
'status': _$DriveTaskStatusEnumMap[instance.status]!,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'type': instance.type,
|
||||||
|
'transmission_progress': instance.transmissionProgress,
|
||||||
|
'error_message': instance.errorMessage,
|
||||||
|
'status_message': instance.statusMessage,
|
||||||
|
'result': instance.result?.toJson(),
|
||||||
|
'pool_id': instance.poolId,
|
||||||
|
'bundle_id': instance.bundleId,
|
||||||
|
'encrypt_password': instance.encryptPassword,
|
||||||
|
'expired_at': instance.expiredAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$DriveTaskStatusEnumMap = {
|
||||||
|
DriveTaskStatus.pending: 'pending',
|
||||||
|
DriveTaskStatus.inProgress: 'inProgress',
|
||||||
|
DriveTaskStatus.paused: 'paused',
|
||||||
|
DriveTaskStatus.completed: 'completed',
|
||||||
|
DriveTaskStatus.failed: 'failed',
|
||||||
|
DriveTaskStatus.expired: 'expired',
|
||||||
|
DriveTaskStatus.cancelled: 'cancelled',
|
||||||
|
};
|
||||||
@@ -60,3 +60,19 @@ sealed class SnCloudFile with _$SnCloudFile {
|
|||||||
factory SnCloudFile.fromJson(Map<String, dynamic> json) =>
|
factory SnCloudFile.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnCloudFileFromJson(json);
|
_$SnCloudFileFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnCloudFileIndex with _$SnCloudFileIndex {
|
||||||
|
const factory SnCloudFileIndex({
|
||||||
|
required String id,
|
||||||
|
required String path,
|
||||||
|
required String fileId,
|
||||||
|
required SnCloudFile file,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
required DateTime? deletedAt,
|
||||||
|
}) = _SnCloudFileIndex;
|
||||||
|
|
||||||
|
factory SnCloudFileIndex.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnCloudFileIndexFromJson(json);
|
||||||
|
}
|
||||||
|
|||||||
@@ -622,4 +622,297 @@ $SnFilePoolCopyWith<$Res>? get pool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnCloudFileIndex {
|
||||||
|
|
||||||
|
String get id; String get path; String get fileId; SnCloudFile get file; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||||
|
/// Create a copy of SnCloudFileIndex
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileIndexCopyWith<SnCloudFileIndex> get copyWith => _$SnCloudFileIndexCopyWithImpl<SnCloudFileIndex>(this as SnCloudFileIndex, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this SnCloudFileIndex to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFileIndex&&(identical(other.id, id) || other.id == id)&&(identical(other.path, path) || other.path == path)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,id,path,fileId,file,createdAt,updatedAt,deletedAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnCloudFileIndex(id: $id, path: $path, fileId: $fileId, file: $file, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $SnCloudFileIndexCopyWith<$Res> {
|
||||||
|
factory $SnCloudFileIndexCopyWith(SnCloudFileIndex value, $Res Function(SnCloudFileIndex) _then) = _$SnCloudFileIndexCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$SnCloudFileCopyWith<$Res> get file;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$SnCloudFileIndexCopyWithImpl<$Res>
|
||||||
|
implements $SnCloudFileIndexCopyWith<$Res> {
|
||||||
|
_$SnCloudFileIndexCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final SnCloudFileIndex _self;
|
||||||
|
final $Res Function(SnCloudFileIndex) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFileIndex
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? path = null,Object? fileId = null,Object? file = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnCloudFile,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?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
/// Create a copy of SnCloudFileIndex
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileCopyWith<$Res> get file {
|
||||||
|
|
||||||
|
return $SnCloudFileCopyWith<$Res>(_self.file, (value) {
|
||||||
|
return _then(_self.copyWith(file: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [SnCloudFileIndex].
|
||||||
|
extension SnCloudFileIndexPatterns on SnCloudFileIndex {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnCloudFileIndex value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFileIndex() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnCloudFileIndex value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFileIndex():
|
||||||
|
return $default(_that);}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnCloudFileIndex value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFileIndex() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFileIndex() when $default != null:
|
||||||
|
return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFileIndex():
|
||||||
|
return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFileIndex() when $default != null:
|
||||||
|
return $default(_that.id,_that.path,_that.fileId,_that.file,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _SnCloudFileIndex implements SnCloudFileIndex {
|
||||||
|
const _SnCloudFileIndex({required this.id, required this.path, required this.fileId, required this.file, required this.createdAt, required this.updatedAt, required this.deletedAt});
|
||||||
|
factory _SnCloudFileIndex.fromJson(Map<String, dynamic> json) => _$SnCloudFileIndexFromJson(json);
|
||||||
|
|
||||||
|
@override final String id;
|
||||||
|
@override final String path;
|
||||||
|
@override final String fileId;
|
||||||
|
@override final SnCloudFile file;
|
||||||
|
@override final DateTime createdAt;
|
||||||
|
@override final DateTime updatedAt;
|
||||||
|
@override final DateTime? deletedAt;
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFileIndex
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$SnCloudFileIndexCopyWith<_SnCloudFileIndex> get copyWith => __$SnCloudFileIndexCopyWithImpl<_SnCloudFileIndex>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$SnCloudFileIndexToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFileIndex&&(identical(other.id, id) || other.id == id)&&(identical(other.path, path) || other.path == path)&&(identical(other.fileId, fileId) || other.fileId == fileId)&&(identical(other.file, file) || other.file == file)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,id,path,fileId,file,createdAt,updatedAt,deletedAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnCloudFileIndex(id: $id, path: $path, fileId: $fileId, file: $file, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$SnCloudFileIndexCopyWith<$Res> implements $SnCloudFileIndexCopyWith<$Res> {
|
||||||
|
factory _$SnCloudFileIndexCopyWith(_SnCloudFileIndex value, $Res Function(_SnCloudFileIndex) _then) = __$SnCloudFileIndexCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String path, String fileId, SnCloudFile file, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@override $SnCloudFileCopyWith<$Res> get file;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$SnCloudFileIndexCopyWithImpl<$Res>
|
||||||
|
implements _$SnCloudFileIndexCopyWith<$Res> {
|
||||||
|
__$SnCloudFileIndexCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _SnCloudFileIndex _self;
|
||||||
|
final $Res Function(_SnCloudFileIndex) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFileIndex
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? path = null,Object? fileId = null,Object? file = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||||
|
return _then(_SnCloudFileIndex(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,path: null == path ? _self.path : path // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,fileId: null == fileId ? _self.fileId : fileId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,file: null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnCloudFile,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?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFileIndex
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileCopyWith<$Res> get file {
|
||||||
|
|
||||||
|
return $SnCloudFileCopyWith<$Res>(_self.file, (value) {
|
||||||
|
return _then(_self.copyWith(file: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// dart format on
|
// dart format on
|
||||||
|
|||||||
@@ -78,3 +78,28 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
|
|||||||
'updated_at': instance.updatedAt.toIso8601String(),
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_SnCloudFileIndex _$SnCloudFileIndexFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnCloudFileIndex(
|
||||||
|
id: json['id'] as String,
|
||||||
|
path: json['path'] as String,
|
||||||
|
fileId: json['file_id'] as String,
|
||||||
|
file: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnCloudFileIndexToJson(_SnCloudFileIndex instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'path': instance.path,
|
||||||
|
'file_id': instance.fileId,
|
||||||
|
'file': instance.file.toJson(),
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|||||||
12
lib/models/file_list_item.dart
Normal file
12
lib/models/file_list_item.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
|
||||||
|
part 'file_list_item.freezed.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class FileListItem with _$FileListItem {
|
||||||
|
const factory FileListItem.file(SnCloudFileIndex fileIndex) = FileItem;
|
||||||
|
const factory FileListItem.folder(String folderName) = FolderItem;
|
||||||
|
const factory FileListItem.unindexedFile(SnCloudFile file) =
|
||||||
|
UnindexedFileItem;
|
||||||
|
}
|
||||||
396
lib/models/file_list_item.freezed.dart
Normal file
396
lib/models/file_list_item.freezed.dart
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// 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 'file_list_item.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$FileListItem {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is FileListItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => runtimeType.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FileListItem()';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
class $FileListItemCopyWith<$Res> {
|
||||||
|
$FileListItemCopyWith(FileListItem _, $Res Function(FileListItem) __);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [FileListItem].
|
||||||
|
extension FileListItemPatterns on FileListItem {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>({TResult Function( FileItem value)? file,TResult Function( FolderItem value)? folder,TResult Function( UnindexedFileItem value)? unindexedFile,required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case FileItem() when file != null:
|
||||||
|
return file(_that);case FolderItem() when folder != null:
|
||||||
|
return folder(_that);case UnindexedFileItem() when unindexedFile != null:
|
||||||
|
return unindexedFile(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>({required TResult Function( FileItem value) file,required TResult Function( FolderItem value) folder,required TResult Function( UnindexedFileItem value) unindexedFile,}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case FileItem():
|
||||||
|
return file(_that);case FolderItem():
|
||||||
|
return folder(_that);case UnindexedFileItem():
|
||||||
|
return unindexedFile(_that);}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>({TResult? Function( FileItem value)? file,TResult? Function( FolderItem value)? folder,TResult? Function( UnindexedFileItem value)? unindexedFile,}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case FileItem() when file != null:
|
||||||
|
return file(_that);case FolderItem() when folder != null:
|
||||||
|
return folder(_that);case UnindexedFileItem() when unindexedFile != null:
|
||||||
|
return unindexedFile(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>({TResult Function( SnCloudFileIndex fileIndex)? file,TResult Function( String folderName)? folder,TResult Function( SnCloudFile file)? unindexedFile,required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case FileItem() when file != null:
|
||||||
|
return file(_that.fileIndex);case FolderItem() when folder != null:
|
||||||
|
return folder(_that.folderName);case UnindexedFileItem() when unindexedFile != null:
|
||||||
|
return unindexedFile(_that.file);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>({required TResult Function( SnCloudFileIndex fileIndex) file,required TResult Function( String folderName) folder,required TResult Function( SnCloudFile file) unindexedFile,}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case FileItem():
|
||||||
|
return file(_that.fileIndex);case FolderItem():
|
||||||
|
return folder(_that.folderName);case UnindexedFileItem():
|
||||||
|
return unindexedFile(_that.file);}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>({TResult? Function( SnCloudFileIndex fileIndex)? file,TResult? Function( String folderName)? folder,TResult? Function( SnCloudFile file)? unindexedFile,}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case FileItem() when file != null:
|
||||||
|
return file(_that.fileIndex);case FolderItem() when folder != null:
|
||||||
|
return folder(_that.folderName);case UnindexedFileItem() when unindexedFile != null:
|
||||||
|
return unindexedFile(_that.file);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
|
||||||
|
class FileItem implements FileListItem {
|
||||||
|
const FileItem(this.fileIndex);
|
||||||
|
|
||||||
|
|
||||||
|
final SnCloudFileIndex fileIndex;
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$FileItemCopyWith<FileItem> get copyWith => _$FileItemCopyWithImpl<FileItem>(this, _$identity);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is FileItem&&(identical(other.fileIndex, fileIndex) || other.fileIndex == fileIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,fileIndex);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FileListItem.file(fileIndex: $fileIndex)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $FileItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> {
|
||||||
|
factory $FileItemCopyWith(FileItem value, $Res Function(FileItem) _then) = _$FileItemCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
SnCloudFileIndex fileIndex
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$SnCloudFileIndexCopyWith<$Res> get fileIndex;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$FileItemCopyWithImpl<$Res>
|
||||||
|
implements $FileItemCopyWith<$Res> {
|
||||||
|
_$FileItemCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final FileItem _self;
|
||||||
|
final $Res Function(FileItem) _then;
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') $Res call({Object? fileIndex = null,}) {
|
||||||
|
return _then(FileItem(
|
||||||
|
null == fileIndex ? _self.fileIndex : fileIndex // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnCloudFileIndex,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileIndexCopyWith<$Res> get fileIndex {
|
||||||
|
|
||||||
|
return $SnCloudFileIndexCopyWith<$Res>(_self.fileIndex, (value) {
|
||||||
|
return _then(_self.copyWith(fileIndex: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
|
||||||
|
class FolderItem implements FileListItem {
|
||||||
|
const FolderItem(this.folderName);
|
||||||
|
|
||||||
|
|
||||||
|
final String folderName;
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$FolderItemCopyWith<FolderItem> get copyWith => _$FolderItemCopyWithImpl<FolderItem>(this, _$identity);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is FolderItem&&(identical(other.folderName, folderName) || other.folderName == folderName));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,folderName);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FileListItem.folder(folderName: $folderName)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $FolderItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> {
|
||||||
|
factory $FolderItemCopyWith(FolderItem value, $Res Function(FolderItem) _then) = _$FolderItemCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String folderName
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$FolderItemCopyWithImpl<$Res>
|
||||||
|
implements $FolderItemCopyWith<$Res> {
|
||||||
|
_$FolderItemCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final FolderItem _self;
|
||||||
|
final $Res Function(FolderItem) _then;
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') $Res call({Object? folderName = null,}) {
|
||||||
|
return _then(FolderItem(
|
||||||
|
null == folderName ? _self.folderName : folderName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
|
||||||
|
|
||||||
|
class UnindexedFileItem implements FileListItem {
|
||||||
|
const UnindexedFileItem(this.file);
|
||||||
|
|
||||||
|
|
||||||
|
final SnCloudFile file;
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$UnindexedFileItemCopyWith<UnindexedFileItem> get copyWith => _$UnindexedFileItemCopyWithImpl<UnindexedFileItem>(this, _$identity);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is UnindexedFileItem&&(identical(other.file, file) || other.file == file));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,file);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'FileListItem.unindexedFile(file: $file)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $UnindexedFileItemCopyWith<$Res> implements $FileListItemCopyWith<$Res> {
|
||||||
|
factory $UnindexedFileItemCopyWith(UnindexedFileItem value, $Res Function(UnindexedFileItem) _then) = _$UnindexedFileItemCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
SnCloudFile file
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
$SnCloudFileCopyWith<$Res> get file;
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$UnindexedFileItemCopyWithImpl<$Res>
|
||||||
|
implements $UnindexedFileItemCopyWith<$Res> {
|
||||||
|
_$UnindexedFileItemCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final UnindexedFileItem _self;
|
||||||
|
final $Res Function(UnindexedFileItem) _then;
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') $Res call({Object? file = null,}) {
|
||||||
|
return _then(UnindexedFileItem(
|
||||||
|
null == file ? _self.file : file // ignore: cast_nullable_to_non_nullable
|
||||||
|
as SnCloudFile,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a copy of FileListItem
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFileCopyWith<$Res> get file {
|
||||||
|
|
||||||
|
return $SnCloudFileCopyWith<$Res>(_self.file, (value) {
|
||||||
|
return _then(_self.copyWith(file: value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
19
lib/models/folder.dart
Normal file
19
lib/models/folder.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'folder.freezed.dart';
|
||||||
|
part 'folder.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnCloudFolder with _$SnCloudFolder {
|
||||||
|
const factory SnCloudFolder({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String? parentFolderId,
|
||||||
|
required String accountId,
|
||||||
|
required DateTime createdAt,
|
||||||
|
required DateTime updatedAt,
|
||||||
|
}) = _SnCloudFolder;
|
||||||
|
|
||||||
|
factory SnCloudFolder.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnCloudFolderFromJson(json);
|
||||||
|
}
|
||||||
286
lib/models/folder.freezed.dart
Normal file
286
lib/models/folder.freezed.dart
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// 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 'folder.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$SnCloudFolder {
|
||||||
|
|
||||||
|
String get id; String get name; String? get parentFolderId; String get accountId; DateTime get createdAt; DateTime get updatedAt;
|
||||||
|
/// Create a copy of SnCloudFolder
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$SnCloudFolderCopyWith<SnCloudFolder> get copyWith => _$SnCloudFolderCopyWithImpl<SnCloudFolder>(this as SnCloudFolder, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this SnCloudFolder to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnCloudFolder&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.parentFolderId, parentFolderId) || other.parentFolderId == parentFolderId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,id,name,parentFolderId,accountId,createdAt,updatedAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnCloudFolder(id: $id, name: $name, parentFolderId: $parentFolderId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $SnCloudFolderCopyWith<$Res> {
|
||||||
|
factory $SnCloudFolderCopyWith(SnCloudFolder value, $Res Function(SnCloudFolder) _then) = _$SnCloudFolderCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$SnCloudFolderCopyWithImpl<$Res>
|
||||||
|
implements $SnCloudFolderCopyWith<$Res> {
|
||||||
|
_$SnCloudFolderCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final SnCloudFolder _self;
|
||||||
|
final $Res Function(SnCloudFolder) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFolder
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? parentFolderId = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,}) {
|
||||||
|
return _then(_self.copyWith(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,parentFolderId: freezed == parentFolderId ? _self.parentFolderId : parentFolderId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [SnCloudFolder].
|
||||||
|
extension SnCloudFolderPatterns on SnCloudFolder {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _SnCloudFolder value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFolder() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _SnCloudFolder value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFolder():
|
||||||
|
return $default(_that);}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _SnCloudFolder value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFolder() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFolder() when $default != null:
|
||||||
|
return $default(_that.id,_that.name,_that.parentFolderId,_that.accountId,_that.createdAt,_that.updatedAt);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFolder():
|
||||||
|
return $default(_that.id,_that.name,_that.parentFolderId,_that.accountId,_that.createdAt,_that.updatedAt);}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _SnCloudFolder() when $default != null:
|
||||||
|
return $default(_that.id,_that.name,_that.parentFolderId,_that.accountId,_that.createdAt,_that.updatedAt);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _SnCloudFolder implements SnCloudFolder {
|
||||||
|
const _SnCloudFolder({required this.id, required this.name, required this.parentFolderId, required this.accountId, required this.createdAt, required this.updatedAt});
|
||||||
|
factory _SnCloudFolder.fromJson(Map<String, dynamic> json) => _$SnCloudFolderFromJson(json);
|
||||||
|
|
||||||
|
@override final String id;
|
||||||
|
@override final String name;
|
||||||
|
@override final String? parentFolderId;
|
||||||
|
@override final String accountId;
|
||||||
|
@override final DateTime createdAt;
|
||||||
|
@override final DateTime updatedAt;
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFolder
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$SnCloudFolderCopyWith<_SnCloudFolder> get copyWith => __$SnCloudFolderCopyWithImpl<_SnCloudFolder>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$SnCloudFolderToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnCloudFolder&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.parentFolderId, parentFolderId) || other.parentFolderId == parentFolderId)&&(identical(other.accountId, accountId) || other.accountId == accountId)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(runtimeType,id,name,parentFolderId,accountId,createdAt,updatedAt);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'SnCloudFolder(id: $id, name: $name, parentFolderId: $parentFolderId, accountId: $accountId, createdAt: $createdAt, updatedAt: $updatedAt)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$SnCloudFolderCopyWith<$Res> implements $SnCloudFolderCopyWith<$Res> {
|
||||||
|
factory _$SnCloudFolderCopyWith(_SnCloudFolder value, $Res Function(_SnCloudFolder) _then) = __$SnCloudFolderCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String name, String? parentFolderId, String accountId, DateTime createdAt, DateTime updatedAt
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$SnCloudFolderCopyWithImpl<$Res>
|
||||||
|
implements _$SnCloudFolderCopyWith<$Res> {
|
||||||
|
__$SnCloudFolderCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _SnCloudFolder _self;
|
||||||
|
final $Res Function(_SnCloudFolder) _then;
|
||||||
|
|
||||||
|
/// Create a copy of SnCloudFolder
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? parentFolderId = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,}) {
|
||||||
|
return _then(_SnCloudFolder(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,parentFolderId: freezed == parentFolderId ? _self.parentFolderId : parentFolderId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
27
lib/models/folder.g.dart
Normal file
27
lib/models/folder.g.dart
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'folder.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_SnCloudFolder _$SnCloudFolderFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnCloudFolder(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
parentFolderId: json['parent_folder_id'] as String?,
|
||||||
|
accountId: json['account_id'] as String,
|
||||||
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnCloudFolderToJson(_SnCloudFolder instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'name': instance.name,
|
||||||
|
'parent_folder_id': instance.parentFolderId,
|
||||||
|
'account_id': instance.accountId,
|
||||||
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
|
};
|
||||||
@@ -46,6 +46,18 @@ sealed class SnPoll with _$SnPoll {
|
|||||||
}) = _SnPoll;
|
}) = _SnPoll;
|
||||||
|
|
||||||
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
|
factory SnPoll.fromJson(Map<String, dynamic> json) => _$SnPollFromJson(json);
|
||||||
|
|
||||||
|
factory SnPoll.fromPollWithStats(SnPollWithStats pollWithStats) => SnPoll(
|
||||||
|
id: pollWithStats.id,
|
||||||
|
questions: pollWithStats.questions,
|
||||||
|
title: pollWithStats.title,
|
||||||
|
description: pollWithStats.description,
|
||||||
|
endedAt: pollWithStats.endedAt,
|
||||||
|
publisherId: pollWithStats.publisherId,
|
||||||
|
createdAt: pollWithStats.createdAt,
|
||||||
|
updatedAt: pollWithStats.updatedAt,
|
||||||
|
deletedAt: pollWithStats.deletedAt,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
|
|||||||
@@ -38,6 +38,31 @@ class ThinkingChunkTypeConverter
|
|||||||
int toJson(ThinkingChunkType object) => object.value;
|
int toJson(ThinkingChunkType object) => object.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ThinkingMessagePartType {
|
||||||
|
text(0),
|
||||||
|
functionCall(1),
|
||||||
|
functionResult(2);
|
||||||
|
|
||||||
|
const ThinkingMessagePartType(this.value);
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
static ThinkingMessagePartType fromValue(int value) {
|
||||||
|
return values.firstWhere((e) => e.value == value, orElse: () => text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ThinkingMessagePartTypeConverter
|
||||||
|
implements JsonConverter<ThinkingMessagePartType, int> {
|
||||||
|
const ThinkingMessagePartTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ThinkingMessagePartType fromJson(int json) =>
|
||||||
|
ThinkingMessagePartType.fromValue(json);
|
||||||
|
|
||||||
|
@override
|
||||||
|
int toJson(ThinkingMessagePartType object) => object.value;
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
sealed class StreamThinkingRequest with _$StreamThinkingRequest {
|
sealed class StreamThinkingRequest with _$StreamThinkingRequest {
|
||||||
const factory StreamThinkingRequest({
|
const factory StreamThinkingRequest({
|
||||||
@@ -46,6 +71,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest {
|
|||||||
@Default([]) List<String> accpetProposals,
|
@Default([]) List<String> accpetProposals,
|
||||||
List<String>? attachedPosts,
|
List<String>? attachedPosts,
|
||||||
List<Map<String, dynamic>>? attachedMessages,
|
List<Map<String, dynamic>>? attachedMessages,
|
||||||
|
@JsonKey(name: 'service_id') String? serviceId,
|
||||||
}) = _StreamThinkingRequest;
|
}) = _StreamThinkingRequest;
|
||||||
|
|
||||||
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
|
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -77,6 +103,43 @@ sealed class SnThinkingChunk with _$SnThinkingChunk {
|
|||||||
_$SnThinkingChunkFromJson(json);
|
_$SnThinkingChunkFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnFunctionCall with _$SnFunctionCall {
|
||||||
|
const factory SnFunctionCall({
|
||||||
|
required String id,
|
||||||
|
required String name,
|
||||||
|
required String arguments,
|
||||||
|
}) = _SnFunctionCall;
|
||||||
|
|
||||||
|
factory SnFunctionCall.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnFunctionCallFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnFunctionResult with _$SnFunctionResult {
|
||||||
|
const factory SnFunctionResult({
|
||||||
|
required String callId,
|
||||||
|
required dynamic result,
|
||||||
|
required bool isError,
|
||||||
|
}) = _SnFunctionResult;
|
||||||
|
|
||||||
|
factory SnFunctionResult.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnFunctionResultFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class SnThinkingMessagePart with _$SnThinkingMessagePart {
|
||||||
|
const factory SnThinkingMessagePart({
|
||||||
|
@ThinkingMessagePartTypeConverter() required ThinkingMessagePartType type,
|
||||||
|
String? text,
|
||||||
|
SnFunctionCall? functionCall,
|
||||||
|
SnFunctionResult? functionResult,
|
||||||
|
}) = _SnThinkingMessagePart;
|
||||||
|
|
||||||
|
factory SnThinkingMessagePart.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$SnThinkingMessagePartFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
sealed class SnThinkingSequence with _$SnThinkingSequence {
|
sealed class SnThinkingSequence with _$SnThinkingSequence {
|
||||||
const factory SnThinkingSequence({
|
const factory SnThinkingSequence({
|
||||||
@@ -98,9 +161,8 @@ sealed class SnThinkingSequence with _$SnThinkingSequence {
|
|||||||
sealed class SnThinkingThought with _$SnThinkingThought {
|
sealed class SnThinkingThought with _$SnThinkingThought {
|
||||||
const factory SnThinkingThought({
|
const factory SnThinkingThought({
|
||||||
required String id,
|
required String id,
|
||||||
String? content,
|
@Default([]) List<SnThinkingMessagePart> parts,
|
||||||
@Default([]) List<SnCloudFile> files,
|
@Default([]) List<SnCloudFile> files,
|
||||||
@Default([]) List<SnThinkingChunk> chunks,
|
|
||||||
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
|
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
|
||||||
int? tokenCount,
|
int? tokenCount,
|
||||||
String? modelName,
|
String? modelName,
|
||||||
@@ -114,3 +176,26 @@ sealed class SnThinkingThought with _$SnThinkingThought {
|
|||||||
factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
|
factory SnThinkingThought.fromJson(Map<String, dynamic> json) =>
|
||||||
_$SnThinkingThoughtFromJson(json);
|
_$SnThinkingThoughtFromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class ThoughtService with _$ThoughtService {
|
||||||
|
const factory ThoughtService({
|
||||||
|
@JsonKey(name: 'service_id') required String serviceId,
|
||||||
|
required double billingMultiplier,
|
||||||
|
required int perkLevel,
|
||||||
|
}) = _ThoughtService;
|
||||||
|
|
||||||
|
factory ThoughtService.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ThoughtServiceFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
sealed class ThoughtServicesResponse with _$ThoughtServicesResponse {
|
||||||
|
const factory ThoughtServicesResponse({
|
||||||
|
@JsonKey(name: 'default_service') required String defaultService,
|
||||||
|
required List<ThoughtService> services,
|
||||||
|
}) = _ThoughtServicesResponse;
|
||||||
|
|
||||||
|
factory ThoughtServicesResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ThoughtServicesResponseFromJson(json);
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,7 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson(
|
|||||||
(json['attached_messages'] as List<dynamic>?)
|
(json['attached_messages'] as List<dynamic>?)
|
||||||
?.map((e) => e as Map<String, dynamic>)
|
?.map((e) => e as Map<String, dynamic>)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
serviceId: json['service_id'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$StreamThinkingRequestToJson(
|
Map<String, dynamic> _$StreamThinkingRequestToJson(
|
||||||
@@ -34,6 +35,7 @@ Map<String, dynamic> _$StreamThinkingRequestToJson(
|
|||||||
'accpet_proposals': instance.accpetProposals,
|
'accpet_proposals': instance.accpetProposals,
|
||||||
'attached_posts': instance.attachedPosts,
|
'attached_posts': instance.attachedPosts,
|
||||||
'attached_messages': instance.attachedMessages,
|
'attached_messages': instance.attachedMessages,
|
||||||
|
'service_id': instance.serviceId,
|
||||||
};
|
};
|
||||||
|
|
||||||
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
|
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
|
||||||
@@ -50,6 +52,64 @@ Map<String, dynamic> _$SnThinkingChunkToJson(_SnThinkingChunk instance) =>
|
|||||||
'data': instance.data,
|
'data': instance.data,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_SnFunctionCall _$SnFunctionCallFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnFunctionCall(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
arguments: json['arguments'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnFunctionCallToJson(_SnFunctionCall instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'name': instance.name,
|
||||||
|
'arguments': instance.arguments,
|
||||||
|
};
|
||||||
|
|
||||||
|
_SnFunctionResult _$SnFunctionResultFromJson(Map<String, dynamic> json) =>
|
||||||
|
_SnFunctionResult(
|
||||||
|
callId: json['call_id'] as String,
|
||||||
|
result: json['result'],
|
||||||
|
isError: json['is_error'] as bool,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnFunctionResultToJson(_SnFunctionResult instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'call_id': instance.callId,
|
||||||
|
'result': instance.result,
|
||||||
|
'is_error': instance.isError,
|
||||||
|
};
|
||||||
|
|
||||||
|
_SnThinkingMessagePart _$SnThinkingMessagePartFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _SnThinkingMessagePart(
|
||||||
|
type: const ThinkingMessagePartTypeConverter().fromJson(
|
||||||
|
(json['type'] as num).toInt(),
|
||||||
|
),
|
||||||
|
text: json['text'] as String?,
|
||||||
|
functionCall:
|
||||||
|
json['function_call'] == null
|
||||||
|
? null
|
||||||
|
: SnFunctionCall.fromJson(
|
||||||
|
json['function_call'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
functionResult:
|
||||||
|
json['function_result'] == null
|
||||||
|
? null
|
||||||
|
: SnFunctionResult.fromJson(
|
||||||
|
json['function_result'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$SnThinkingMessagePartToJson(
|
||||||
|
_SnThinkingMessagePart instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'type': const ThinkingMessagePartTypeConverter().toJson(instance.type),
|
||||||
|
'text': instance.text,
|
||||||
|
'function_call': instance.functionCall?.toJson(),
|
||||||
|
'function_result': instance.functionResult?.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
_SnThinkingSequence _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
|
_SnThinkingSequence _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
|
||||||
_SnThinkingSequence(
|
_SnThinkingSequence(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
@@ -80,17 +140,19 @@ Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
|
|||||||
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
|
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
|
||||||
_SnThinkingThought(
|
_SnThinkingThought(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
content: json['content'] as String?,
|
parts:
|
||||||
|
(json['parts'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) =>
|
||||||
|
SnThinkingMessagePart.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
files:
|
files:
|
||||||
(json['files'] as List<dynamic>?)
|
(json['files'] as List<dynamic>?)
|
||||||
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||||
.toList() ??
|
.toList() ??
|
||||||
const [],
|
const [],
|
||||||
chunks:
|
|
||||||
(json['chunks'] as List<dynamic>?)
|
|
||||||
?.map((e) => SnThinkingChunk.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList() ??
|
|
||||||
const [],
|
|
||||||
role: const ThinkingThoughtRoleConverter().fromJson(
|
role: const ThinkingThoughtRoleConverter().fromJson(
|
||||||
(json['role'] as num).toInt(),
|
(json['role'] as num).toInt(),
|
||||||
),
|
),
|
||||||
@@ -114,9 +176,8 @@ _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
|
|||||||
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
|
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'content': instance.content,
|
'parts': instance.parts.map((e) => e.toJson()).toList(),
|
||||||
'files': instance.files.map((e) => e.toJson()).toList(),
|
'files': instance.files.map((e) => e.toJson()).toList(),
|
||||||
'chunks': instance.chunks.map((e) => e.toJson()).toList(),
|
|
||||||
'role': const ThinkingThoughtRoleConverter().toJson(instance.role),
|
'role': const ThinkingThoughtRoleConverter().toJson(instance.role),
|
||||||
'token_count': instance.tokenCount,
|
'token_count': instance.tokenCount,
|
||||||
'model_name': instance.modelName,
|
'model_name': instance.modelName,
|
||||||
@@ -126,3 +187,34 @@ Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
|
|||||||
'updated_at': instance.updatedAt.toIso8601String(),
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_ThoughtService _$ThoughtServiceFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ThoughtService(
|
||||||
|
serviceId: json['service_id'] as String,
|
||||||
|
billingMultiplier: (json['billing_multiplier'] as num).toDouble(),
|
||||||
|
perkLevel: (json['perk_level'] as num).toInt(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ThoughtServiceToJson(_ThoughtService instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'service_id': instance.serviceId,
|
||||||
|
'billing_multiplier': instance.billingMultiplier,
|
||||||
|
'perk_level': instance.perkLevel,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ThoughtServicesResponse _$ThoughtServicesResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ThoughtServicesResponse(
|
||||||
|
defaultService: json['default_service'] as String,
|
||||||
|
services:
|
||||||
|
(json['services'] as List<dynamic>)
|
||||||
|
.map((e) => ThoughtService.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ThoughtServicesResponseToJson(
|
||||||
|
_ThoughtServicesResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'default_service': instance.defaultService,
|
||||||
|
'services': instance.services.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
|||||||
@@ -176,6 +176,8 @@ sealed class SnWalletFund with _$SnWalletFund {
|
|||||||
required String id,
|
required String id,
|
||||||
required String currency,
|
required String currency,
|
||||||
required double totalAmount,
|
required double totalAmount,
|
||||||
|
required double remainingAmount,
|
||||||
|
required int amountOfSplits,
|
||||||
required int splitType, // 0: even, 1: random
|
required int splitType, // 0: even, 1: random
|
||||||
required int
|
required int
|
||||||
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
|
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
|
||||||
@@ -184,6 +186,7 @@ sealed class SnWalletFund with _$SnWalletFund {
|
|||||||
required SnAccount? creatorAccount,
|
required SnAccount? creatorAccount,
|
||||||
required DateTime expiredAt,
|
required DateTime expiredAt,
|
||||||
required List<SnWalletFundRecipient> recipients,
|
required List<SnWalletFundRecipient> recipients,
|
||||||
|
required bool isOpen,
|
||||||
required DateTime createdAt,
|
required DateTime createdAt,
|
||||||
required DateTime updatedAt,
|
required DateTime updatedAt,
|
||||||
required DateTime? deletedAt,
|
required DateTime? deletedAt,
|
||||||
|
|||||||
@@ -2553,9 +2553,9 @@ $SnWalletSubscriptionCopyWith<$Res>? get subscription {
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$SnWalletFund {
|
mixin _$SnWalletFund {
|
||||||
|
|
||||||
String get id; String get currency; double get totalAmount; int get splitType;// 0: even, 1: random
|
String get id; String get currency; double get totalAmount; double get remainingAmount; int get amountOfSplits; int get splitType;// 0: even, 1: random
|
||||||
int get status;// 0: created, 1: partially claimed, 2: fully claimed, 3: expired
|
int get status;// 0: created, 1: partially claimed, 2: fully claimed, 3: expired
|
||||||
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
String? get message; String get creatorAccountId; SnAccount? get creatorAccount; DateTime get expiredAt; List<SnWalletFundRecipient> get recipients; bool get isOpen; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
|
||||||
/// Create a copy of SnWalletFund
|
/// Create a copy of SnWalletFund
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -2568,16 +2568,16 @@ $SnWalletFundCopyWith<SnWalletFund> get copyWith => _$SnWalletFundCopyWithImpl<S
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other.recipients, recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),createdAt,updatedAt,deletedAt);
|
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(recipients),isOpen,createdAt,updatedAt,deletedAt);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2588,7 +2588,7 @@ abstract mixin class $SnWalletFundCopyWith<$Res> {
|
|||||||
factory $SnWalletFundCopyWith(SnWalletFund value, $Res Function(SnWalletFund) _then) = _$SnWalletFundCopyWithImpl;
|
factory $SnWalletFundCopyWith(SnWalletFund value, $Res Function(SnWalletFund) _then) = _$SnWalletFundCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -2605,19 +2605,22 @@ class _$SnWalletFundCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of SnWalletFund
|
/// Create a copy of SnWalletFund
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||||
return _then(_self.copyWith(
|
return _then(_self.copyWith(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
||||||
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
|
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
|
||||||
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
||||||
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||||
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
|
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
|
||||||
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
|
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
|
||||||
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,recipients: null == recipients ? _self.recipients : recipients // ignore: cast_nullable_to_non_nullable
|
as DateTime,recipients: null == recipients ? _self.recipients : recipients // ignore: cast_nullable_to_non_nullable
|
||||||
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,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,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime?,
|
as DateTime?,
|
||||||
@@ -2714,10 +2717,10 @@ return $default(_that);case _:
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _SnWalletFund() when $default != null:
|
case _SnWalletFund() when $default != null:
|
||||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||||
return orElse();
|
return orElse();
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2735,10 +2738,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _SnWalletFund():
|
case _SnWalletFund():
|
||||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);}
|
||||||
}
|
}
|
||||||
/// A variant of `when` that fallback to returning `null`
|
/// A variant of `when` that fallback to returning `null`
|
||||||
///
|
///
|
||||||
@@ -2752,10 +2755,10 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
|
|||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
|
|
||||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
|
||||||
switch (_that) {
|
switch (_that) {
|
||||||
case _SnWalletFund() when $default != null:
|
case _SnWalletFund() when $default != null:
|
||||||
return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
return $default(_that.id,_that.currency,_that.totalAmount,_that.remainingAmount,_that.amountOfSplits,_that.splitType,_that.status,_that.message,_that.creatorAccountId,_that.creatorAccount,_that.expiredAt,_that.recipients,_that.isOpen,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -2767,12 +2770,14 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _SnWalletFund implements SnWalletFund {
|
class _SnWalletFund implements SnWalletFund {
|
||||||
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
|
const _SnWalletFund({required this.id, required this.currency, required this.totalAmount, required this.remainingAmount, required this.amountOfSplits, required this.splitType, required this.status, required this.message, required this.creatorAccountId, required this.creatorAccount, required this.expiredAt, required final List<SnWalletFundRecipient> recipients, required this.isOpen, required this.createdAt, required this.updatedAt, required this.deletedAt}): _recipients = recipients;
|
||||||
factory _SnWalletFund.fromJson(Map<String, dynamic> json) => _$SnWalletFundFromJson(json);
|
factory _SnWalletFund.fromJson(Map<String, dynamic> json) => _$SnWalletFundFromJson(json);
|
||||||
|
|
||||||
@override final String id;
|
@override final String id;
|
||||||
@override final String currency;
|
@override final String currency;
|
||||||
@override final double totalAmount;
|
@override final double totalAmount;
|
||||||
|
@override final double remainingAmount;
|
||||||
|
@override final int amountOfSplits;
|
||||||
@override final int splitType;
|
@override final int splitType;
|
||||||
// 0: even, 1: random
|
// 0: even, 1: random
|
||||||
@override final int status;
|
@override final int status;
|
||||||
@@ -2788,6 +2793,7 @@ class _SnWalletFund implements SnWalletFund {
|
|||||||
return EqualUnmodifiableListView(_recipients);
|
return EqualUnmodifiableListView(_recipients);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override final bool isOpen;
|
||||||
@override final DateTime createdAt;
|
@override final DateTime createdAt;
|
||||||
@override final DateTime updatedAt;
|
@override final DateTime updatedAt;
|
||||||
@override final DateTime? deletedAt;
|
@override final DateTime? deletedAt;
|
||||||
@@ -2805,16 +2811,16 @@ Map<String, dynamic> toJson() {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnWalletFund&&(identical(other.id, id) || other.id == id)&&(identical(other.currency, currency) || other.currency == currency)&&(identical(other.totalAmount, totalAmount) || other.totalAmount == totalAmount)&&(identical(other.remainingAmount, remainingAmount) || other.remainingAmount == remainingAmount)&&(identical(other.amountOfSplits, amountOfSplits) || other.amountOfSplits == amountOfSplits)&&(identical(other.splitType, splitType) || other.splitType == splitType)&&(identical(other.status, status) || other.status == status)&&(identical(other.message, message) || other.message == message)&&(identical(other.creatorAccountId, creatorAccountId) || other.creatorAccountId == creatorAccountId)&&(identical(other.creatorAccount, creatorAccount) || other.creatorAccount == creatorAccount)&&(identical(other.expiredAt, expiredAt) || other.expiredAt == expiredAt)&&const DeepCollectionEquality().equals(other._recipients, _recipients)&&(identical(other.isOpen, isOpen) || other.isOpen == isOpen)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt)&&(identical(other.deletedAt, deletedAt) || other.deletedAt == deletedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@override
|
@override
|
||||||
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),createdAt,updatedAt,deletedAt);
|
int get hashCode => Object.hash(runtimeType,id,currency,totalAmount,remainingAmount,amountOfSplits,splitType,status,message,creatorAccountId,creatorAccount,expiredAt,const DeepCollectionEquality().hash(_recipients),isOpen,createdAt,updatedAt,deletedAt);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
return 'SnWalletFund(id: $id, currency: $currency, totalAmount: $totalAmount, remainingAmount: $remainingAmount, amountOfSplits: $amountOfSplits, splitType: $splitType, status: $status, message: $message, creatorAccountId: $creatorAccountId, creatorAccount: $creatorAccount, expiredAt: $expiredAt, recipients: $recipients, isOpen: $isOpen, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -2825,7 +2831,7 @@ abstract mixin class _$SnWalletFundCopyWith<$Res> implements $SnWalletFundCopyWi
|
|||||||
factory _$SnWalletFundCopyWith(_SnWalletFund value, $Res Function(_SnWalletFund) _then) = __$SnWalletFundCopyWithImpl;
|
factory _$SnWalletFundCopyWith(_SnWalletFund value, $Res Function(_SnWalletFund) _then) = __$SnWalletFundCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$Res call({
|
||||||
String id, String currency, double totalAmount, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
String id, String currency, double totalAmount, double remainingAmount, int amountOfSplits, int splitType, int status, String? message, String creatorAccountId, SnAccount? creatorAccount, DateTime expiredAt, List<SnWalletFundRecipient> recipients, bool isOpen, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -2842,19 +2848,22 @@ class __$SnWalletFundCopyWithImpl<$Res>
|
|||||||
|
|
||||||
/// Create a copy of SnWalletFund
|
/// Create a copy of SnWalletFund
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? currency = null,Object? totalAmount = null,Object? remainingAmount = null,Object? amountOfSplits = null,Object? splitType = null,Object? status = null,Object? message = freezed,Object? creatorAccountId = null,Object? creatorAccount = freezed,Object? expiredAt = null,Object? recipients = null,Object? isOpen = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
|
||||||
return _then(_SnWalletFund(
|
return _then(_SnWalletFund(
|
||||||
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
as String,currency: null == currency ? _self.currency : currency // ignore: cast_nullable_to_non_nullable
|
||||||
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
|
as String,totalAmount: null == totalAmount ? _self.totalAmount : totalAmount // ignore: cast_nullable_to_non_nullable
|
||||||
as double,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
as double,remainingAmount: null == remainingAmount ? _self.remainingAmount : remainingAmount // ignore: cast_nullable_to_non_nullable
|
||||||
|
as double,amountOfSplits: null == amountOfSplits ? _self.amountOfSplits : amountOfSplits // ignore: cast_nullable_to_non_nullable
|
||||||
|
as int,splitType: null == splitType ? _self.splitType : splitType // ignore: cast_nullable_to_non_nullable
|
||||||
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
as int,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable
|
||||||
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
as int,message: freezed == message ? _self.message : message // ignore: cast_nullable_to_non_nullable
|
||||||
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
|
as String?,creatorAccountId: null == creatorAccountId ? _self.creatorAccountId : creatorAccountId // ignore: cast_nullable_to_non_nullable
|
||||||
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
|
as String,creatorAccount: freezed == creatorAccount ? _self.creatorAccount : creatorAccount // ignore: cast_nullable_to_non_nullable
|
||||||
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
as SnAccount?,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime,recipients: null == recipients ? _self._recipients : recipients // ignore: cast_nullable_to_non_nullable
|
as DateTime,recipients: null == recipients ? _self._recipients : recipients // ignore: cast_nullable_to_non_nullable
|
||||||
as List<SnWalletFundRecipient>,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
|
as List<SnWalletFundRecipient>,isOpen: null == isOpen ? _self.isOpen : isOpen // ignore: cast_nullable_to_non_nullable
|
||||||
|
as bool,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,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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
|
||||||
as DateTime?,
|
as DateTime?,
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ _SnWalletFund _$SnWalletFundFromJson(
|
|||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
currency: json['currency'] as String,
|
currency: json['currency'] as String,
|
||||||
totalAmount: (json['total_amount'] as num).toDouble(),
|
totalAmount: (json['total_amount'] as num).toDouble(),
|
||||||
|
remainingAmount: (json['remaining_amount'] as num).toDouble(),
|
||||||
|
amountOfSplits: (json['amount_of_splits'] as num).toInt(),
|
||||||
splitType: (json['split_type'] as num).toInt(),
|
splitType: (json['split_type'] as num).toInt(),
|
||||||
status: (json['status'] as num).toInt(),
|
status: (json['status'] as num).toInt(),
|
||||||
message: json['message'] as String?,
|
message: json['message'] as String?,
|
||||||
@@ -349,6 +351,7 @@ _SnWalletFund _$SnWalletFundFromJson(
|
|||||||
(json['recipients'] as List<dynamic>)
|
(json['recipients'] as List<dynamic>)
|
||||||
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
|
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
|
isOpen: json['is_open'] as bool,
|
||||||
createdAt: DateTime.parse(json['created_at'] as String),
|
createdAt: DateTime.parse(json['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||||
deletedAt:
|
deletedAt:
|
||||||
@@ -362,6 +365,8 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'currency': instance.currency,
|
'currency': instance.currency,
|
||||||
'total_amount': instance.totalAmount,
|
'total_amount': instance.totalAmount,
|
||||||
|
'remaining_amount': instance.remainingAmount,
|
||||||
|
'amount_of_splits': instance.amountOfSplits,
|
||||||
'split_type': instance.splitType,
|
'split_type': instance.splitType,
|
||||||
'status': instance.status,
|
'status': instance.status,
|
||||||
'message': instance.message,
|
'message': instance.message,
|
||||||
@@ -369,6 +374,7 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
|
|||||||
'creator_account': instance.creatorAccount?.toJson(),
|
'creator_account': instance.creatorAccount?.toJson(),
|
||||||
'expired_at': instance.expiredAt.toIso8601String(),
|
'expired_at': instance.expiredAt.toIso8601String(),
|
||||||
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
|
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
|
||||||
|
'is_open': instance.isOpen,
|
||||||
'created_at': instance.createdAt.toIso8601String(),
|
'created_at': instance.createdAt.toIso8601String(),
|
||||||
'updated_at': instance.updatedAt.toIso8601String(),
|
'updated_at': instance.updatedAt.toIso8601String(),
|
||||||
'deleted_at': instance.deletedAt?.toIso8601String(),
|
'deleted_at': instance.deletedAt?.toIso8601String(),
|
||||||
|
|||||||
@@ -120,9 +120,11 @@ class ActivityRpcServer {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Set up IPC close handler
|
// Set up IPC close handler
|
||||||
_ipcServer!.onSocketClose = (socket) {
|
if (!kIsWeb) {
|
||||||
handlers['close']?.call(socket);
|
(_ipcServer as dynamic).onSocketClose = (socket) {
|
||||||
};
|
handlers['close']?.call(socket);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await _ipcServer!.start();
|
await _ipcServer!.start();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import "package:dio/dio.dart";
|
|||||||
import "package:drift/drift.dart" show Variable;
|
import "package:drift/drift.dart" show Variable;
|
||||||
import "package:easy_localization/easy_localization.dart";
|
import "package:easy_localization/easy_localization.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:island/database/drift_db.dart";
|
import "package:island/database/drift_db.dart";
|
||||||
import "package:island/database/message.dart";
|
import "package:island/database/message.dart";
|
||||||
import "package:island/models/chat.dart";
|
import "package:island/models/chat.dart";
|
||||||
import "package:island/models/file.dart";
|
import "package:island/models/file.dart";
|
||||||
|
import "package:island/models/poll.dart";
|
||||||
|
import "package:island/models/wallet.dart";
|
||||||
import "package:island/pods/database.dart";
|
import "package:island/pods/database.dart";
|
||||||
import "package:island/pods/lifecycle.dart";
|
import "package:island/pods/lifecycle.dart";
|
||||||
import "package:island/pods/network.dart";
|
import "package:island/pods/network.dart";
|
||||||
@@ -28,7 +31,7 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
late final SnChatMember _identity;
|
late final SnChatMember _identity;
|
||||||
|
|
||||||
final Map<String, LocalChatMessage> _pendingMessages = {};
|
final Map<String, LocalChatMessage> _pendingMessages = {};
|
||||||
final Map<String, Map<int, double>> _fileUploadProgress = {};
|
final Map<String, Map<int, double?>> _fileUploadProgress = {};
|
||||||
int? _totalCount;
|
int? _totalCount;
|
||||||
String? _searchQuery;
|
String? _searchQuery;
|
||||||
bool? _withLinks;
|
bool? _withLinks;
|
||||||
@@ -433,12 +436,15 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(
|
Future<void> sendMessage(
|
||||||
|
WidgetRef ref,
|
||||||
String content,
|
String content,
|
||||||
List<UniversalFile> attachments, {
|
List<UniversalFile> attachments, {
|
||||||
|
SnPoll? poll,
|
||||||
|
SnWalletFund? fund,
|
||||||
SnChatMessage? editingTo,
|
SnChatMessage? editingTo,
|
||||||
SnChatMessage? forwardingTo,
|
SnChatMessage? forwardingTo,
|
||||||
SnChatMessage? replyingTo,
|
SnChatMessage? replyingTo,
|
||||||
Function(String, Map<int, double>)? onProgress,
|
Function(String, Map<int, double?>)? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
final nonce = const Uuid().v4();
|
final nonce = const Uuid().v4();
|
||||||
talker.log('Sending message with nonce $nonce');
|
talker.log('Sending message with nonce $nonce');
|
||||||
@@ -471,10 +477,10 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
for (var idx = 0; idx < attachments.length; idx++) {
|
for (var idx = 0; idx < attachments.length; idx++) {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
|
ref: ref,
|
||||||
fileData: attachments[idx],
|
fileData: attachments[idx],
|
||||||
client: ref.read(apiClientProvider),
|
|
||||||
onProgress: (progress, _) {
|
onProgress: (progress, _) {
|
||||||
_fileUploadProgress[localMessage.id]?[idx] = progress;
|
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
|
||||||
onProgress?.call(
|
onProgress?.call(
|
||||||
localMessage.id,
|
localMessage.id,
|
||||||
_fileUploadProgress[localMessage.id] ?? {},
|
_fileUploadProgress[localMessage.id] ?? {},
|
||||||
@@ -496,6 +502,8 @@ class MessagesNotifier extends _$MessagesNotifier {
|
|||||||
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
|
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
|
||||||
'replied_message_id': replyingTo?.id,
|
'replied_message_id': replyingTo?.id,
|
||||||
'forwarded_message_id': forwardingTo?.id,
|
'forwarded_message_id': forwardingTo?.id,
|
||||||
|
'poll_id': poll?.id,
|
||||||
|
'fund_id': fund?.id,
|
||||||
'meta': {},
|
'meta': {},
|
||||||
'nonce': nonce,
|
'nonce': nonce,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ part of 'messages_notifier.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
String _$messagesNotifierHash() => r'6adefd9152cdd686c2a863964993f24c42d405b5';
|
String _$messagesNotifierHash() => r'fc9c99024a0801efa4894f250aea8bdc6127a0b6';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
class _SystemHash {
|
class _SystemHash {
|
||||||
|
|||||||
104
lib/pods/file_list.dart
Normal file
104
lib/pods/file_list.dart
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/models/file_list_item.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
|
|
||||||
|
part 'file_list.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class CloudFileListNotifier extends _$CloudFileListNotifier
|
||||||
|
with CursorPagingNotifierMixin<FileListItem> {
|
||||||
|
String _currentPath = '/';
|
||||||
|
|
||||||
|
void setPath(String path) {
|
||||||
|
_currentPath = path;
|
||||||
|
ref.invalidateSelf();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<FileListItem>> fetch({
|
||||||
|
required String? cursor,
|
||||||
|
}) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
final response = await client.get(
|
||||||
|
'/drive/index/browse',
|
||||||
|
queryParameters: {'path': _currentPath},
|
||||||
|
);
|
||||||
|
|
||||||
|
final List<String> folders =
|
||||||
|
(response.data['folders'] as List).map((e) => e as String).toList();
|
||||||
|
final List<SnCloudFileIndex> files =
|
||||||
|
(response.data['files'] as List)
|
||||||
|
.map((e) => SnCloudFileIndex.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final List<FileListItem> items = [
|
||||||
|
...folders.map((folderName) => FileListItem.folder(folderName)),
|
||||||
|
...files.map((file) => FileListItem.file(file)),
|
||||||
|
];
|
||||||
|
|
||||||
|
// The new API returns all files in the path, no pagination
|
||||||
|
return CursorPagingData(items: items, hasMore: false, nextCursor: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final response = await client.get('/drive/billing/usage');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class UnindexedFileListNotifier extends _$UnindexedFileListNotifier
|
||||||
|
with CursorPagingNotifierMixin<FileListItem> {
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<FileListItem>> build() => fetch(cursor: null);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<CursorPagingData<FileListItem>> fetch({
|
||||||
|
required String? cursor,
|
||||||
|
}) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
|
||||||
|
final offset = cursor != null ? int.tryParse(cursor) ?? 0 : 0;
|
||||||
|
const take = 50; // Default page size
|
||||||
|
|
||||||
|
final response = await client.get(
|
||||||
|
'/drive/index/unindexed',
|
||||||
|
queryParameters: {'take': take.toString(), 'offset': offset.toString()},
|
||||||
|
);
|
||||||
|
|
||||||
|
final total = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;
|
||||||
|
|
||||||
|
final List<SnCloudFile> files =
|
||||||
|
(response.data as List)
|
||||||
|
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final List<FileListItem> items =
|
||||||
|
files.map((file) => FileListItem.unindexedFile(file)).toList();
|
||||||
|
|
||||||
|
final hasMore = offset + take < total;
|
||||||
|
final nextCursor = hasMore ? (offset + take).toString() : null;
|
||||||
|
|
||||||
|
return CursorPagingData(
|
||||||
|
items: items,
|
||||||
|
hasMore: hasMore,
|
||||||
|
nextCursor: nextCursor,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final response = await client.get('/drive/billing/quota');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
@@ -45,13 +45,13 @@ final billingQuotaProvider =
|
|||||||
// ignore: unused_element
|
// ignore: unused_element
|
||||||
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
|
||||||
String _$cloudFileListNotifierHash() =>
|
String _$cloudFileListNotifierHash() =>
|
||||||
r'22c45a8ea23147a3835ba870ad2f0bb833f853ea';
|
r'5f2f80357cb31ac6473df5ac2101f9a462004f81';
|
||||||
|
|
||||||
/// See also [CloudFileListNotifier].
|
/// See also [CloudFileListNotifier].
|
||||||
@ProviderFor(CloudFileListNotifier)
|
@ProviderFor(CloudFileListNotifier)
|
||||||
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||||
CloudFileListNotifier,
|
CloudFileListNotifier,
|
||||||
CursorPagingData<SnCloudFile>
|
CursorPagingData<FileListItem>
|
||||||
>.internal(
|
>.internal(
|
||||||
CloudFileListNotifier.new,
|
CloudFileListNotifier.new,
|
||||||
name: r'cloudFileListNotifierProvider',
|
name: r'cloudFileListNotifierProvider',
|
||||||
@@ -64,6 +64,27 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
|||||||
);
|
);
|
||||||
|
|
||||||
typedef _$CloudFileListNotifier =
|
typedef _$CloudFileListNotifier =
|
||||||
AutoDisposeAsyncNotifier<CursorPagingData<SnCloudFile>>;
|
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
|
||||||
|
String _$unindexedFileListNotifierHash() =>
|
||||||
|
r'48fc92432a50a562190da5fe8ed0920d171b07b6';
|
||||||
|
|
||||||
|
/// See also [UnindexedFileListNotifier].
|
||||||
|
@ProviderFor(UnindexedFileListNotifier)
|
||||||
|
final unindexedFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
|
||||||
|
UnindexedFileListNotifier,
|
||||||
|
CursorPagingData<FileListItem>
|
||||||
|
>.internal(
|
||||||
|
UnindexedFileListNotifier.new,
|
||||||
|
name: r'unindexedFileListNotifierProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$unindexedFileListNotifierHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef _$UnindexedFileListNotifier =
|
||||||
|
AutoDisposeAsyncNotifier<CursorPagingData<FileListItem>>;
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
494
lib/pods/upload_tasks.dart
Normal file
494
lib/pods/upload_tasks.dart
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:cross_file/cross_file.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/models/drive_task.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/websocket.dart';
|
||||||
|
import 'package:island/services/file_uploader.dart';
|
||||||
|
import 'package:island/talker.dart';
|
||||||
|
|
||||||
|
final uploadTasksProvider =
|
||||||
|
StateNotifierProvider<UploadTasksNotifier, List<DriveTask>>(
|
||||||
|
(ref) => UploadTasksNotifier(ref),
|
||||||
|
);
|
||||||
|
|
||||||
|
class UploadTasksNotifier extends StateNotifier<List<DriveTask>> {
|
||||||
|
final Ref ref;
|
||||||
|
StreamSubscription? _websocketSubscription;
|
||||||
|
final Map<String, Map<String, dynamic>> _pendingUploads = {};
|
||||||
|
|
||||||
|
UploadTasksNotifier(this.ref) : super([]) {
|
||||||
|
_listenToWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _listenToWebSocket() {
|
||||||
|
final WebSocketService websocketService = ref.read(websocketProvider);
|
||||||
|
_websocketSubscription = websocketService.dataStream.listen(
|
||||||
|
_handleWebSocketPacket,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleWebSocketPacket(dynamic packet) {
|
||||||
|
if (packet.type.startsWith('task.')) {
|
||||||
|
final data = packet.data;
|
||||||
|
if (data == null) return;
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
talker.info(
|
||||||
|
'[UploadTasks] Received WebSocket packet: ${packet.type}, data: $data',
|
||||||
|
);
|
||||||
|
|
||||||
|
final taskId = data['task_id'] as String?;
|
||||||
|
if (taskId == null) return;
|
||||||
|
|
||||||
|
switch (packet.type) {
|
||||||
|
case 'task.created':
|
||||||
|
_handleTaskCreated(taskId, data);
|
||||||
|
break;
|
||||||
|
case 'task.progress':
|
||||||
|
_handleProgressUpdate(taskId, data);
|
||||||
|
break;
|
||||||
|
case 'task.completed':
|
||||||
|
_handleUploadCompleted(taskId, data);
|
||||||
|
break;
|
||||||
|
case 'task.failed':
|
||||||
|
_handleUploadFailed(taskId, data);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTaskCreated(String taskId, Map<String, dynamic> data) {
|
||||||
|
talker.info('[UploadTasks] Handling task.created for taskId: $taskId');
|
||||||
|
|
||||||
|
// Check if task already exists (might have been created locally)
|
||||||
|
final existingTask =
|
||||||
|
state.where((task) => task.taskId == taskId).firstOrNull;
|
||||||
|
if (existingTask != null) {
|
||||||
|
talker.info('[UploadTasks] Task already exists, updating status');
|
||||||
|
// Task already exists, just update its status to confirm server creation
|
||||||
|
state =
|
||||||
|
state.map((task) {
|
||||||
|
if (task.taskId == taskId) {
|
||||||
|
return task.copyWith(
|
||||||
|
status: DriveTaskStatus.pending,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}).toList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we have stored metadata for this task
|
||||||
|
final metadata = _pendingUploads[taskId];
|
||||||
|
talker.info('[UploadTasks] Metadata for taskId $taskId: $metadata');
|
||||||
|
|
||||||
|
if (metadata != null) {
|
||||||
|
talker.info('[UploadTasks] Creating task with full metadata');
|
||||||
|
// Create task with full metadata
|
||||||
|
final uploadTask = DriveTask(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
taskId: taskId,
|
||||||
|
fileName: metadata['file_name'] as String,
|
||||||
|
contentType: metadata['mime_type'] as String,
|
||||||
|
fileSize: metadata['file_size'] as int,
|
||||||
|
uploadedBytes: 0,
|
||||||
|
totalChunks: metadata['total_chunks'] as int,
|
||||||
|
uploadedChunks: 0,
|
||||||
|
status: DriveTaskStatus.pending,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
type: 'FileUpload',
|
||||||
|
poolId: metadata['pool_id'] as String?,
|
||||||
|
bundleId: metadata['bundleId'] as String?,
|
||||||
|
encryptPassword: metadata['encrypt_password'] as String?,
|
||||||
|
expiredAt: metadata['expired_at'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
state = [...state, uploadTask];
|
||||||
|
talker.info(
|
||||||
|
'[UploadTasks] Task created successfully. Total tasks: ${state.length}',
|
||||||
|
);
|
||||||
|
// Clean up stored metadata
|
||||||
|
_pendingUploads.remove(taskId);
|
||||||
|
} else {
|
||||||
|
talker.info('[UploadTasks] No metadata found, creating minimal task');
|
||||||
|
// Create minimal task if no metadata is stored
|
||||||
|
final params = data['parameters'];
|
||||||
|
final uploadTask = DriveTask(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
taskId: taskId,
|
||||||
|
fileName: params['file_name'] as String? ?? 'Unknown file',
|
||||||
|
contentType: params['content_type'],
|
||||||
|
fileSize: params['file_size'],
|
||||||
|
uploadedBytes:
|
||||||
|
(params['chunk_size'] as int) * (params['chunks_uploaded'] as int),
|
||||||
|
totalChunks: params['chunks_count'],
|
||||||
|
uploadedChunks: params['chunks_uploaded'],
|
||||||
|
status: DriveTaskStatus.pending,
|
||||||
|
createdAt: DateTime.tryParse(data['created_at']) ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
type: data['type'],
|
||||||
|
);
|
||||||
|
|
||||||
|
state = [...state, uploadTask];
|
||||||
|
talker.info(
|
||||||
|
'[UploadTasks] Minimal task created. Total tasks: ${state.length}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleProgressUpdate(String taskId, Map<String, dynamic> data) {
|
||||||
|
final progress = data['progress'] as num? ?? 0.0;
|
||||||
|
|
||||||
|
state =
|
||||||
|
state.map((task) {
|
||||||
|
if (task.taskId == taskId) {
|
||||||
|
final uploadedBytes = (progress / 100.0 * task.fileSize).toInt();
|
||||||
|
return task.copyWith(
|
||||||
|
statusMessage: data['status'],
|
||||||
|
uploadedBytes: uploadedBytes,
|
||||||
|
status: DriveTaskStatus.inProgress,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleUploadCompleted(String taskId, Map<String, dynamic> data) {
|
||||||
|
final results = data['results'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
|
state =
|
||||||
|
state.map((task) {
|
||||||
|
if (task.taskId == taskId) {
|
||||||
|
return task.copyWith(
|
||||||
|
status: DriveTaskStatus.completed,
|
||||||
|
uploadedChunks: task.totalChunks,
|
||||||
|
uploadedBytes: task.fileSize,
|
||||||
|
// Update file information from Results if available
|
||||||
|
fileName: results?['file_name'] as String? ?? task.fileName,
|
||||||
|
fileSize: results?['file_size'] as int? ?? task.fileSize,
|
||||||
|
contentType: results?['mime_type'] as String? ?? task.contentType,
|
||||||
|
result:
|
||||||
|
results?['file_info'] != null
|
||||||
|
? SnCloudFile.fromJson(results!['file_info'])
|
||||||
|
: null,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleUploadFailed(String taskId, Map<String, dynamic> data) {
|
||||||
|
final errorMessage = data['error_message'] as String? ?? 'Upload failed';
|
||||||
|
|
||||||
|
state =
|
||||||
|
state.map((task) {
|
||||||
|
if (task.taskId == taskId) {
|
||||||
|
return task.copyWith(
|
||||||
|
status: DriveTaskStatus.failed,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addUploadTask(DriveTask task) {
|
||||||
|
state = [...state, task];
|
||||||
|
}
|
||||||
|
|
||||||
|
void storeUploadMetadata(
|
||||||
|
String taskId, {
|
||||||
|
required String fileName,
|
||||||
|
required String contentType,
|
||||||
|
required int fileSize,
|
||||||
|
required int totalChunks,
|
||||||
|
String? poolId,
|
||||||
|
String? bundleId,
|
||||||
|
String? encryptPassword,
|
||||||
|
String? expiredAt,
|
||||||
|
}) {
|
||||||
|
_pendingUploads[taskId] = {
|
||||||
|
'file_name': fileName,
|
||||||
|
'mime_type': contentType,
|
||||||
|
'file_size': fileSize,
|
||||||
|
'total_chunks': totalChunks,
|
||||||
|
'pool_id': poolId,
|
||||||
|
'bundleId': bundleId,
|
||||||
|
'encrypt_password': encryptPassword,
|
||||||
|
'expired_at': expiredAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTaskStatus(
|
||||||
|
String taskId,
|
||||||
|
DriveTaskStatus status, {
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
state =
|
||||||
|
state.map((task) {
|
||||||
|
if (task.taskId == taskId) {
|
||||||
|
return task.copyWith(
|
||||||
|
status: status,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateTransmissionProgress(String taskId, double progress) {
|
||||||
|
state =
|
||||||
|
state.map((task) {
|
||||||
|
if (task.taskId == taskId) {
|
||||||
|
return task.copyWith(
|
||||||
|
transmissionProgress: progress,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return task;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeTask(String taskId) {
|
||||||
|
state = state.where((task) => task.taskId != taskId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearCompletedTasks() {
|
||||||
|
state =
|
||||||
|
state
|
||||||
|
.where(
|
||||||
|
(task) =>
|
||||||
|
task.status != DriveTaskStatus.completed &&
|
||||||
|
task.status != DriveTaskStatus.failed &&
|
||||||
|
task.status != DriveTaskStatus.cancelled &&
|
||||||
|
task.status != DriveTaskStatus.expired,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
DriveTask? getTask(String taskId) {
|
||||||
|
return state.where((task) => task.taskId == taskId).firstOrNull;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DriveTask> getActiveTasks() {
|
||||||
|
return state
|
||||||
|
.where(
|
||||||
|
(task) =>
|
||||||
|
task.status == DriveTaskStatus.pending ||
|
||||||
|
task.status == DriveTaskStatus.inProgress ||
|
||||||
|
task.status == DriveTaskStatus.paused ||
|
||||||
|
task.status == DriveTaskStatus.completed,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_websocketSubscription?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider for the enhanced FileUploader that integrates with upload tasks
|
||||||
|
final enhancedFileUploaderProvider = Provider<EnhancedFileUploader>((ref) {
|
||||||
|
final dio = ref.watch(apiClientProvider);
|
||||||
|
return EnhancedFileUploader(dio, ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class EnhancedFileUploader extends FileUploader {
|
||||||
|
final Ref ref;
|
||||||
|
|
||||||
|
EnhancedFileUploader(super.client, this.ref);
|
||||||
|
|
||||||
|
/// Reads the next chunk from a stream subscription.
|
||||||
|
Future<Uint8List> _readNextChunkFromStream(
|
||||||
|
StreamSubscription<List<int>> subscription,
|
||||||
|
int size,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<Uint8List>();
|
||||||
|
final buffer = <int>[];
|
||||||
|
int remaining = size;
|
||||||
|
|
||||||
|
void onData(List<int> data) {
|
||||||
|
buffer.addAll(data);
|
||||||
|
remaining -= data.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
subscription.pause();
|
||||||
|
completer.complete(Uint8List.fromList(buffer.sublist(0, size)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDone() {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(Uint8List.fromList(buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.onData(onData);
|
||||||
|
subscription.onDone(onDone);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SnCloudFile> uploadFile({
|
||||||
|
required dynamic fileData,
|
||||||
|
required String fileName,
|
||||||
|
required String contentType,
|
||||||
|
String? poolId,
|
||||||
|
String? bundleId,
|
||||||
|
String? encryptPassword,
|
||||||
|
String? expiredAt,
|
||||||
|
int? customChunkSize,
|
||||||
|
String? path,
|
||||||
|
Function(double? progress, Duration estimate)? onProgress,
|
||||||
|
}) async {
|
||||||
|
// Step 1: Create upload task
|
||||||
|
onProgress?.call(null, Duration.zero);
|
||||||
|
final createResponse = await createUploadTask(
|
||||||
|
fileData: fileData,
|
||||||
|
fileName: fileName,
|
||||||
|
contentType: contentType,
|
||||||
|
poolId: poolId,
|
||||||
|
bundleId: bundleId,
|
||||||
|
encryptPassword: encryptPassword,
|
||||||
|
expiredAt: expiredAt,
|
||||||
|
chunkSize: customChunkSize,
|
||||||
|
path: path,
|
||||||
|
);
|
||||||
|
|
||||||
|
int totalSize;
|
||||||
|
if (fileData is XFile) {
|
||||||
|
totalSize = await fileData.length();
|
||||||
|
} else if (fileData is Uint8List) {
|
||||||
|
totalSize = fileData.length;
|
||||||
|
} else {
|
||||||
|
throw ArgumentError('Invalid fileData type');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createResponse['file_exists'] == true) {
|
||||||
|
// File already exists, create a local task to show it was found
|
||||||
|
final existingFile = SnCloudFile.fromJson(createResponse['file']);
|
||||||
|
|
||||||
|
// Create a task that shows as completed immediately
|
||||||
|
// Use a generated taskId since the server might not provide one for existing files
|
||||||
|
final taskId =
|
||||||
|
createResponse['task_id'] as String? ??
|
||||||
|
'existing-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
|
||||||
|
final uploadTask = DriveTask(
|
||||||
|
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
taskId: taskId,
|
||||||
|
fileName: fileName,
|
||||||
|
contentType: contentType,
|
||||||
|
fileSize: totalSize,
|
||||||
|
uploadedBytes: totalSize,
|
||||||
|
totalChunks: 1, // For existing files, we consider it as 1 chunk
|
||||||
|
uploadedChunks: 1,
|
||||||
|
status: DriveTaskStatus.completed,
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
type: 'FileUpload',
|
||||||
|
poolId: poolId,
|
||||||
|
bundleId: bundleId,
|
||||||
|
encryptPassword: encryptPassword,
|
||||||
|
expiredAt: expiredAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.read(uploadTasksProvider.notifier).addUploadTask(uploadTask);
|
||||||
|
|
||||||
|
return existingFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
final taskId = createResponse['task_id'] as String;
|
||||||
|
final chunkSize = createResponse['chunk_size'] as int;
|
||||||
|
final chunksCount = createResponse['chunks_count'] as int;
|
||||||
|
|
||||||
|
// Store upload metadata for when task.created event arrives
|
||||||
|
talker.info('[UploadTasks] Storing metadata for taskId: $taskId');
|
||||||
|
ref
|
||||||
|
.read(uploadTasksProvider.notifier)
|
||||||
|
.storeUploadMetadata(
|
||||||
|
taskId,
|
||||||
|
fileName: fileName,
|
||||||
|
contentType: contentType,
|
||||||
|
fileSize: totalSize,
|
||||||
|
totalChunks: chunksCount,
|
||||||
|
poolId: poolId,
|
||||||
|
bundleId: bundleId,
|
||||||
|
encryptPassword: encryptPassword,
|
||||||
|
expiredAt: expiredAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Upload chunks
|
||||||
|
int bytesUploaded = 0;
|
||||||
|
if (fileData is XFile) {
|
||||||
|
// Use stream for XFile
|
||||||
|
final subscription = fileData.openRead().listen(null);
|
||||||
|
subscription.pause();
|
||||||
|
for (int i = 0; i < chunksCount; i++) {
|
||||||
|
subscription.resume();
|
||||||
|
final chunkData = await _readNextChunkFromStream(
|
||||||
|
subscription,
|
||||||
|
chunkSize,
|
||||||
|
);
|
||||||
|
await uploadChunk(
|
||||||
|
taskId: taskId,
|
||||||
|
chunkIndex: i,
|
||||||
|
chunkData: chunkData,
|
||||||
|
onSendProgress: (sent, total) {
|
||||||
|
final overallProgress = (bytesUploaded + sent) / totalSize;
|
||||||
|
onProgress?.call(overallProgress, Duration.zero);
|
||||||
|
// Update transmission progress in UI
|
||||||
|
ref
|
||||||
|
.read(uploadTasksProvider.notifier)
|
||||||
|
.updateTransmissionProgress(taskId, overallProgress);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
bytesUploaded += chunkData.length;
|
||||||
|
}
|
||||||
|
subscription.cancel();
|
||||||
|
} else if (fileData is Uint8List) {
|
||||||
|
// Use old way for Uint8List
|
||||||
|
final chunks = <Uint8List>[];
|
||||||
|
for (int i = 0; i < fileData.length; i += chunkSize) {
|
||||||
|
final end =
|
||||||
|
i + chunkSize > fileData.length ? fileData.length : i + chunkSize;
|
||||||
|
chunks.add(Uint8List.fromList(fileData.sublist(i, end)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload each chunk
|
||||||
|
for (int i = 0; i < chunks.length; i++) {
|
||||||
|
await uploadChunk(
|
||||||
|
taskId: taskId,
|
||||||
|
chunkIndex: i,
|
||||||
|
chunkData: chunks[i],
|
||||||
|
onSendProgress: (sent, total) {
|
||||||
|
final overallProgress = (bytesUploaded + sent) / totalSize;
|
||||||
|
onProgress?.call(overallProgress, Duration.zero);
|
||||||
|
// Update transmission progress in UI
|
||||||
|
ref
|
||||||
|
.read(uploadTasksProvider.notifier)
|
||||||
|
.updateTransmissionProgress(taskId, overallProgress);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
bytesUploaded += chunks[i].length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ArgumentError('Invalid fileData type');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Complete upload
|
||||||
|
onProgress?.call(null, Duration.zero);
|
||||||
|
return await completeUpload(taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ import 'package:island/screens/developers/hub.dart';
|
|||||||
import 'package:island/screens/developers/edit_project.dart';
|
import 'package:island/screens/developers/edit_project.dart';
|
||||||
import 'package:island/screens/developers/new_project.dart';
|
import 'package:island/screens/developers/new_project.dart';
|
||||||
import 'package:island/screens/discovery/articles.dart';
|
import 'package:island/screens/discovery/articles.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/screens/files/file_list.dart';
|
import 'package:island/screens/files/file_list.dart';
|
||||||
|
import 'package:island/screens/files/file_detail.dart';
|
||||||
import 'package:island/screens/posts/post_categories_list.dart';
|
import 'package:island/screens/posts/post_categories_list.dart';
|
||||||
import 'package:island/screens/posts/post_category_detail.dart';
|
import 'package:island/screens/posts/post_category_detail.dart';
|
||||||
import 'package:island/screens/posts/post_search.dart';
|
import 'package:island/screens/posts/post_search.dart';
|
||||||
@@ -28,7 +30,6 @@ import 'package:island/screens/account/me/profile_update.dart';
|
|||||||
import 'package:island/screens/account/leveling.dart';
|
import 'package:island/screens/account/leveling.dart';
|
||||||
import 'package:island/screens/account/me/account_settings.dart';
|
import 'package:island/screens/account/me/account_settings.dart';
|
||||||
import 'package:island/screens/chat/chat.dart';
|
import 'package:island/screens/chat/chat.dart';
|
||||||
import 'package:island/screens/chat/chat_form.dart';
|
|
||||||
import 'package:island/screens/chat/room.dart';
|
import 'package:island/screens/chat/room.dart';
|
||||||
import 'package:island/screens/chat/room_detail.dart';
|
import 'package:island/screens/chat/room_detail.dart';
|
||||||
import 'package:island/screens/chat/call.dart';
|
import 'package:island/screens/chat/call.dart';
|
||||||
@@ -42,9 +43,7 @@ import 'package:island/screens/stickers/pack_detail.dart';
|
|||||||
import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
|
import 'package:island/screens/discovery/feeds/feed_marketplace.dart';
|
||||||
import 'package:island/screens/discovery/feeds/feed_detail.dart';
|
import 'package:island/screens/discovery/feeds/feed_detail.dart';
|
||||||
import 'package:island/screens/creators/poll/poll_list.dart';
|
import 'package:island/screens/creators/poll/poll_list.dart';
|
||||||
import 'package:island/screens/creators/publishers_form.dart';
|
|
||||||
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
import 'package:island/screens/creators/webfeed/webfeed_list.dart';
|
||||||
import 'package:island/screens/poll/poll_editor.dart';
|
|
||||||
import 'package:island/screens/posts/compose.dart';
|
import 'package:island/screens/posts/compose.dart';
|
||||||
import 'package:island/screens/posts/compose_article.dart';
|
import 'package:island/screens/posts/compose_article.dart';
|
||||||
import 'package:island/screens/posts/post_detail.dart';
|
import 'package:island/screens/posts/post_detail.dart';
|
||||||
@@ -126,11 +125,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return CallScreen(roomId: id);
|
return CallScreen(roomId: id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
name: 'thought',
|
|
||||||
path: '/thought',
|
|
||||||
builder: (context, state) => const ThoughtScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'logs',
|
name: 'logs',
|
||||||
path: '/logs',
|
path: '/logs',
|
||||||
@@ -269,11 +263,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/chat',
|
path: '/chat',
|
||||||
builder: (context, state) => const ChatListScreen(),
|
builder: (context, state) => const ChatListScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
name: 'chatNew',
|
|
||||||
path: '/chat/new',
|
|
||||||
builder: (context, state) => const NewChatScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'chatRoom',
|
name: 'chatRoom',
|
||||||
path: '/chat/:id',
|
path: '/chat/:id',
|
||||||
@@ -282,14 +271,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return ChatRoomScreen(id: id);
|
return ChatRoomScreen(id: id);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
name: 'chatEdit',
|
|
||||||
path: '/chat/:id/edit',
|
|
||||||
builder: (context, state) {
|
|
||||||
final id = state.pathParameters['id']!;
|
|
||||||
return EditChatScreen(id: id);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'chatDetail',
|
name: 'chatDetail',
|
||||||
path: '/chat/:id/detail',
|
path: '/chat/:id/detail',
|
||||||
@@ -396,11 +377,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/account/wallet',
|
path: '/account/wallet',
|
||||||
builder: (context, state) => const WalletScreen(),
|
builder: (context, state) => const WalletScreen(),
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
name: 'files',
|
|
||||||
path: '/account/files',
|
|
||||||
builder: (context, state) => const FileListScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'relationships',
|
name: 'relationships',
|
||||||
path: '/account/relationships',
|
path: '/account/relationships',
|
||||||
@@ -445,6 +421,38 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return AccountProfileScreen(name: name);
|
return AccountProfileScreen(name: name);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Files tab
|
||||||
|
GoRoute(
|
||||||
|
name: 'files',
|
||||||
|
path: '/files',
|
||||||
|
builder: (context, state) => const FileListScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
name: 'fileDetail',
|
||||||
|
path: ':id',
|
||||||
|
builder: (context, state) {
|
||||||
|
// For now, we'll need to pass the file object through extra
|
||||||
|
// This will be updated when we modify the file list navigation
|
||||||
|
final file = state.extra as SnCloudFile?;
|
||||||
|
if (file != null) {
|
||||||
|
return FileDetailScreen(item: file);
|
||||||
|
}
|
||||||
|
// Fallback - this shouldn't happen in normal flow
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// SN-chan tab
|
||||||
|
GoRoute(
|
||||||
|
name: 'thought',
|
||||||
|
path: '/thought',
|
||||||
|
builder: (context, state) => const ThoughtScreen(),
|
||||||
|
),
|
||||||
|
|
||||||
// Creator hub tab
|
// Creator hub tab
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'creatorHub',
|
name: 'creatorHub',
|
||||||
@@ -477,28 +485,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return CreatorPollListScreen(pubName: name);
|
return CreatorPollListScreen(pubName: name);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
// Poll routes
|
|
||||||
GoRoute(
|
|
||||||
name: 'creatorPollNew',
|
|
||||||
path: ':name/polls/new',
|
|
||||||
builder: (context, state) {
|
|
||||||
final name = state.pathParameters['name']!;
|
|
||||||
// initialPollId left null for create; initialPublisher prefilled
|
|
||||||
return PollEditorScreen(initialPublisher: name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
name: 'creatorPollEdit',
|
|
||||||
path: ':name/polls/:id/edit',
|
|
||||||
builder: (context, state) {
|
|
||||||
final name = state.pathParameters['name']!;
|
|
||||||
final id = state.pathParameters['id']!;
|
|
||||||
return PollEditorScreen(
|
|
||||||
initialPollId: id,
|
|
||||||
initialPublisher: name,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
name: 'creatorStickers',
|
name: 'creatorStickers',
|
||||||
path: ':name/stickers',
|
path: ':name/stickers',
|
||||||
@@ -507,19 +494,6 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return StickersScreen(pubName: name);
|
return StickersScreen(pubName: name);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
|
||||||
name: 'creatorNew',
|
|
||||||
path: 'new',
|
|
||||||
builder: (context, state) => const NewPublisherScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
name: 'creatorEdit',
|
|
||||||
path: ':name/edit',
|
|
||||||
builder: (context, state) {
|
|
||||||
final name = state.pathParameters['name']!;
|
|
||||||
return EditPublisherScreen(name: name);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
pathParameters: {'name': user.value!.name},
|
pathParameters: {'name': user.value!.name},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
).padding(bottom: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -375,6 +375,17 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (!isWideScreen(context))
|
||||||
|
ListTile(
|
||||||
|
minTileHeight: 48,
|
||||||
|
leading: const Icon(Symbols.files),
|
||||||
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
|
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
title: Text('files').tr(),
|
||||||
|
onTap: () {
|
||||||
|
context.goNamed('files');
|
||||||
|
},
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.wallet),
|
leading: const Icon(Symbols.wallet),
|
||||||
@@ -385,16 +396,6 @@ class AccountScreen extends HookConsumerWidget {
|
|||||||
context.pushNamed('wallet');
|
context.pushNamed('wallet');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
|
||||||
minTileHeight: 48,
|
|
||||||
leading: const Icon(Symbols.files),
|
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
|
||||||
title: Text('files').tr(),
|
|
||||||
onTap: () {
|
|
||||||
context.pushNamed('files');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ListTile(
|
ListTile(
|
||||||
minTileHeight: 48,
|
minTileHeight: 48,
|
||||||
leading: const Icon(Symbols.people),
|
leading: const Icon(Symbols.people),
|
||||||
|
|||||||
@@ -84,9 +84,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
'accountPasswordChange'.tr(),
|
'accountPasswordChange'.tr(),
|
||||||
);
|
);
|
||||||
if (!confirm || !context.mounted) return;
|
if (!confirm || !context.mounted) return;
|
||||||
final captchaTk = await Navigator.of(
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
|
|
||||||
if (captchaTk == null) return;
|
if (captchaTk == null) return;
|
||||||
try {
|
try {
|
||||||
if (context.mounted) showLoadingModal(context);
|
if (context.mounted) showLoadingModal(context);
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class UpdateProfileScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
client: ref.read(apiClientProvider),
|
ref: ref,
|
||||||
fileData: UniversalFile(
|
fileData: UniversalFile(
|
||||||
data: result,
|
data: result,
|
||||||
type: UniversalFileType.image,
|
type: UniversalFileType.image,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
|
|||||||
case 'github':
|
case 'github':
|
||||||
case 'discord':
|
case 'discord':
|
||||||
case 'afdian':
|
case 'afdian':
|
||||||
|
case 'steam':
|
||||||
return SvgPicture.asset(
|
return SvgPicture.asset(
|
||||||
'assets/images/oidc/$providerLower.svg',
|
'assets/images/oidc/$providerLower.svg',
|
||||||
width: size,
|
width: size,
|
||||||
@@ -64,6 +65,8 @@ String getLocalizedProviderName(String provider) {
|
|||||||
return 'accountConnectionProviderAfdian'.tr();
|
return 'accountConnectionProviderAfdian'.tr();
|
||||||
case 'spotify':
|
case 'spotify':
|
||||||
return 'accountConnectionProviderSpotify'.tr();
|
return 'accountConnectionProviderSpotify'.tr();
|
||||||
|
case 'steam':
|
||||||
|
return 'accountConnectionProviderSteam'.tr();
|
||||||
default:
|
default:
|
||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
@@ -164,6 +167,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
|
|||||||
'discord',
|
'discord',
|
||||||
'afdian',
|
'afdian',
|
||||||
'spotify',
|
'spotify',
|
||||||
|
'steam',
|
||||||
];
|
];
|
||||||
|
|
||||||
Future<void> addConnection() async {
|
Future<void> addConnection() async {
|
||||||
@@ -199,12 +203,7 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
|
|||||||
} finally {
|
} finally {
|
||||||
if (context.mounted) hideLoadingModal(context);
|
if (context.mounted) hideLoadingModal(context);
|
||||||
}
|
}
|
||||||
case 'microsoft':
|
default:
|
||||||
case 'google':
|
|
||||||
case 'github':
|
|
||||||
case 'discord':
|
|
||||||
case 'afdian':
|
|
||||||
case 'spotify':
|
|
||||||
final serverUrl = ref.watch(serverUrlProvider);
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
final accessToken = ref.watch(tokenProvider);
|
final accessToken = ref.watch(tokenProvider);
|
||||||
launchUrlString(
|
launchUrlString(
|
||||||
@@ -212,9 +211,6 @@ class AccountConnectionNewSheet extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (context.mounted) Navigator.pop(context, true);
|
if (context.mounted) Navigator.pop(context, true);
|
||||||
break;
|
break;
|
||||||
default:
|
|
||||||
showSnackBar('accountConnectionAddError'.tr());
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,17 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:island/screens/auth/captcha.config.dart';
|
import 'package:island/screens/auth/captcha.config.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
|
||||||
class CaptchaScreen extends ConsumerWidget {
|
class CaptchaScreen extends ConsumerWidget {
|
||||||
|
static Future<String?> show(BuildContext context) {
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const CaptchaScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const CaptchaScreen({super.key});
|
const CaptchaScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -13,9 +21,9 @@ class CaptchaScreen extends ConsumerWidget {
|
|||||||
|
|
||||||
if (!captchaUrl.hasValue) return Center(child: CircularProgressIndicator());
|
if (!captchaUrl.hasValue) return Center(child: CircularProgressIndicator());
|
||||||
|
|
||||||
return AppScaffold(
|
return SheetScaffold(
|
||||||
appBar: AppBar(title: Text("Anti-Robot")),
|
titleText: "Anti-Robot",
|
||||||
body: InAppWebView(
|
child: InAppWebView(
|
||||||
initialUrlRequest: URLRequest(
|
initialUrlRequest: URLRequest(
|
||||||
url: WebUri('${captchaUrl.value}?redirect_uri=solian://captcha'),
|
url: WebUri('${captchaUrl.value}?redirect_uri=solian://captcha'),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ import 'dart:ui_web' as ui;
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/screens/auth/captcha.config.dart';
|
import 'package:island/screens/auth/captcha.config.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class CaptchaScreen extends ConsumerStatefulWidget {
|
class CaptchaScreen extends ConsumerStatefulWidget {
|
||||||
|
static Future<String?> show(BuildContext context) {
|
||||||
|
return showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const CaptchaScreen(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const CaptchaScreen({super.key});
|
const CaptchaScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -61,9 +69,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AppScaffold(
|
return SheetScaffold(
|
||||||
appBar: AppBar(title: Text("Anti-Robot")),
|
titleText: "Anti-Robot",
|
||||||
body:
|
child:
|
||||||
_isInitialized
|
_isInitialized
|
||||||
? HtmlElementView(viewType: 'captcha-iframe')
|
? HtmlElementView(viewType: 'captcha-iframe')
|
||||||
: Center(child: CircularProgressIndicator()),
|
: Center(child: CircularProgressIndicator()),
|
||||||
|
|||||||
@@ -1,317 +1,22 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:email_validator/email_validator.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:gap/gap.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/pods/network.dart';
|
|
||||||
import 'package:island/screens/account/me/profile_update.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
import 'captcha.dart';
|
import 'create_account_content.dart';
|
||||||
|
|
||||||
class CreateAccountScreen extends HookConsumerWidget {
|
class CreateAccountScreen extends HookConsumerWidget {
|
||||||
const CreateAccountScreen({super.key});
|
const CreateAccountScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
|
||||||
|
|
||||||
final emailController = useTextEditingController();
|
|
||||||
final usernameController = useTextEditingController();
|
|
||||||
final nicknameController = useTextEditingController();
|
|
||||||
final passwordController = useTextEditingController();
|
|
||||||
|
|
||||||
void showPostCreateModal() {
|
|
||||||
showModalBottomSheet(
|
|
||||||
isScrollControlled: true,
|
|
||||||
context: context,
|
|
||||||
builder: (context) => _PostCreateModal(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void performAction() async {
|
|
||||||
if (!formKey.currentState!.validate()) return;
|
|
||||||
|
|
||||||
final captchaTk = await Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
|
|
||||||
if (captchaTk == null) return;
|
|
||||||
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
showLoadingModal(context);
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
await client.post(
|
|
||||||
'/pass/accounts',
|
|
||||||
data: {
|
|
||||||
'name': usernameController.text,
|
|
||||||
'nick': nicknameController.text,
|
|
||||||
'email': emailController.text,
|
|
||||||
'password': passwordController.text,
|
|
||||||
'language':
|
|
||||||
kServerSupportedLanguages[EasyLocalization.of(
|
|
||||||
context,
|
|
||||||
)!.currentLocale.toString()] ??
|
|
||||||
'en-us',
|
|
||||||
'captcha_token': captchaTk,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
hideLoadingModal(context);
|
|
||||||
showPostCreateModal();
|
|
||||||
} catch (err) {
|
|
||||||
if (context.mounted) hideLoadingModal(context);
|
|
||||||
showErrorAlert(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('createAccount').tr(),
|
title: Text('createAccount').tr(),
|
||||||
),
|
),
|
||||||
body:
|
body: CreateAccountContent(),
|
||||||
StyledWidget(
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 380),
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.person_add, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'createAccount',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
).tr().padding(left: 4, bottom: 16),
|
|
||||||
Form(
|
|
||||||
key: formKey,
|
|
||||||
autovalidateMode: AutovalidateMode.onUserInteraction,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: usernameController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autofillHints: const [AutofillHints.username],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'username'.tr(),
|
|
||||||
helperText: 'usernameCannotChangeHint'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
TextFormField(
|
|
||||||
controller: nicknameController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: const [AutofillHints.nickname],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'nickname'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
TextFormField(
|
|
||||||
controller: emailController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
if (!EmailValidator.validate(value)) {
|
|
||||||
return 'fieldEmailAddressMustBeValid'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autofillHints: const [AutofillHints.email],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'email'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
TextFormField(
|
|
||||||
controller: passwordController,
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'fieldCannotBeEmpty'.tr();
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
obscureText: true,
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autofillHints: const [AutofillHints.password],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'password'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside:
|
|
||||||
(_) =>
|
|
||||||
FocusManager.instance.primaryFocus
|
|
||||||
?.unfocus(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 7),
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: StyledWidget(
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 290),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'termAcceptNextWithAgree'.tr(),
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodySmall!.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurface
|
|
||||||
.withAlpha((255 * 0.75).round()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('termAcceptLink').tr(),
|
|
||||||
const Gap(4),
|
|
||||||
const Icon(Symbols.launch, size: 14),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
launchUrlString(
|
|
||||||
'https://solsynth.dev/terms',
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
performAction();
|
|
||||||
},
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text("next").tr(),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(all: 24).center(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PostCreateModal extends HookConsumerWidget {
|
|
||||||
const _PostCreateModal();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 280),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('🎉').fontSize(32),
|
|
||||||
Text(
|
|
||||||
'postCreateAccountTitle'.tr(),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
).fontSize(17),
|
|
||||||
const Gap(18),
|
|
||||||
Text('postCreateAccountNext').tr().fontSize(19).bold(),
|
|
||||||
const Gap(4),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 6,
|
|
||||||
children: [
|
|
||||||
Text('\u2022'),
|
|
||||||
Expanded(child: Text('postCreateAccountNext1').tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
spacing: 6,
|
|
||||||
children: [
|
|
||||||
Text('\u2022'),
|
|
||||||
Expanded(child: Text('postCreateAccountNext2').tr()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(6),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
context.pushReplacementNamed('login');
|
|
||||||
},
|
|
||||||
child: Text('login'.tr()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
486
lib/screens/auth/create_account_content.dart
Normal file
486
lib/screens/auth/create_account_content.dart
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:email_validator/email_validator.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/pods/websocket.dart';
|
||||||
|
import 'package:island/screens/account/me/profile_update.dart';
|
||||||
|
import 'package:island/services/event_bus.dart';
|
||||||
|
import 'package:island/services/notify.dart';
|
||||||
|
import 'package:island/services/udid.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
import 'captcha.dart';
|
||||||
|
|
||||||
|
Widget getProviderIcon(String provider, {double size = 24, Color? color}) {
|
||||||
|
final providerLower = provider.toLowerCase();
|
||||||
|
|
||||||
|
// Check if we have an SVG for this provider
|
||||||
|
switch (providerLower) {
|
||||||
|
case 'apple':
|
||||||
|
case 'microsoft':
|
||||||
|
case 'google':
|
||||||
|
case 'github':
|
||||||
|
case 'discord':
|
||||||
|
case 'afdian':
|
||||||
|
case 'steam':
|
||||||
|
return SvgPicture.asset(
|
||||||
|
'assets/images/oidc/$providerLower.svg',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
colorFilter:
|
||||||
|
color != null ? ColorFilter.mode(color, BlendMode.srcIn) : null,
|
||||||
|
);
|
||||||
|
case 'spotify':
|
||||||
|
return Image.asset(
|
||||||
|
'assets/images/oidc/spotify.png',
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
color: color,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return Icon(Symbols.link, size: size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CreateAccountContent extends HookConsumerWidget {
|
||||||
|
const CreateAccountContent({super.key});
|
||||||
|
|
||||||
|
Map<String, dynamic> decodeJwt(String token) {
|
||||||
|
final parts = token.split('.');
|
||||||
|
if (parts.length != 3) throw FormatException('Invalid JWT');
|
||||||
|
final payload = parts[1];
|
||||||
|
final normalized = base64Url.normalize(payload);
|
||||||
|
final decoded = utf8.decode(base64Url.decode(normalized));
|
||||||
|
return json.decode(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final formKey = useMemoized(GlobalKey<FormState>.new, const []);
|
||||||
|
|
||||||
|
final emailController = useTextEditingController();
|
||||||
|
final usernameController = useTextEditingController();
|
||||||
|
final nicknameController = useTextEditingController();
|
||||||
|
final passwordController = useTextEditingController();
|
||||||
|
final waitingForOidc = useState(false);
|
||||||
|
final onboardingToken = useState<String?>(null);
|
||||||
|
|
||||||
|
void showPostCreateModal() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
isScrollControlled: true,
|
||||||
|
context: context,
|
||||||
|
builder: (context) => _PostCreateModal(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void performAction() async {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
String endpoint = '/pass/accounts';
|
||||||
|
Map<String, dynamic> data = {};
|
||||||
|
|
||||||
|
if (onboardingToken.value != null) {
|
||||||
|
// OIDC onboarding
|
||||||
|
endpoint = '/pass/account/onboard';
|
||||||
|
data['onboarding_token'] = onboardingToken.value;
|
||||||
|
data['name'] = usernameController.text;
|
||||||
|
data['nick'] = nicknameController.text;
|
||||||
|
// Password is required in form, but might be optional
|
||||||
|
} else {
|
||||||
|
// Manual account creation
|
||||||
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
data['captcha_token'] = captchaTk;
|
||||||
|
data['name'] = usernameController.text;
|
||||||
|
data['nick'] = nicknameController.text;
|
||||||
|
data['email'] = emailController.text;
|
||||||
|
data['password'] = passwordController.text;
|
||||||
|
data['language'] =
|
||||||
|
kServerSupportedLanguages[EasyLocalization.of(
|
||||||
|
context,
|
||||||
|
)!.currentLocale.toString()] ??
|
||||||
|
'en-us';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showLoadingModal(context);
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final resp = await client.post(endpoint, data: data);
|
||||||
|
if (endpoint == '/pass/account/onboard') {
|
||||||
|
// Onboard response has tokens, set them
|
||||||
|
final token = resp.data['token'];
|
||||||
|
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||||
|
ref.invalidate(tokenProvider);
|
||||||
|
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||||
|
await userNotifier.fetchUser();
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
subscribePushNotification(apiClient);
|
||||||
|
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||||
|
wsNotifier.connect();
|
||||||
|
if (context.mounted) Navigator.pop(context, true);
|
||||||
|
} else {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
hideLoadingModal(context);
|
||||||
|
onboardingToken.value = null; // reset
|
||||||
|
showPostCreateModal();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
|
if (!waitingForOidc.value || !context.mounted) return;
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
// Exchange code for tokens
|
||||||
|
final resp = await client.post(
|
||||||
|
'/pass/auth/token',
|
||||||
|
data: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': event.challengeId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final data = resp.data;
|
||||||
|
if (data.containsKey('onboarding_token')) {
|
||||||
|
// New user onboarding
|
||||||
|
final token = data['onboarding_token'] as String;
|
||||||
|
final decoded = decodeJwt(token);
|
||||||
|
final name = decoded['name'] as String?;
|
||||||
|
final email = decoded['email'] as String?;
|
||||||
|
final provider = decoded['provider'] as String?;
|
||||||
|
// Pre-fill form
|
||||||
|
usernameController.text = '';
|
||||||
|
nicknameController.text = name ?? '';
|
||||||
|
emailController.text = email ?? '';
|
||||||
|
passwordController.clear(); // User needs to set password
|
||||||
|
onboardingToken.value = token;
|
||||||
|
// Optionally show a message
|
||||||
|
showSnackBar('Pre-filled from ${provider ?? 'provider'}');
|
||||||
|
} else {
|
||||||
|
// Existing user, switch to login
|
||||||
|
showSnackBar('Account already exists. Redirecting to login.');
|
||||||
|
if (context.mounted) context.goNamed('login');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return subscription.cancel;
|
||||||
|
}, [waitingForOidc.value, context.mounted]);
|
||||||
|
|
||||||
|
Future<void> withOidc(String provider) async {
|
||||||
|
waitingForOidc.value = true;
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
|
final deviceId = await getUdid();
|
||||||
|
final url =
|
||||||
|
Uri.parse('$serverUrl/pass/auth/login/${provider.toLowerCase()}')
|
||||||
|
.replace(
|
||||||
|
queryParameters: {
|
||||||
|
'returnUrl': 'solian://auth/callback',
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'flow': 'login',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toString();
|
||||||
|
final isLaunched = await launchUrlString(
|
||||||
|
url,
|
||||||
|
mode:
|
||||||
|
kIsWeb
|
||||||
|
? LaunchMode.platformDefault
|
||||||
|
: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!isLaunched) {
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
showErrorAlert('failedToLaunchBrowser'.tr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return StyledWidget(
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 380),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.person_add, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'createAccount',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
).tr().padding(left: 4, bottom: 16),
|
||||||
|
if (!kIsWeb)
|
||||||
|
Row(
|
||||||
|
spacing: 6,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
Text("orCreateWith").tr().fontSize(11).opacity(0.85),
|
||||||
|
const Gap(8),
|
||||||
|
Spacer(),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('github'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"github",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'GitHub',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('google'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"google",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Google',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('apple'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"apple",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Apple Account',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 8, vertical: 8)
|
||||||
|
else
|
||||||
|
const Gap(12),
|
||||||
|
Form(
|
||||||
|
key: formKey,
|
||||||
|
autovalidateMode: AutovalidateMode.onUserInteraction,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: usernameController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'username'.tr(),
|
||||||
|
helperText: 'usernameCannotChangeHint'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
TextFormField(
|
||||||
|
controller: nicknameController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
autocorrect: false,
|
||||||
|
autofillHints: const [AutofillHints.nickname],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'nickname'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
TextFormField(
|
||||||
|
controller: emailController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
if (!EmailValidator.validate(value)) {
|
||||||
|
return 'fieldEmailAddressMustBeValid'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.email],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'email'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
TextFormField(
|
||||||
|
controller: passwordController,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'fieldCannotBeEmpty'.tr();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
obscureText: true,
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
autofillHints: const [AutofillHints.password],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'password'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside:
|
||||||
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 7),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: StyledWidget(
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 290),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'termAcceptNextWithAgree'.tr(),
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall!.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface
|
||||||
|
.withAlpha((255 * 0.75).round()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('termAcceptLink').tr(),
|
||||||
|
const Gap(4),
|
||||||
|
const Icon(Symbols.launch, size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://solsynth.dev/terms');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(horizontal: 16),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
performAction();
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text("next").tr(),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(all: 24).center();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PostCreateModal extends HookConsumerWidget {
|
||||||
|
const _PostCreateModal();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 280),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text('🎉').fontSize(32),
|
||||||
|
Text(
|
||||||
|
'postCreateAccountTitle'.tr(),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
).fontSize(17),
|
||||||
|
const Gap(18),
|
||||||
|
Text('postCreateAccountNext').tr().fontSize(19).bold(),
|
||||||
|
const Gap(4),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Text('\u2022'),
|
||||||
|
Expanded(child: Text('postCreateAccountNext1').tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
spacing: 6,
|
||||||
|
children: [
|
||||||
|
Text('\u2022'),
|
||||||
|
Expanded(child: Text('postCreateAccountNext2').tr()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(6),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
context.pushReplacementNamed('login');
|
||||||
|
},
|
||||||
|
child: Text('login'.tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/screens/auth/create_account_modal.dart
Normal file
19
lib/screens/auth/create_account_modal.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
|
||||||
|
import 'create_account_content.dart';
|
||||||
|
|
||||||
|
class CreateAccountModal extends HookConsumerWidget {
|
||||||
|
const CreateAccountModal({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SheetScaffold(
|
||||||
|
titleText: 'createAccount'.tr(),
|
||||||
|
heightFactor: 0.9,
|
||||||
|
child: CreateAccountContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,31 +1,10 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:animations/animations.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
||||||
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:gap/gap.dart';
|
|
||||||
import 'package:island/models/auth.dart';
|
|
||||||
import 'package:island/pods/config.dart';
|
|
||||||
import 'package:island/pods/network.dart';
|
|
||||||
import 'package:island/pods/userinfo.dart';
|
|
||||||
import 'package:island/pods/websocket.dart';
|
|
||||||
import 'package:island/screens/account/me/settings_connections.dart';
|
|
||||||
import 'package:island/screens/auth/oidc.dart';
|
|
||||||
import 'package:island/services/notify.dart';
|
|
||||||
import 'package:island/services/udid.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
|
||||||
|
|
||||||
import 'captcha.dart';
|
import 'login_content.dart';
|
||||||
|
|
||||||
final Map<int, (String, String, IconData)> kFactorTypes = {
|
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||||
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||||
@@ -44,745 +23,13 @@ class LoginScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final isBusy = useState(false);
|
|
||||||
|
|
||||||
final period = useState(0);
|
|
||||||
final currentTicket = useState<SnAuthChallenge?>(null);
|
|
||||||
final factors = useState<List<SnAuthFactor>>([]);
|
|
||||||
final factorPicked = useState<SnAuthFactor?>(null);
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: const PageBackButton(),
|
leading: const PageBackButton(),
|
||||||
title: Text('login').tr(),
|
title: Text('login').tr(),
|
||||||
),
|
),
|
||||||
body: Theme(
|
body: LoginContent(),
|
||||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
if (isBusy.value)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
minHeight: 4,
|
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
trackGap: 0,
|
|
||||||
stopIndicatorRadius: 0,
|
|
||||||
)
|
|
||||||
else if (currentTicket.value != null)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
minHeight: 4,
|
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
trackGap: 0,
|
|
||||||
stopIndicatorRadius: 0,
|
|
||||||
value:
|
|
||||||
1 -
|
|
||||||
(currentTicket.value!.stepRemain /
|
|
||||||
currentTicket.value!.stepTotal),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const Gap(4),
|
|
||||||
Expanded(
|
|
||||||
child:
|
|
||||||
SingleChildScrollView(
|
|
||||||
child: PageTransitionSwitcher(
|
|
||||||
transitionBuilder: (
|
|
||||||
Widget child,
|
|
||||||
Animation<double> primaryAnimation,
|
|
||||||
Animation<double> secondaryAnimation,
|
|
||||||
) {
|
|
||||||
return SharedAxisTransition(
|
|
||||||
animation: primaryAnimation,
|
|
||||||
secondaryAnimation: secondaryAnimation,
|
|
||||||
transitionType: SharedAxisTransitionType.horizontal,
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(maxWidth: 380),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: switch (period.value % 3) {
|
|
||||||
1 => _LoginPickerScreen(
|
|
||||||
key: const ValueKey(1),
|
|
||||||
challenge: currentTicket.value,
|
|
||||||
factors: factors.value,
|
|
||||||
onChallenge:
|
|
||||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
|
||||||
onPickFactor:
|
|
||||||
(SnAuthFactor p0) => factorPicked.value = p0,
|
|
||||||
onNext: () => period.value++,
|
|
||||||
onBusy: (value) => isBusy.value = value,
|
|
||||||
),
|
|
||||||
2 => _LoginCheckScreen(
|
|
||||||
key: const ValueKey(2),
|
|
||||||
challenge: currentTicket.value,
|
|
||||||
factor: factorPicked.value,
|
|
||||||
onChallenge:
|
|
||||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
|
||||||
onNext: () => period.value = 1,
|
|
||||||
onBusy: (value) => isBusy.value = value,
|
|
||||||
),
|
|
||||||
_ => _LoginLookupScreen(
|
|
||||||
key: const ValueKey(0),
|
|
||||||
ticket: currentTicket.value,
|
|
||||||
onChallenge:
|
|
||||||
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
|
||||||
onFactor:
|
|
||||||
(List<SnAuthFactor>? p0) =>
|
|
||||||
factors.value = p0 ?? [],
|
|
||||||
onNext: () => period.value++,
|
|
||||||
onBusy: (value) => isBusy.value = value,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
).padding(all: 24),
|
|
||||||
).center(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Gap(4),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginCheckScreen extends HookConsumerWidget {
|
|
||||||
final SnAuthChallenge? challenge;
|
|
||||||
final SnAuthFactor? factor;
|
|
||||||
final Function(SnAuthChallenge?) onChallenge;
|
|
||||||
final VoidCallback onNext;
|
|
||||||
final Function(bool) onBusy;
|
|
||||||
|
|
||||||
const _LoginCheckScreen({
|
|
||||||
super.key,
|
|
||||||
required this.challenge,
|
|
||||||
required this.factor,
|
|
||||||
required this.onChallenge,
|
|
||||||
required this.onNext,
|
|
||||||
required this.onBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isBusy = useState(false);
|
|
||||||
final passwordController = useTextEditingController();
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
onBusy.call(isBusy.value);
|
|
||||||
return null;
|
|
||||||
}, [isBusy]);
|
|
||||||
|
|
||||||
Future<void> getToken({String? code}) async {
|
|
||||||
// Get token if challenge is completed
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
final tokenResp = await client.post(
|
|
||||||
'/pass/auth/token',
|
|
||||||
data: {
|
|
||||||
'grant_type': 'authorization_code',
|
|
||||||
'code': code ?? challenge!.id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final token = tokenResp.data['token'];
|
|
||||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
|
||||||
ref.invalidate(tokenProvider);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
// Do post login tasks
|
|
||||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
|
||||||
userNotifier.fetchUser().then((_) {
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
subscribePushNotification(apiClient);
|
|
||||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
|
||||||
wsNotifier.connect();
|
|
||||||
if (context.mounted) Navigator.pop(context, true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (challenge != null && challenge?.stepRemain == 0) {
|
|
||||||
Future(() {
|
|
||||||
if (isBusy.value) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
getToken().catchError((err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
isBusy.value = false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [challenge]);
|
|
||||||
|
|
||||||
if (factor == null) {
|
|
||||||
// Logging in by third parties
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.asterisk, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'loginInProgress'.tr(),
|
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
|
||||||
).padding(left: 4, bottom: 16),
|
|
||||||
const Gap(16),
|
|
||||||
CircularProgressIndicator().alignment(Alignment.centerLeft),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> performCheckTicket() async {
|
|
||||||
final pwd = passwordController.value.text;
|
|
||||||
if (pwd.isEmpty) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
try {
|
|
||||||
// Pass challenge
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
final resp = await client.patch(
|
|
||||||
'/pass/auth/challenge/${challenge!.id}',
|
|
||||||
data: {'factor_id': factor!.id, 'password': pwd},
|
|
||||||
);
|
|
||||||
final result = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(result);
|
|
||||||
if (result.stepRemain > 0) {
|
|
||||||
onNext();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await getToken(code: result.id);
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final width = math.min(380, MediaQuery.of(context).size.width);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.asterisk, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'loginEnterPassword'.tr(),
|
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
|
||||||
).padding(left: 4, bottom: 16),
|
|
||||||
if ([0].contains(factor!.type))
|
|
||||||
TextField(
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
controller: passwordController,
|
|
||||||
obscureText: true,
|
|
||||||
autofillHints: [
|
|
||||||
factor!.type == 0
|
|
||||||
? AutofillHints.password
|
|
||||||
: AutofillHints.oneTimeCode,
|
|
||||||
],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
labelText: 'password'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
|
|
||||||
).padding(horizontal: 7)
|
|
||||||
else
|
|
||||||
OtpTextField(
|
|
||||||
showCursor: false,
|
|
||||||
numberOfFields: 6,
|
|
||||||
obscureText: false,
|
|
||||||
showFieldAsBox: true,
|
|
||||||
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
|
||||||
fieldWidth: (width / 6) - 10,
|
|
||||||
onSubmit: (value) {
|
|
||||||
passwordController.text = value;
|
|
||||||
performCheckTicket();
|
|
||||||
},
|
|
||||||
textStyle: Theme.of(context).textTheme.titleLarge!,
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
ListTile(
|
|
||||||
leading: Icon(
|
|
||||||
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
|
|
||||||
),
|
|
||||||
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
|
|
||||||
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => performCheckTicket(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('next').tr(),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginPickerScreen extends HookConsumerWidget {
|
|
||||||
final SnAuthChallenge? challenge;
|
|
||||||
final List<SnAuthFactor>? factors;
|
|
||||||
final Function(SnAuthChallenge?) onChallenge;
|
|
||||||
final Function(SnAuthFactor) onPickFactor;
|
|
||||||
final VoidCallback onNext;
|
|
||||||
final Function(bool) onBusy;
|
|
||||||
|
|
||||||
const _LoginPickerScreen({
|
|
||||||
super.key,
|
|
||||||
required this.challenge,
|
|
||||||
required this.factors,
|
|
||||||
required this.onChallenge,
|
|
||||||
required this.onPickFactor,
|
|
||||||
required this.onNext,
|
|
||||||
required this.onBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isBusy = useState(false);
|
|
||||||
final factorPicked = useState<SnAuthFactor?>(null);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
onBusy.call(isBusy.value);
|
|
||||||
return null;
|
|
||||||
}, [isBusy]);
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
if (challenge != null && challenge?.stepRemain == 0) {
|
|
||||||
Future(() {
|
|
||||||
onNext();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [challenge]);
|
|
||||||
|
|
||||||
final unfocusColor = Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
|
||||||
|
|
||||||
final hintController = useTextEditingController();
|
|
||||||
|
|
||||||
void performGetFactorCode() async {
|
|
||||||
if (factorPicked.value == null) return;
|
|
||||||
|
|
||||||
isBusy.value = true;
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await client.post(
|
|
||||||
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
|
|
||||||
data:
|
|
||||||
hintController.text.isNotEmpty
|
|
||||||
? jsonEncode(hintController.text)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
if (err is DioException && err.response?.statusCode == 400) {
|
|
||||||
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
|
||||||
onNext();
|
|
||||||
if (context.mounted) {
|
|
||||||
showSnackBar(err.response!.data.toString());
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showErrorAlert(err);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
key: const ValueKey<int>(1),
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.lock, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'loginPickFactor',
|
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
|
||||||
).tr().padding(left: 4),
|
|
||||||
const Gap(8),
|
|
||||||
Card(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
||||||
child: Column(
|
|
||||||
children:
|
|
||||||
factors
|
|
||||||
?.map(
|
|
||||||
(x) => CheckboxListTile(
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
||||||
),
|
|
||||||
secondary: Icon(
|
|
||||||
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
|
|
||||||
),
|
|
||||||
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
|
|
||||||
enabled: !challenge!.blacklistFactors.contains(x.id),
|
|
||||||
value: factorPicked.value == x,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value == true) {
|
|
||||||
factorPicked.value = x;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList() ??
|
|
||||||
List.empty(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if ([1].contains(factorPicked.value?.type))
|
|
||||||
TextField(
|
|
||||||
controller: hintController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
labelText: 'authFactorHint'.tr(),
|
|
||||||
helperText: 'authFactorHintHelper'.tr(),
|
|
||||||
),
|
|
||||||
).padding(top: 12, bottom: 4, horizontal: 4),
|
|
||||||
const Gap(8),
|
|
||||||
Text(
|
|
||||||
'loginMultiFactor'.plural(challenge!.stepRemain),
|
|
||||||
style: TextStyle(color: unfocusColor, fontSize: 13),
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
const Gap(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => performGetFactorCode(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('next'.tr()),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginLookupScreen extends HookConsumerWidget {
|
|
||||||
final SnAuthChallenge? ticket;
|
|
||||||
final Function(SnAuthChallenge?) onChallenge;
|
|
||||||
final Function(List<SnAuthFactor>?) onFactor;
|
|
||||||
final VoidCallback onNext;
|
|
||||||
final Function(bool) onBusy;
|
|
||||||
|
|
||||||
const _LoginLookupScreen({
|
|
||||||
super.key,
|
|
||||||
required this.ticket,
|
|
||||||
required this.onChallenge,
|
|
||||||
required this.onFactor,
|
|
||||||
required this.onNext,
|
|
||||||
required this.onBusy,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final isBusy = useState(false);
|
|
||||||
final usernameController = useTextEditingController();
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
onBusy.call(isBusy.value);
|
|
||||||
return null;
|
|
||||||
}, [isBusy]);
|
|
||||||
|
|
||||||
Future<void> requestResetPassword() async {
|
|
||||||
final uname = usernameController.value.text;
|
|
||||||
if (uname.isEmpty) {
|
|
||||||
showErrorAlert('loginResetPasswordHint'.tr());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final captchaTk = await Navigator.of(
|
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
|
|
||||||
if (captchaTk == null) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
try {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
await client.post(
|
|
||||||
'/pass/accounts/recovery/password',
|
|
||||||
data: {'account': uname, 'captcha_token': captchaTk},
|
|
||||||
);
|
|
||||||
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> performNewTicket() async {
|
|
||||||
final uname = usernameController.value.text;
|
|
||||||
if (uname.isEmpty) return;
|
|
||||||
isBusy.value = true;
|
|
||||||
try {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
final resp = await client.post(
|
|
||||||
'/pass/auth/challenge',
|
|
||||||
data: {
|
|
||||||
'account': uname,
|
|
||||||
'device_id': await getUdid(),
|
|
||||||
'device_name': await getDeviceName(),
|
|
||||||
'platform':
|
|
||||||
kIsWeb
|
|
||||||
? 1
|
|
||||||
: switch (defaultTargetPlatform) {
|
|
||||||
TargetPlatform.iOS => 2,
|
|
||||||
TargetPlatform.android => 3,
|
|
||||||
TargetPlatform.macOS => 4,
|
|
||||||
TargetPlatform.windows => 5,
|
|
||||||
TargetPlatform.linux => 6,
|
|
||||||
_ => 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
final result = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(result);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${result.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
return;
|
|
||||||
} finally {
|
|
||||||
isBusy.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> withApple() async {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
try {
|
|
||||||
final credential = await SignInWithApple.getAppleIDCredential(
|
|
||||||
scopes: [AppleIDAuthorizationScopes.email],
|
|
||||||
webAuthenticationOptions: WebAuthenticationOptions(
|
|
||||||
clientId: 'dev.solsynth.solarpass',
|
|
||||||
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (context.mounted) showLoadingModal(context);
|
|
||||||
final resp = await client.post(
|
|
||||||
'/pass/auth/login/apple/mobile',
|
|
||||||
data: {
|
|
||||||
'identity_token': credential.identityToken!,
|
|
||||||
'authorization_code': credential.authorizationCode,
|
|
||||||
'device_id': await getUdid(),
|
|
||||||
'device_name': await getDeviceName(),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
final challenge = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(challenge);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${challenge.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
if (err is SignInWithAppleAuthorizationException) return;
|
|
||||||
showErrorAlert(err);
|
|
||||||
} finally {
|
|
||||||
if (context.mounted) hideLoadingModal(context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> withOidc(String provider) async {
|
|
||||||
final challengeId = await Navigator.of(context, rootNavigator: true).push(
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
try {
|
|
||||||
final resp = await client.get('/pass/auth/challenge/$challengeId');
|
|
||||||
final challenge = SnAuthChallenge.fromJson(resp.data);
|
|
||||||
onChallenge(challenge);
|
|
||||||
final factorResp = await client.get(
|
|
||||||
'/pass/auth/challenge/${challenge.id}/factors',
|
|
||||||
);
|
|
||||||
onFactor(
|
|
||||||
List<SnAuthFactor>.from(
|
|
||||||
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
onNext();
|
|
||||||
} catch (err) {
|
|
||||||
showErrorAlert(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: CircleAvatar(
|
|
||||||
radius: 26,
|
|
||||||
child: const Icon(Symbols.login, size: 28),
|
|
||||||
).padding(bottom: 8),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'loginGreeting',
|
|
||||||
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
|
||||||
).tr().padding(left: 4, bottom: 16),
|
|
||||||
TextField(
|
|
||||||
autocorrect: false,
|
|
||||||
enableSuggestions: false,
|
|
||||||
controller: usernameController,
|
|
||||||
autofillHints: const [AutofillHints.username],
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
border: const UnderlineInputBorder(),
|
|
||||||
labelText: 'username'.tr(),
|
|
||||||
helperText: 'usernameLookupHint'.tr(),
|
|
||||||
),
|
|
||||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
|
||||||
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
|
|
||||||
).padding(horizontal: 7),
|
|
||||||
if (!kIsWeb)
|
|
||||||
Row(
|
|
||||||
spacing: 6,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: <Widget>[
|
|
||||||
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
|
||||||
const Gap(8),
|
|
||||||
Spacer(),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: () => withOidc('github'),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: getProviderIcon(
|
|
||||||
"github",
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
tooltip: 'GitHub',
|
|
||||||
),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: () => withOidc('google'),
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: getProviderIcon(
|
|
||||||
"google",
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
tooltip: 'Google',
|
|
||||||
),
|
|
||||||
IconButton.filledTonal(
|
|
||||||
onPressed: withApple,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
icon: getProviderIcon(
|
|
||||||
"apple",
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
tooltip: 'Apple Account',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 8, vertical: 8)
|
|
||||||
else
|
|
||||||
const Gap(12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => requestResetPassword(),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
|
||||||
child: Text('forgotPassword'.tr()),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: isBusy.value ? null : () => performNewTicket(),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('next').tr(),
|
|
||||||
const Icon(Symbols.chevron_right),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Gap(12),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: StyledWidget(
|
|
||||||
Container(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 290),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'termAcceptNextWithAgree'.tr(),
|
|
||||||
textAlign: TextAlign.end,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text('termAcceptLink'.tr()),
|
|
||||||
const Gap(4),
|
|
||||||
const Icon(Symbols.launch, size: 14),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
launchUrlString('https://solsynth.dev/terms');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(horizontal: 16),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
814
lib/screens/auth/login_content.dart
Normal file
814
lib/screens/auth/login_content.dart
Normal file
@@ -0,0 +1,814 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:animations/animations.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_otp_text_field/flutter_otp_text_field.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:island/models/auth.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/pods/websocket.dart';
|
||||||
|
import 'package:island/screens/account/me/settings_connections.dart';
|
||||||
|
import 'package:island/services/event_bus.dart';
|
||||||
|
import 'package:island/services/notify.dart';
|
||||||
|
import 'package:island/services/udid.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
|
import 'captcha.dart';
|
||||||
|
|
||||||
|
final Map<int, (String, String, IconData)> kFactorTypes = {
|
||||||
|
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
|
||||||
|
1: ('authFactorEmail', 'authFactorEmailDescription', Symbols.email),
|
||||||
|
2: (
|
||||||
|
'authFactorInAppNotify',
|
||||||
|
'authFactorInAppNotifyDescription',
|
||||||
|
Symbols.notifications_active,
|
||||||
|
),
|
||||||
|
3: ('authFactorTOTP', 'authFactorTOTPDescription', Symbols.timer),
|
||||||
|
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
|
||||||
|
};
|
||||||
|
|
||||||
|
class _LoginCheckScreen extends HookConsumerWidget {
|
||||||
|
final SnAuthChallenge? challenge;
|
||||||
|
final SnAuthFactor? factor;
|
||||||
|
final Function(SnAuthChallenge?) onChallenge;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final Function(bool) onBusy;
|
||||||
|
|
||||||
|
const _LoginCheckScreen({
|
||||||
|
super.key,
|
||||||
|
required this.challenge,
|
||||||
|
required this.factor,
|
||||||
|
required this.onChallenge,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onBusy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
final passwordController = useTextEditingController();
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
onBusy.call(isBusy.value);
|
||||||
|
return null;
|
||||||
|
}, [isBusy]);
|
||||||
|
|
||||||
|
Future<void> getToken({String? code}) async {
|
||||||
|
// Get token if challenge is completed
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final tokenResp = await client.post(
|
||||||
|
'/pass/auth/token',
|
||||||
|
data: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code ?? challenge!.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final token = tokenResp.data['token'];
|
||||||
|
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||||
|
ref.invalidate(tokenProvider);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Do post login tasks
|
||||||
|
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||||
|
userNotifier.fetchUser().then((_) {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
subscribePushNotification(apiClient);
|
||||||
|
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||||
|
wsNotifier.connect();
|
||||||
|
if (context.mounted) Navigator.pop(context, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (challenge != null && challenge?.stepRemain == 0) {
|
||||||
|
Future(() {
|
||||||
|
if (isBusy.value) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
getToken().catchError((err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
isBusy.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [challenge]);
|
||||||
|
|
||||||
|
if (factor == null) {
|
||||||
|
// Logging in by third parties
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.asterisk, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginInProgress'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4, bottom: 16),
|
||||||
|
const Gap(16),
|
||||||
|
CircularProgressIndicator().alignment(Alignment.centerLeft),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performCheckTicket() async {
|
||||||
|
final pwd = passwordController.value.text;
|
||||||
|
if (pwd.isEmpty) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
try {
|
||||||
|
// Pass challenge
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final resp = await client.patch(
|
||||||
|
'/pass/auth/challenge/${challenge!.id}',
|
||||||
|
data: {'factor_id': factor!.id, 'password': pwd},
|
||||||
|
);
|
||||||
|
final result = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(result);
|
||||||
|
if (result.stepRemain > 0) {
|
||||||
|
onNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await getToken(code: result.id);
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final width = math.min(380, MediaQuery.of(context).size.width);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.asterisk, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginEnterPassword'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4, bottom: 16),
|
||||||
|
if ([0].contains(factor!.type))
|
||||||
|
TextField(
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
controller: passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
autofillHints: [
|
||||||
|
factor!.type == 0
|
||||||
|
? AutofillHints.password
|
||||||
|
: AutofillHints.oneTimeCode,
|
||||||
|
],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
labelText: 'password'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: isBusy.value ? null : (_) => performCheckTicket(),
|
||||||
|
).padding(horizontal: 7)
|
||||||
|
else
|
||||||
|
OtpTextField(
|
||||||
|
showCursor: false,
|
||||||
|
numberOfFields: 6,
|
||||||
|
obscureText: false,
|
||||||
|
showFieldAsBox: true,
|
||||||
|
focusedBorderColor: Theme.of(context).colorScheme.primary,
|
||||||
|
fieldWidth: (width / 6) - 10,
|
||||||
|
onSubmit: (value) {
|
||||||
|
passwordController.text = value;
|
||||||
|
performCheckTicket();
|
||||||
|
},
|
||||||
|
textStyle: Theme.of(context).textTheme.titleLarge!,
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
kFactorTypes[factor!.type]?.$3 ?? Symbols.question_mark,
|
||||||
|
),
|
||||||
|
title: Text(kFactorTypes[factor!.type]?.$1 ?? 'unknown').tr(),
|
||||||
|
subtitle: Text(kFactorTypes[factor!.type]?.$2 ?? 'unknown').tr(),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => performCheckTicket(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next').tr(),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LoginContent extends HookConsumerWidget {
|
||||||
|
const LoginContent({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
|
||||||
|
final period = useState(0);
|
||||||
|
final currentTicket = useState<SnAuthChallenge?>(null);
|
||||||
|
final factors = useState<List<SnAuthFactor>>([]);
|
||||||
|
final factorPicked = useState<SnAuthFactor?>(null);
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (isBusy.value)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
minHeight: 4,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
trackGap: 0,
|
||||||
|
stopIndicatorRadius: 0,
|
||||||
|
)
|
||||||
|
else if (currentTicket.value != null)
|
||||||
|
LinearProgressIndicator(
|
||||||
|
minHeight: 4,
|
||||||
|
borderRadius: BorderRadius.zero,
|
||||||
|
trackGap: 0,
|
||||||
|
stopIndicatorRadius: 0,
|
||||||
|
value:
|
||||||
|
1 -
|
||||||
|
(currentTicket.value!.stepRemain /
|
||||||
|
currentTicket.value!.stepTotal),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const Gap(4),
|
||||||
|
Expanded(
|
||||||
|
child:
|
||||||
|
SingleChildScrollView(
|
||||||
|
child: PageTransitionSwitcher(
|
||||||
|
transitionBuilder: (
|
||||||
|
Widget child,
|
||||||
|
Animation<double> primaryAnimation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
) {
|
||||||
|
return SharedAxisTransition(
|
||||||
|
animation: primaryAnimation,
|
||||||
|
secondaryAnimation: secondaryAnimation,
|
||||||
|
transitionType: SharedAxisTransitionType.horizontal,
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(maxWidth: 380),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: switch (period.value % 3) {
|
||||||
|
1 => _LoginPickerScreen(
|
||||||
|
key: const ValueKey(1),
|
||||||
|
challenge: currentTicket.value,
|
||||||
|
factors: factors.value,
|
||||||
|
onChallenge:
|
||||||
|
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||||
|
onPickFactor:
|
||||||
|
(SnAuthFactor p0) => factorPicked.value = p0,
|
||||||
|
onNext: () => period.value++,
|
||||||
|
onBusy: (value) => isBusy.value = value,
|
||||||
|
),
|
||||||
|
2 => _LoginCheckScreen(
|
||||||
|
key: const ValueKey(2),
|
||||||
|
challenge: currentTicket.value,
|
||||||
|
factor: factorPicked.value,
|
||||||
|
onChallenge:
|
||||||
|
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||||
|
onNext: () => period.value = 1,
|
||||||
|
onBusy: (value) => isBusy.value = value,
|
||||||
|
),
|
||||||
|
_ => _LoginLookupScreen(
|
||||||
|
key: const ValueKey(0),
|
||||||
|
ticket: currentTicket.value,
|
||||||
|
onChallenge:
|
||||||
|
(SnAuthChallenge? p0) => currentTicket.value = p0,
|
||||||
|
onFactor:
|
||||||
|
(List<SnAuthFactor>? p0) =>
|
||||||
|
factors.value = p0 ?? [],
|
||||||
|
onNext: () => period.value++,
|
||||||
|
onBusy: (value) => isBusy.value = value,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
).padding(all: 24),
|
||||||
|
).center(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Gap(4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginPickerScreen extends HookConsumerWidget {
|
||||||
|
final SnAuthChallenge? challenge;
|
||||||
|
final List<SnAuthFactor>? factors;
|
||||||
|
final Function(SnAuthChallenge?) onChallenge;
|
||||||
|
final Function(SnAuthFactor) onPickFactor;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final Function(bool) onBusy;
|
||||||
|
|
||||||
|
const _LoginPickerScreen({
|
||||||
|
super.key,
|
||||||
|
required this.challenge,
|
||||||
|
required this.factors,
|
||||||
|
required this.onChallenge,
|
||||||
|
required this.onPickFactor,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onBusy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
final factorPicked = useState<SnAuthFactor?>(null);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
onBusy.call(isBusy.value);
|
||||||
|
return null;
|
||||||
|
}, [isBusy]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (challenge != null && challenge?.stepRemain == 0) {
|
||||||
|
Future(() {
|
||||||
|
onNext();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [challenge]);
|
||||||
|
|
||||||
|
final unfocusColor = Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||||
|
|
||||||
|
final hintController = useTextEditingController();
|
||||||
|
|
||||||
|
void performGetFactorCode() async {
|
||||||
|
if (factorPicked.value == null) return;
|
||||||
|
|
||||||
|
isBusy.value = true;
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.post(
|
||||||
|
'/pass/auth/challenge/${challenge!.id}/factors/${factorPicked.value!.id}',
|
||||||
|
data:
|
||||||
|
hintController.text.isNotEmpty
|
||||||
|
? jsonEncode(hintController.text)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err is DioException && err.response?.statusCode == 400) {
|
||||||
|
onPickFactor(factors!.where((x) => x == factorPicked.value).first);
|
||||||
|
onNext();
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackBar(err.response!.data.toString());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showErrorAlert(err);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
key: const ValueKey<int>(1),
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.lock, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginPickFactor'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4),
|
||||||
|
const Gap(8),
|
||||||
|
Card(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Column(
|
||||||
|
children:
|
||||||
|
factors
|
||||||
|
?.map(
|
||||||
|
(x) => CheckboxListTile(
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
||||||
|
),
|
||||||
|
secondary: Icon(
|
||||||
|
kFactorTypes[x.type]?.$3 ?? Symbols.question_mark,
|
||||||
|
),
|
||||||
|
title: Text(kFactorTypes[x.type]?.$1 ?? 'unknown').tr(),
|
||||||
|
enabled: !challenge!.blacklistFactors.contains(x.id),
|
||||||
|
value: factorPicked.value == x,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value == true) {
|
||||||
|
factorPicked.value = x;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
List.empty(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if ([1].contains(factorPicked.value?.type))
|
||||||
|
TextField(
|
||||||
|
controller: hintController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
labelText: 'authFactorHint'.tr(),
|
||||||
|
helperText: 'authFactorHintHelper'.tr(),
|
||||||
|
),
|
||||||
|
).padding(top: 12, bottom: 4, horizontal: 4),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'loginMultiFactor'.plural(challenge!.stepRemain),
|
||||||
|
style: TextStyle(color: unfocusColor, fontSize: 13),
|
||||||
|
).padding(horizontal: 16),
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => performGetFactorCode(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next'.tr()),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoginLookupScreen extends HookConsumerWidget {
|
||||||
|
final SnAuthChallenge? ticket;
|
||||||
|
final Function(SnAuthChallenge?) onChallenge;
|
||||||
|
final Function(List<SnAuthFactor>?) onFactor;
|
||||||
|
final VoidCallback onNext;
|
||||||
|
final Function(bool) onBusy;
|
||||||
|
|
||||||
|
const _LoginLookupScreen({
|
||||||
|
super.key,
|
||||||
|
required this.ticket,
|
||||||
|
required this.onChallenge,
|
||||||
|
required this.onFactor,
|
||||||
|
required this.onNext,
|
||||||
|
required this.onBusy,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = useState(false);
|
||||||
|
final usernameController = useTextEditingController();
|
||||||
|
final waitingForOidc = useState(false);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
onBusy.call(isBusy.value);
|
||||||
|
return null;
|
||||||
|
}, [isBusy]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
final subscription = eventBus.on<OidcAuthCallbackEvent>().listen((
|
||||||
|
event,
|
||||||
|
) async {
|
||||||
|
if (!waitingForOidc.value || !context.mounted) return;
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final resp = await client.get(
|
||||||
|
'/pass/auth/challenge/${event.challengeId}',
|
||||||
|
);
|
||||||
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(challenge);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${challenge.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return subscription.cancel;
|
||||||
|
}, [waitingForOidc.value, context.mounted]);
|
||||||
|
|
||||||
|
Future<void> requestResetPassword() async {
|
||||||
|
final uname = usernameController.value.text;
|
||||||
|
if (uname.isEmpty) {
|
||||||
|
showErrorAlert('loginResetPasswordHint'.tr());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
|
if (captchaTk == null) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
try {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
await client.post(
|
||||||
|
'/pass/accounts/recovery/password',
|
||||||
|
data: {'account': uname, 'captcha_token': captchaTk},
|
||||||
|
);
|
||||||
|
showInfoAlert('loginResetPasswordSent'.tr(), 'done'.tr());
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performNewTicket() async {
|
||||||
|
final uname = usernameController.value.text;
|
||||||
|
if (uname.isEmpty) return;
|
||||||
|
isBusy.value = true;
|
||||||
|
try {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final resp = await client.post(
|
||||||
|
'/pass/auth/challenge',
|
||||||
|
data: {
|
||||||
|
'account': uname,
|
||||||
|
'device_id': await getUdid(),
|
||||||
|
'device_name': await getDeviceName(),
|
||||||
|
'platform':
|
||||||
|
kIsWeb
|
||||||
|
? 1
|
||||||
|
: switch (defaultTargetPlatform) {
|
||||||
|
TargetPlatform.iOS => 2,
|
||||||
|
TargetPlatform.android => 3,
|
||||||
|
TargetPlatform.macOS => 4,
|
||||||
|
TargetPlatform.windows => 5,
|
||||||
|
TargetPlatform.linux => 6,
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final result = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(result);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${result.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
isBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> withApple() async {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final credential = await SignInWithApple.getAppleIDCredential(
|
||||||
|
scopes: [AppleIDAuthorizationScopes.email],
|
||||||
|
webAuthenticationOptions: WebAuthenticationOptions(
|
||||||
|
clientId: 'dev.solsynth.solarpass',
|
||||||
|
redirectUri: Uri.parse('https://nt.solian.app/auth/callback/apple'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) showLoadingModal(context);
|
||||||
|
final resp = await client.post(
|
||||||
|
'/pass/auth/login/apple/mobile',
|
||||||
|
data: {
|
||||||
|
'identity_token': credential.identityToken!,
|
||||||
|
'authorization_code': credential.authorizationCode,
|
||||||
|
'device_id': await getUdid(),
|
||||||
|
'device_name': await getDeviceName(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(challenge);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/pass/auth/challenge/${challenge.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
if (err is SignInWithAppleAuthorizationException) return;
|
||||||
|
showErrorAlert(err);
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> withOidc(String provider) async {
|
||||||
|
waitingForOidc.value = true;
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
|
final token = ref.watch(tokenProvider);
|
||||||
|
final deviceId = await getUdid();
|
||||||
|
final queryParams = <String, String>{
|
||||||
|
'returnUrl': 'solian://auth/callback',
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'flow': 'login',
|
||||||
|
};
|
||||||
|
if (token?.token != null) {
|
||||||
|
queryParams['token'] = token!.token;
|
||||||
|
}
|
||||||
|
final url =
|
||||||
|
Uri.parse(
|
||||||
|
'$serverUrl/pass/auth/login/${provider.toLowerCase()}',
|
||||||
|
).replace(queryParameters: queryParams).toString();
|
||||||
|
final isLaunched = await launchUrlString(
|
||||||
|
url,
|
||||||
|
mode:
|
||||||
|
kIsWeb
|
||||||
|
? LaunchMode.platformDefault
|
||||||
|
: LaunchMode.externalApplication,
|
||||||
|
webOnlyWindowName:
|
||||||
|
token?.token != null ? 'auth-${token!.token}' : 'auth',
|
||||||
|
);
|
||||||
|
if (!isLaunched) {
|
||||||
|
waitingForOidc.value = false;
|
||||||
|
showErrorAlert('failedToLaunchBrowser'.tr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: CircleAvatar(
|
||||||
|
radius: 26,
|
||||||
|
child: const Icon(Symbols.login, size: 28),
|
||||||
|
).padding(bottom: 8),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'loginGreeting'.tr(),
|
||||||
|
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.w900),
|
||||||
|
).padding(left: 4, bottom: 16),
|
||||||
|
TextField(
|
||||||
|
autocorrect: false,
|
||||||
|
enableSuggestions: false,
|
||||||
|
controller: usernameController,
|
||||||
|
autofillHints: const [AutofillHints.username],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
border: const UnderlineInputBorder(),
|
||||||
|
labelText: 'username'.tr(),
|
||||||
|
helperText: 'usernameLookupHint'.tr(),
|
||||||
|
),
|
||||||
|
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
onSubmitted: isBusy.value ? null : (_) => performNewTicket(),
|
||||||
|
).padding(horizontal: 7),
|
||||||
|
if (!kIsWeb)
|
||||||
|
Row(
|
||||||
|
spacing: 6,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
||||||
|
const Gap(8),
|
||||||
|
Spacer(),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('github'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"github",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'GitHub',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('google'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"google",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Google',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: withApple,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"apple",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Apple Account',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 8, vertical: 8)
|
||||||
|
else
|
||||||
|
const Gap(12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => requestResetPassword(),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.grey),
|
||||||
|
child: Text('forgotPassword'.tr()),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: isBusy.value ? null : () => performNewTicket(),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('next').tr(),
|
||||||
|
const Icon(Symbols.chevron_right),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: StyledWidget(
|
||||||
|
Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 290),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'termAcceptNextWithAgree'.tr(),
|
||||||
|
textAlign: TextAlign.end,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall!.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withAlpha((255 * 0.75).round()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text('termAcceptLink'.tr()),
|
||||||
|
const Gap(4),
|
||||||
|
const Icon(Symbols.launch, size: 14),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
launchUrlString('https://solsynth.dev/terms');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(horizontal: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/screens/auth/login_modal.dart
Normal file
19
lib/screens/auth/login_modal.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
|
||||||
|
import 'login_content.dart';
|
||||||
|
|
||||||
|
class LoginModal extends HookConsumerWidget {
|
||||||
|
const LoginModal({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return SheetScaffold(
|
||||||
|
titleText: 'login'.tr(),
|
||||||
|
heightFactor: 0.9,
|
||||||
|
child: LoginContent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,10 +16,10 @@ import 'package:island/screens/realm/realms.dart';
|
|||||||
import 'package:island/services/file.dart';
|
import 'package:island/services/file.dart';
|
||||||
import 'package:island/services/file_uploader.dart';
|
import 'package:island/services/file_uploader.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
|
||||||
class NewChatScreen extends StatelessWidget {
|
class NewChatScreen extends StatelessWidget {
|
||||||
const NewChatScreen({super.key});
|
const NewChatScreen({super.key});
|
||||||
@@ -99,7 +99,7 @@ class EditChatScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
client: ref.read(apiClientProvider),
|
ref: ref,
|
||||||
fileData: UniversalFile(
|
fileData: UniversalFile(
|
||||||
data: result,
|
data: result,
|
||||||
type: UniversalFileType.image,
|
type: UniversalFileType.image,
|
||||||
@@ -151,12 +151,10 @@ class EditChatScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AppScaffold(
|
return SheetScaffold(
|
||||||
appBar: AppBar(
|
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
|
||||||
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
|
onClose: () => context.pop(),
|
||||||
leading: const PageBackButton(),
|
child: SingleChildScrollView(
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
@@ -204,16 +202,24 @@ class EditChatScreen extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(labelText: 'Name'),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Name',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
onTapOutside:
|
onTapOutside:
|
||||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: descriptionController,
|
controller: descriptionController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Description',
|
labelText: 'Description',
|
||||||
alignLabelWithHint: true,
|
alignLabelWithHint: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
maxLines: null,
|
maxLines: null,
|
||||||
@@ -223,7 +229,12 @@ class EditChatScreen extends HookConsumerWidget {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
DropdownButtonFormField<SnRealm>(
|
DropdownButtonFormField<SnRealm>(
|
||||||
value: currentRealm.value,
|
value: currentRealm.value,
|
||||||
decoration: InputDecoration(labelText: 'realm'.tr()),
|
decoration: InputDecoration(
|
||||||
|
labelText: 'realm'.tr(),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
items: [
|
items: [
|
||||||
DropdownMenuItem<SnRealm>(
|
DropdownMenuItem<SnRealm>(
|
||||||
value: null,
|
value: null,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "dart:async";
|
|||||||
import "dart:math" as math;
|
import "dart:math" as math;
|
||||||
import "package:easy_localization/easy_localization.dart";
|
import "package:easy_localization/easy_localization.dart";
|
||||||
import "package:file_picker/file_picker.dart";
|
import "package:file_picker/file_picker.dart";
|
||||||
|
import "package:image_picker/image_picker.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:go_router/go_router.dart";
|
import "package:go_router/go_router.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
@@ -10,6 +11,8 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
|
|||||||
import "package:island/database/message.dart";
|
import "package:island/database/message.dart";
|
||||||
import "package:island/models/chat.dart";
|
import "package:island/models/chat.dart";
|
||||||
import "package:island/models/file.dart";
|
import "package:island/models/file.dart";
|
||||||
|
import "package:island/models/poll.dart";
|
||||||
|
import "package:island/models/wallet.dart";
|
||||||
import "package:island/pods/chat/chat_rooms.dart";
|
import "package:island/pods/chat/chat_rooms.dart";
|
||||||
import "package:island/pods/chat/chat_subscribe.dart";
|
import "package:island/pods/chat/chat_subscribe.dart";
|
||||||
import "package:island/pods/chat/messages_notifier.dart";
|
import "package:island/pods/chat/messages_notifier.dart";
|
||||||
@@ -141,14 +144,38 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
final messageController = useTextEditingController();
|
final messageController = useTextEditingController();
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
// Input height measurement for dynamic padding
|
||||||
|
final inputKey = useMemoized(() => GlobalKey());
|
||||||
|
final inputHeight = useState<double>(80.0);
|
||||||
|
|
||||||
|
// Track previous height for smooth animations
|
||||||
|
final previousInputHeight = usePrevious<double>(inputHeight.value);
|
||||||
|
|
||||||
|
// Periodic height measurement for dynamic sizing
|
||||||
|
useEffect(() {
|
||||||
|
final timer = Timer.periodic(const Duration(milliseconds: 50), (_) {
|
||||||
|
final renderBox =
|
||||||
|
inputKey.currentContext?.findRenderObject() as RenderBox?;
|
||||||
|
if (renderBox != null) {
|
||||||
|
final newHeight = renderBox.size.height;
|
||||||
|
if (newHeight != inputHeight.value) {
|
||||||
|
inputHeight.value = newHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return timer.cancel;
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Scroll animation notifiers
|
// Scroll animation notifiers
|
||||||
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
||||||
|
|
||||||
final messageReplyingTo = useState<SnChatMessage?>(null);
|
final messageReplyingTo = useState<SnChatMessage?>(null);
|
||||||
final messageForwardingTo = useState<SnChatMessage?>(null);
|
final messageForwardingTo = useState<SnChatMessage?>(null);
|
||||||
final messageEditingTo = useState<SnChatMessage?>(null);
|
final messageEditingTo = useState<SnChatMessage?>(null);
|
||||||
|
final selectedPoll = useState<SnPoll?>(null);
|
||||||
|
final selectedFund = useState<SnWalletFund?>(null);
|
||||||
final attachments = useState<List<UniversalFile>>([]);
|
final attachments = useState<List<UniversalFile>>([]);
|
||||||
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
|
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
|
||||||
|
|
||||||
// Selection mode state
|
// Selection mode state
|
||||||
final isSelectionMode = useState<bool>(false);
|
final isSelectionMode = useState<bool>(false);
|
||||||
@@ -181,16 +208,13 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
}, [scrollController]);
|
}, [scrollController]);
|
||||||
|
|
||||||
Future<void> pickPhotoMedia() async {
|
Future<void> pickPhotoMedia() async {
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final ImagePicker picker = ImagePicker();
|
||||||
type: FileType.image,
|
final List<XFile> results = await picker.pickMultiImage();
|
||||||
allowMultiple: true,
|
if (results.isEmpty) return;
|
||||||
allowCompression: false,
|
|
||||||
);
|
|
||||||
if (result == null || result.count == 0) return;
|
|
||||||
attachments.value = [
|
attachments.value = [
|
||||||
...attachments.value,
|
...attachments.value,
|
||||||
...result.files.map(
|
...results.map(
|
||||||
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
|
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -265,10 +289,15 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
void sendMessage() {
|
void sendMessage() {
|
||||||
if (messageController.text.trim().isNotEmpty ||
|
if (messageController.text.trim().isNotEmpty ||
|
||||||
attachments.value.isNotEmpty) {
|
attachments.value.isNotEmpty ||
|
||||||
|
selectedPoll.value != null ||
|
||||||
|
selectedFund.value != null) {
|
||||||
messagesNotifier.sendMessage(
|
messagesNotifier.sendMessage(
|
||||||
|
ref,
|
||||||
messageController.text.trim(),
|
messageController.text.trim(),
|
||||||
attachments.value,
|
attachments.value,
|
||||||
|
poll: selectedPoll.value,
|
||||||
|
fund: selectedFund.value,
|
||||||
editingTo: messageEditingTo.value,
|
editingTo: messageEditingTo.value,
|
||||||
forwardingTo: messageForwardingTo.value,
|
forwardingTo: messageForwardingTo.value,
|
||||||
replyingTo: messageReplyingTo.value,
|
replyingTo: messageReplyingTo.value,
|
||||||
@@ -283,6 +312,8 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
messageEditingTo.value = null;
|
messageEditingTo.value = null;
|
||||||
messageReplyingTo.value = null;
|
messageReplyingTo.value = null;
|
||||||
messageForwardingTo.value = null;
|
messageForwardingTo.value = null;
|
||||||
|
selectedPoll.value = null;
|
||||||
|
selectedFund.value = null;
|
||||||
attachments.value = [];
|
attachments.value = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,7 +594,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
client: ref.read(apiClientProvider),
|
ref: ref,
|
||||||
fileData: attachment,
|
fileData: attachment,
|
||||||
poolId: config.poolId,
|
poolId: config.poolId,
|
||||||
mode:
|
mode:
|
||||||
@@ -573,7 +604,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
onProgress: (progress, _) {
|
onProgress: (progress, _) {
|
||||||
attachmentProgress.value = {
|
attachmentProgress.value = {
|
||||||
...attachmentProgress.value,
|
...attachmentProgress.value,
|
||||||
'chat-upload': {index: progress},
|
'chat-upload': {index: progress ?? 0.0},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
).future;
|
).future;
|
||||||
@@ -593,183 +624,428 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget chatMessageListWidget(
|
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
|
||||||
List<LocalChatMessage> messageList,
|
previousInputHeight != null && previousInputHeight != inputHeight.value
|
||||||
) => SuperListView.builder(
|
? TweenAnimationBuilder<double>(
|
||||||
listController: listController,
|
tween: Tween<double>(
|
||||||
padding: EdgeInsets.only(
|
begin: previousInputHeight,
|
||||||
top: 16,
|
end: inputHeight.value,
|
||||||
bottom:
|
),
|
||||||
MediaQuery.of(context).padding.bottom +
|
duration: const Duration(milliseconds: 200),
|
||||||
80, // Leave space for chat input
|
curve: Curves.easeOut,
|
||||||
),
|
builder:
|
||||||
controller: scrollController,
|
(context, height, child) => SuperListView.builder(
|
||||||
reverse: true, // Show newest messages at the bottom
|
listController: listController,
|
||||||
itemCount: messageList.length,
|
padding: EdgeInsets.only(
|
||||||
findChildIndexCallback: (key) {
|
top: 16,
|
||||||
if (key is! ValueKey<String>) return null;
|
bottom:
|
||||||
final messageId = key.value.substring(messageKeyPrefix.length);
|
MediaQuery.of(context).padding.bottom + 8 + height,
|
||||||
final index = messageList.indexWhere(
|
),
|
||||||
(m) => (m.nonce ?? m.id) == messageId,
|
controller: scrollController,
|
||||||
);
|
reverse: true, // Show newest messages at the bottom
|
||||||
// Return null for invalid indices to let SuperListView handle it properly
|
itemCount: messageList.length,
|
||||||
return index >= 0 ? index : null;
|
findChildIndexCallback: (key) {
|
||||||
},
|
if (key is! ValueKey<String>) return null;
|
||||||
extentEstimation: (_, _) => 40,
|
final messageId = key.value.substring(
|
||||||
itemBuilder: (context, index) {
|
messageKeyPrefix.length,
|
||||||
final message = messageList[index];
|
);
|
||||||
final nextMessage =
|
final index = messageList.indexWhere(
|
||||||
index < messageList.length - 1 ? messageList[index + 1] : null;
|
(m) => (m.nonce ?? m.id) == messageId,
|
||||||
final isLastInGroup =
|
);
|
||||||
nextMessage == null ||
|
// Return null for invalid indices to let SuperListView handle it properly
|
||||||
nextMessage.senderId != message.senderId ||
|
return index >= 0 ? index : null;
|
||||||
nextMessage.createdAt
|
},
|
||||||
.difference(message.createdAt)
|
extentEstimation: (_, _) => 40,
|
||||||
.inMinutes
|
itemBuilder: (context, index) {
|
||||||
.abs() >
|
final message = messageList[index];
|
||||||
3;
|
final nextMessage =
|
||||||
|
index < messageList.length - 1
|
||||||
|
? messageList[index + 1]
|
||||||
|
: null;
|
||||||
|
final isLastInGroup =
|
||||||
|
nextMessage == null ||
|
||||||
|
nextMessage.senderId != message.senderId ||
|
||||||
|
nextMessage.createdAt
|
||||||
|
.difference(message.createdAt)
|
||||||
|
.inMinutes
|
||||||
|
.abs() >
|
||||||
|
3;
|
||||||
|
|
||||||
// Use a stable animation key that doesn't change during message lifecycle
|
// Use a stable animation key that doesn't change during message lifecycle
|
||||||
final key = Key('$messageKeyPrefix${message.nonce ?? message.id}');
|
final key = Key(
|
||||||
|
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||||
|
);
|
||||||
|
|
||||||
final messageWidget = chatIdentity.when(
|
final messageWidget = chatIdentity.when(
|
||||||
skipError: true,
|
skipError: true,
|
||||||
data:
|
data:
|
||||||
(identity) => GestureDetector(
|
(identity) => GestureDetector(
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
if (!isSelectionMode.value) {
|
if (!isSelectionMode.value) {
|
||||||
toggleSelectionMode();
|
toggleSelectionMode();
|
||||||
toggleMessageSelection(message.id);
|
toggleMessageSelection(message.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (isSelectionMode.value) {
|
if (isSelectionMode.value) {
|
||||||
toggleMessageSelection(message.id);
|
toggleMessageSelection(message.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
color:
|
color:
|
||||||
selectedMessages.value.contains(message.id)
|
selectedMessages.value.contains(message.id)
|
||||||
? Theme.of(
|
? Theme.of(context)
|
||||||
context,
|
.colorScheme
|
||||||
).colorScheme.primaryContainer.withOpacity(0.3)
|
.primaryContainer
|
||||||
: null,
|
.withOpacity(0.3)
|
||||||
child: Stack(
|
: null,
|
||||||
children: [
|
child: Stack(
|
||||||
MessageItem(
|
children: [
|
||||||
key: settings.disableAnimation ? key : null,
|
MessageItem(
|
||||||
message: message,
|
key:
|
||||||
isCurrentUser: identity?.id == message.senderId,
|
settings.disableAnimation
|
||||||
onAction:
|
? key
|
||||||
isSelectionMode.value
|
: null,
|
||||||
? null
|
message: message,
|
||||||
: (action) {
|
isCurrentUser:
|
||||||
switch (action) {
|
identity?.id == message.senderId,
|
||||||
case MessageItemAction.delete:
|
onAction:
|
||||||
messagesNotifier.deleteMessage(
|
isSelectionMode.value
|
||||||
message.id,
|
? null
|
||||||
);
|
: (action) {
|
||||||
case MessageItemAction.edit:
|
switch (action) {
|
||||||
messageEditingTo.value =
|
case MessageItemAction.delete:
|
||||||
message.toRemoteMessage();
|
messagesNotifier
|
||||||
messageController.text =
|
.deleteMessage(
|
||||||
messageEditingTo.value?.content ?? '';
|
message.id,
|
||||||
attachments.value =
|
);
|
||||||
messageEditingTo.value!.attachments
|
case MessageItemAction.edit:
|
||||||
.map(
|
messageEditingTo.value =
|
||||||
(e) =>
|
message
|
||||||
UniversalFile.fromAttachment(
|
.toRemoteMessage();
|
||||||
e,
|
messageController.text =
|
||||||
),
|
messageEditingTo
|
||||||
)
|
.value
|
||||||
.toList();
|
?.content ??
|
||||||
case MessageItemAction.forward:
|
'';
|
||||||
messageForwardingTo.value =
|
attachments.value =
|
||||||
message.toRemoteMessage();
|
messageEditingTo
|
||||||
case MessageItemAction.reply:
|
.value!
|
||||||
messageReplyingTo.value =
|
.attachments
|
||||||
message.toRemoteMessage();
|
.map(
|
||||||
case MessageItemAction.resend:
|
(e) =>
|
||||||
messagesNotifier.retryMessage(message.id);
|
UniversalFile.fromAttachment(
|
||||||
}
|
e,
|
||||||
},
|
),
|
||||||
onJump: (messageId) {
|
)
|
||||||
scrollToMessage(
|
.toList();
|
||||||
messageId: messageId,
|
case MessageItemAction
|
||||||
messageList: messageList,
|
.forward:
|
||||||
messagesNotifier: messagesNotifier,
|
messageForwardingTo.value =
|
||||||
listController: listController,
|
message
|
||||||
scrollController: scrollController,
|
.toRemoteMessage();
|
||||||
ref: ref,
|
case MessageItemAction.reply:
|
||||||
|
messageReplyingTo.value =
|
||||||
|
message
|
||||||
|
.toRemoteMessage();
|
||||||
|
case MessageItemAction.resend:
|
||||||
|
messagesNotifier
|
||||||
|
.retryMessage(
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onJump: (messageId) {
|
||||||
|
scrollToMessage(
|
||||||
|
messageId: messageId,
|
||||||
|
messageList: messageList,
|
||||||
|
messagesNotifier: messagesNotifier,
|
||||||
|
listController: listController,
|
||||||
|
scrollController: scrollController,
|
||||||
|
ref: ref,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
progress:
|
||||||
|
attachmentProgress.value[message.id],
|
||||||
|
showAvatar: isLastInGroup,
|
||||||
|
isSelectionMode: isSelectionMode.value,
|
||||||
|
isSelected: selectedMessages.value
|
||||||
|
.contains(message.id),
|
||||||
|
onToggleSelection: toggleMessageSelection,
|
||||||
|
onEnterSelectionMode: () {
|
||||||
|
if (!isSelectionMode.value) {
|
||||||
|
toggleSelectionMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (selectedMessages.value.contains(
|
||||||
|
message.id,
|
||||||
|
))
|
||||||
|
...([
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 12,
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
loading:
|
||||||
|
() => MessageItem(
|
||||||
|
message: message,
|
||||||
|
isCurrentUser: false,
|
||||||
|
onAction: null,
|
||||||
|
progress: null,
|
||||||
|
showAvatar: false,
|
||||||
|
onJump: (_) {},
|
||||||
|
),
|
||||||
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return settings.disableAnimation
|
||||||
|
? messageWidget
|
||||||
|
: TweenAnimationBuilder<double>(
|
||||||
|
key: key,
|
||||||
|
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||||
|
duration: Duration(
|
||||||
|
milliseconds: 400 + (index % 5) * 50,
|
||||||
|
), // Staggered delay
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (context, animationValue, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(
|
||||||
|
0,
|
||||||
|
20 * (1 - animationValue),
|
||||||
|
), // Slide up from bottom
|
||||||
|
child: Opacity(
|
||||||
|
opacity: animationValue,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: messageWidget,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
progress: attachmentProgress.value[message.id],
|
),
|
||||||
showAvatar: isLastInGroup,
|
)
|
||||||
isSelectionMode: isSelectionMode.value,
|
: SuperListView.builder(
|
||||||
isSelected: selectedMessages.value.contains(message.id),
|
listController: listController,
|
||||||
onToggleSelection: toggleMessageSelection,
|
padding: EdgeInsets.only(
|
||||||
onEnterSelectionMode: () {
|
top: 16,
|
||||||
|
bottom:
|
||||||
|
MediaQuery.of(context).padding.bottom +
|
||||||
|
8 +
|
||||||
|
inputHeight.value,
|
||||||
|
),
|
||||||
|
controller: scrollController,
|
||||||
|
reverse: true, // Show newest messages at the bottom
|
||||||
|
itemCount: messageList.length,
|
||||||
|
findChildIndexCallback: (key) {
|
||||||
|
if (key is! ValueKey<String>) return null;
|
||||||
|
final messageId = key.value.substring(messageKeyPrefix.length);
|
||||||
|
final index = messageList.indexWhere(
|
||||||
|
(m) => (m.nonce ?? m.id) == messageId,
|
||||||
|
);
|
||||||
|
// Return null for invalid indices to let SuperListView handle it properly
|
||||||
|
return index >= 0 ? index : null;
|
||||||
|
},
|
||||||
|
extentEstimation: (_, _) => 40,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final message = messageList[index];
|
||||||
|
final nextMessage =
|
||||||
|
index < messageList.length - 1
|
||||||
|
? messageList[index + 1]
|
||||||
|
: null;
|
||||||
|
final isLastInGroup =
|
||||||
|
nextMessage == null ||
|
||||||
|
nextMessage.senderId != message.senderId ||
|
||||||
|
nextMessage.createdAt
|
||||||
|
.difference(message.createdAt)
|
||||||
|
.inMinutes
|
||||||
|
.abs() >
|
||||||
|
3;
|
||||||
|
|
||||||
|
// Use a stable animation key that doesn't change during message lifecycle
|
||||||
|
final key = Key(
|
||||||
|
'$messageKeyPrefix${message.nonce ?? message.id}',
|
||||||
|
);
|
||||||
|
|
||||||
|
final messageWidget = chatIdentity.when(
|
||||||
|
skipError: true,
|
||||||
|
data:
|
||||||
|
(identity) => GestureDetector(
|
||||||
|
onLongPress: () {
|
||||||
if (!isSelectionMode.value) {
|
if (!isSelectionMode.value) {
|
||||||
toggleSelectionMode();
|
toggleSelectionMode();
|
||||||
|
toggleMessageSelection(message.id);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
onTap: () {
|
||||||
if (selectedMessages.value.contains(message.id))
|
if (isSelectionMode.value) {
|
||||||
Positioned(
|
toggleMessageSelection(message.id);
|
||||||
top: 8,
|
}
|
||||||
right: 8,
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 16,
|
color:
|
||||||
height: 16,
|
selectedMessages.value.contains(message.id)
|
||||||
decoration: BoxDecoration(
|
? Theme.of(context)
|
||||||
color: Theme.of(context).colorScheme.primary,
|
.colorScheme
|
||||||
shape: BoxShape.circle,
|
.primaryContainer
|
||||||
),
|
.withOpacity(0.3)
|
||||||
child: Icon(
|
: null,
|
||||||
Icons.check,
|
child: Stack(
|
||||||
size: 12,
|
children: [
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
MessageItem(
|
||||||
),
|
key: settings.disableAnimation ? key : null,
|
||||||
|
message: message,
|
||||||
|
isCurrentUser: identity?.id == message.senderId,
|
||||||
|
onAction:
|
||||||
|
isSelectionMode.value
|
||||||
|
? null
|
||||||
|
: (action) {
|
||||||
|
switch (action) {
|
||||||
|
case MessageItemAction.delete:
|
||||||
|
messagesNotifier.deleteMessage(
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
|
case MessageItemAction.edit:
|
||||||
|
messageEditingTo.value =
|
||||||
|
message.toRemoteMessage();
|
||||||
|
messageController.text =
|
||||||
|
messageEditingTo
|
||||||
|
.value
|
||||||
|
?.content ??
|
||||||
|
'';
|
||||||
|
attachments.value =
|
||||||
|
messageEditingTo
|
||||||
|
.value!
|
||||||
|
.attachments
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
UniversalFile.fromAttachment(
|
||||||
|
e,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
case MessageItemAction.forward:
|
||||||
|
messageForwardingTo.value =
|
||||||
|
message.toRemoteMessage();
|
||||||
|
case MessageItemAction.reply:
|
||||||
|
messageReplyingTo.value =
|
||||||
|
message.toRemoteMessage();
|
||||||
|
case MessageItemAction.resend:
|
||||||
|
messagesNotifier.retryMessage(
|
||||||
|
message.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onJump: (messageId) {
|
||||||
|
scrollToMessage(
|
||||||
|
messageId: messageId,
|
||||||
|
messageList: messageList,
|
||||||
|
messagesNotifier: messagesNotifier,
|
||||||
|
listController: listController,
|
||||||
|
scrollController: scrollController,
|
||||||
|
ref: ref,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
progress: attachmentProgress.value[message.id],
|
||||||
|
showAvatar: isLastInGroup,
|
||||||
|
isSelectionMode: isSelectionMode.value,
|
||||||
|
isSelected: selectedMessages.value.contains(
|
||||||
|
message.id,
|
||||||
|
),
|
||||||
|
onToggleSelection: toggleMessageSelection,
|
||||||
|
onEnterSelectionMode: () {
|
||||||
|
if (!isSelectionMode.value) {
|
||||||
|
toggleSelectionMode();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (selectedMessages.value.contains(message.id))
|
||||||
|
...([
|
||||||
|
Positioned(
|
||||||
|
top: 8,
|
||||||
|
right: 8,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check,
|
||||||
|
size: 12,
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
loading:
|
||||||
),
|
() => MessageItem(
|
||||||
),
|
message: message,
|
||||||
loading:
|
isCurrentUser: false,
|
||||||
() => MessageItem(
|
onAction: null,
|
||||||
message: message,
|
progress: null,
|
||||||
isCurrentUser: false,
|
showAvatar: false,
|
||||||
onAction: null,
|
onJump: (_) {},
|
||||||
progress: null,
|
),
|
||||||
showAvatar: false,
|
error: (_, _) => const SizedBox.shrink(),
|
||||||
onJump: (_) {},
|
|
||||||
),
|
|
||||||
error: (_, _) => const SizedBox.shrink(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return settings.disableAnimation
|
|
||||||
? messageWidget
|
|
||||||
: TweenAnimationBuilder<double>(
|
|
||||||
key: key,
|
|
||||||
tween: Tween<double>(begin: 0.0, end: 1.0),
|
|
||||||
duration: Duration(
|
|
||||||
milliseconds: 400 + (index % 5) * 50,
|
|
||||||
), // Staggered delay
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
builder: (context, animationValue, child) {
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(
|
|
||||||
0,
|
|
||||||
20 * (1 - animationValue),
|
|
||||||
), // Slide up from bottom
|
|
||||||
child: Opacity(opacity: animationValue, child: child),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return settings.disableAnimation
|
||||||
|
? messageWidget
|
||||||
|
: TweenAnimationBuilder<double>(
|
||||||
|
key: key,
|
||||||
|
tween: Tween<double>(begin: 0.0, end: 1.0),
|
||||||
|
duration: Duration(
|
||||||
|
milliseconds: 400 + (index % 5) * 50,
|
||||||
|
), // Staggered delay
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
builder: (context, animationValue, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(
|
||||||
|
0,
|
||||||
|
20 * (1 - animationValue),
|
||||||
|
), // Slide up from bottom
|
||||||
|
child: Opacity(opacity: animationValue, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: messageWidget,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: messageWidget,
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -965,6 +1241,7 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
child: chatRoom.when(
|
child: chatRoom.when(
|
||||||
data:
|
data:
|
||||||
(room) => Column(
|
(room) => Column(
|
||||||
|
key: inputKey,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ChatInput(
|
ChatInput(
|
||||||
@@ -979,10 +1256,16 @@ class ChatRoomScreen extends HookConsumerWidget {
|
|||||||
messageEditingTo.value = null;
|
messageEditingTo.value = null;
|
||||||
messageReplyingTo.value = null;
|
messageReplyingTo.value = null;
|
||||||
messageForwardingTo.value = null;
|
messageForwardingTo.value = null;
|
||||||
|
selectedPoll.value = null;
|
||||||
|
selectedFund.value = null;
|
||||||
},
|
},
|
||||||
messageEditingTo: messageEditingTo.value,
|
messageEditingTo: messageEditingTo.value,
|
||||||
messageReplyingTo: messageReplyingTo.value,
|
messageReplyingTo: messageReplyingTo.value,
|
||||||
messageForwardingTo: messageForwardingTo.value,
|
messageForwardingTo: messageForwardingTo.value,
|
||||||
|
selectedPoll: selectedPoll.value,
|
||||||
|
onPollSelected: (poll) => selectedPoll.value = poll,
|
||||||
|
selectedFund: selectedFund.value,
|
||||||
|
onFundSelected: (fund) => selectedFund.value = fund,
|
||||||
onPickFile: (bool isPhoto) {
|
onPickFile: (bool isPhoto) {
|
||||||
if (isPhoto) {
|
if (isPhoto) {
|
||||||
pickPhotoMedia();
|
pickPhotoMedia();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import 'package:island/widgets/alert.dart';
|
|||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
import 'package:island/widgets/content/sheet.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
|
import 'package:island/screens/chat/chat_form.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
@@ -447,10 +448,17 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
|
|||||||
if ((chatIdentity.value?.role ?? 0) >= 50)
|
if ((chatIdentity.value?.role ?? 0) >= 50)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.pushReplacementNamed(
|
showModalBottomSheet(
|
||||||
'chatEdit',
|
context: context,
|
||||||
pathParameters: {'id': id},
|
useRootNavigator: true,
|
||||||
);
|
isScrollControlled: true,
|
||||||
|
builder: (context) => EditChatScreen(id: id),
|
||||||
|
).then((value) {
|
||||||
|
if (value != null) {
|
||||||
|
// Invalidate to refresh room data after edit
|
||||||
|
ref.invalidate(chatroomProvider(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -261,7 +261,11 @@ class _PublisherUnselectedWidget extends HookConsumerWidget {
|
|||||||
subtitle: Text('createPublisherHint').tr(),
|
subtitle: Text('createPublisherHint').tr(),
|
||||||
trailing: const Icon(Symbols.chevron_right),
|
trailing: const Icon(Symbols.chevron_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
context.pushNamed('creatorNew').then((value) {
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const NewPublisherScreen(),
|
||||||
|
).then((value) {
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
ref.invalidate(publishersManagedProvider);
|
ref.invalidate(publishersManagedProvider);
|
||||||
}
|
}
|
||||||
@@ -285,19 +289,18 @@ class CreatorHubScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
void updatePublisher() {
|
void updatePublisher() {
|
||||||
context
|
showModalBottomSheet(
|
||||||
.pushNamed(
|
context: context,
|
||||||
'creatorEdit',
|
isScrollControlled: true,
|
||||||
pathParameters: {'name': currentPublisher.value!.name},
|
builder:
|
||||||
)
|
(context) =>
|
||||||
.then((value) async {
|
EditPublisherScreen(name: currentPublisher.value!.name),
|
||||||
if (value == null) return;
|
).then((value) async {
|
||||||
final data = await ref.refresh(publishersManagedProvider.future);
|
if (value == null) return;
|
||||||
currentPublisher.value =
|
final data = await ref.refresh(publishersManagedProvider.future);
|
||||||
data
|
currentPublisher.value =
|
||||||
.where((e) => e.id == currentPublisher.value!.id)
|
data.where((e) => e.id == currentPublisher.value!.id).firstOrNull;
|
||||||
.firstOrNull;
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void deletePublisher() {
|
void deletePublisher() {
|
||||||
@@ -828,7 +831,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final apiClient = ref.watch(apiClientProvider);
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
await apiClient.post(
|
await apiClient.post(
|
||||||
'/publishers/$publisherUname/invites',
|
'/sphere/publishers/$publisherUname/invites',
|
||||||
data: {'related_user_id': result.id, 'role': 0},
|
data: {'related_user_id': result.id, 'role': 0},
|
||||||
);
|
);
|
||||||
// Refresh both providers
|
// Refresh both providers
|
||||||
@@ -959,7 +962,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
|
|||||||
apiClientProvider,
|
apiClientProvider,
|
||||||
);
|
);
|
||||||
await apiClient.delete(
|
await apiClient.delete(
|
||||||
'/publishers/$publisherUname/members/${member.accountId}',
|
'/sphere/publishers/$publisherUname/members/${member.accountId}',
|
||||||
);
|
);
|
||||||
// Refresh both providers
|
// Refresh both providers
|
||||||
memberNotifier.reset();
|
memberNotifier.reset();
|
||||||
@@ -1084,7 +1087,7 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
final apiClient = ref.read(apiClientProvider);
|
||||||
await apiClient.patch(
|
await apiClient.patch(
|
||||||
'/publishers/$publisherUname/members/${member.accountId}/role',
|
'/sphere/publishers/$publisherUname/members/${member.accountId}/role',
|
||||||
data: newRole,
|
data: newRole,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/poll.dart';
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/screens/poll/poll_editor.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/poll/poll_feedback.dart';
|
import 'package:island/widgets/poll/poll_feedback.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -73,10 +73,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
|
|||||||
final String pubName;
|
final String pubName;
|
||||||
|
|
||||||
Future<void> _createPoll(BuildContext context) async {
|
Future<void> _createPoll(BuildContext context) async {
|
||||||
final result = await GoRouter.of(
|
final result = await showModalBottomSheet<SnPollWithStats>(
|
||||||
context,
|
context: context,
|
||||||
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
|
isScrollControlled: true,
|
||||||
if (result is SnPollWithStats && context.mounted) {
|
isDismissible: false,
|
||||||
|
enableDrag: false,
|
||||||
|
builder: (context) => PollEditorScreen(initialPublisher: pubName),
|
||||||
|
);
|
||||||
|
if (result != null && context.mounted) {
|
||||||
Navigator.of(context).maybePop(result);
|
Navigator.of(context).maybePop(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -176,11 +180,20 @@ class _CreatorPollItem extends HookConsumerWidget {
|
|||||||
Text('edit').tr(),
|
Text('edit').tr(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
GoRouter.of(context).pushNamed(
|
final result = await showModalBottomSheet<SnPoll>(
|
||||||
'creatorPollEdit',
|
context: context,
|
||||||
pathParameters: {'name': pubName, 'id': pollWithStats.id},
|
isScrollControlled: true,
|
||||||
|
isDismissible: false,
|
||||||
|
builder:
|
||||||
|
(context) => PollEditorScreen(
|
||||||
|
initialPublisher: pubName,
|
||||||
|
initialPollId: pollWithStats.id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
if (result != null && context.mounted) {
|
||||||
|
ref.invalidate(pollListNotifierProvider(pubName));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import 'package:island/screens/realm/realms.dart';
|
|||||||
import 'package:island/services/file.dart';
|
import 'package:island/services/file.dart';
|
||||||
import 'package:island/services/file_uploader.dart';
|
import 'package:island/services/file_uploader.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
@@ -95,11 +95,11 @@ class EditPublisherScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
|
ref: ref,
|
||||||
fileData: UniversalFile(
|
fileData: UniversalFile(
|
||||||
data: result,
|
data: result,
|
||||||
type: UniversalFileType.image,
|
type: UniversalFileType.image,
|
||||||
),
|
),
|
||||||
client: ref.read(apiClientProvider),
|
|
||||||
).future;
|
).future;
|
||||||
if (cloudFile == null) {
|
if (cloudFile == null) {
|
||||||
throw ArgumentError('Failed to upload the file...');
|
throw ArgumentError('Failed to upload the file...');
|
||||||
@@ -177,13 +177,11 @@ class EditPublisherScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return AppScaffold(
|
final titleText = (name == null ? 'createPublisher' : 'editPublisher').tr();
|
||||||
isNoBackground: false,
|
|
||||||
appBar: AppBar(
|
return SheetScaffold(
|
||||||
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
|
titleText: titleText,
|
||||||
leading: const PageBackButton(),
|
child: SingleChildScrollView(
|
||||||
),
|
|
||||||
body: SingleChildScrollView(
|
|
||||||
padding: EdgeInsets.only(bottom: 16),
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ class EditAppScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
client: ref.read(apiClientProvider),
|
ref: ref,
|
||||||
fileData: UniversalFile(
|
fileData: UniversalFile(
|
||||||
data: result,
|
data: result,
|
||||||
type: UniversalFileType.image,
|
type: UniversalFileType.image,
|
||||||
|
|||||||
@@ -127,11 +127,11 @@ class EditBotScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
|
ref: ref,
|
||||||
fileData: UniversalFile(
|
fileData: UniversalFile(
|
||||||
data: result,
|
data: result,
|
||||||
type: UniversalFileType.image,
|
type: UniversalFileType.image,
|
||||||
),
|
),
|
||||||
client: ref.read(apiClientProvider),
|
|
||||||
).future;
|
).future;
|
||||||
if (cloudFile == null) {
|
if (cloudFile == null) {
|
||||||
throw ArgumentError('Failed to upload the file...');
|
throw ArgumentError('Failed to upload the file...');
|
||||||
|
|||||||
@@ -11,15 +11,16 @@ import 'package:island/models/realm.dart';
|
|||||||
import 'package:island/models/webfeed.dart';
|
import 'package:island/models/webfeed.dart';
|
||||||
import 'package:island/pods/event_calendar.dart';
|
import 'package:island/pods/event_calendar.dart';
|
||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/screens/auth/login_modal.dart';
|
||||||
import 'package:island/screens/notification.dart';
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/widgets/account/friends_overview.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/models/post.dart';
|
import 'package:island/models/post.dart';
|
||||||
import 'package:island/widgets/check_in.dart';
|
import 'package:island/widgets/check_in.dart';
|
||||||
import 'package:island/widgets/navigation/fab_menu.dart';
|
import 'package:island/widgets/navigation/fab_menu.dart';
|
||||||
import 'package:island/widgets/post/post_featured.dart';
|
import 'package:island/widgets/post/post_featured.dart';
|
||||||
import 'package:island/widgets/post/post_item.dart';
|
import 'package:island/widgets/post/post_item.dart';
|
||||||
import 'package:island/widgets/post/compose_card.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
@@ -341,7 +342,7 @@ class ExploreScreen extends HookConsumerWidget {
|
|||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
PostFeaturedList(),
|
PostFeaturedList(),
|
||||||
const PostComposeCard(),
|
FriendsOverviewWidget(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -350,21 +351,39 @@ class ExploreScreen extends HookConsumerWidget {
|
|||||||
else
|
else
|
||||||
Flexible(
|
Flexible(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Column(
|
child:
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Text(
|
children: [
|
||||||
'Welcome to\nthe Solar Network',
|
const Icon(Symbols.emoji_people_rounded, size: 40),
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
const Gap(8),
|
||||||
).bold(),
|
Text(
|
||||||
const Gap(2),
|
'Welcome to\nthe Solar Network',
|
||||||
Text(
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
'Login to explore more!',
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
).bold(),
|
||||||
),
|
const Gap(2),
|
||||||
],
|
Text(
|
||||||
).padding(horizontal: 36, vertical: 16),
|
'Login to explore more!',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => LoginModal(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Symbols.login),
|
||||||
|
label: Text('login').tr(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
).padding(horizontal: 36, vertical: 16).center(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
).padding(horizontal: 12);
|
).padding(horizontal: 12);
|
||||||
@@ -523,6 +542,11 @@ class ExploreScreen extends HookConsumerWidget {
|
|||||||
child: PostFeaturedList(),
|
child: PostFeaturedList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: FriendsOverviewWidget(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (notificationCount.value != null &&
|
if (notificationCount.value != null &&
|
||||||
notificationCount.value! > 0)
|
notificationCount.value! > 0)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
|
|||||||
231
lib/screens/files/file_detail.dart
Normal file
231
lib/screens/files/file_detail.dart
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_saver/file_saver.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gal/gal.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
|
import 'package:island/widgets/content/file_viewer_contents.dart';
|
||||||
|
import 'package:path/path.dart' show extension;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
class FileDetailScreen extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
|
||||||
|
const FileDetailScreen({super.key, required this.item});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
|
final isWide = isWideScreen(context);
|
||||||
|
|
||||||
|
// Animation controller for the drawer
|
||||||
|
final animationController = useAnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
);
|
||||||
|
final animation = useMemoized(
|
||||||
|
() => Tween<double>(begin: 0, end: 1).animate(
|
||||||
|
CurvedAnimation(parent: animationController, curve: Curves.easeInOut),
|
||||||
|
),
|
||||||
|
[animationController],
|
||||||
|
);
|
||||||
|
|
||||||
|
final showDrawer = useState(false);
|
||||||
|
|
||||||
|
void showInfoSheet() {
|
||||||
|
if (isWide) {
|
||||||
|
// Show as animated right panel on wide screens
|
||||||
|
showDrawer.value = !showDrawer.value;
|
||||||
|
if (showDrawer.value) {
|
||||||
|
animationController.forward();
|
||||||
|
} else {
|
||||||
|
animationController.reverse();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show as bottom sheet on narrow screens
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => FileInfoSheet(item: item),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to drawer state changes
|
||||||
|
useEffect(() {
|
||||||
|
void listener() {
|
||||||
|
if (!animationController.isAnimating) {
|
||||||
|
if (animationController.value == 0) {
|
||||||
|
showDrawer.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animationController.addListener(listener);
|
||||||
|
return () => animationController.removeListener(listener);
|
||||||
|
}, [animationController]);
|
||||||
|
|
||||||
|
return AppScaffold(
|
||||||
|
isNoBackground: true,
|
||||||
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Text(item.name.isEmpty ? 'File Details' : item.name),
|
||||||
|
actions: _buildAppBarActions(context, ref, showInfoSheet),
|
||||||
|
),
|
||||||
|
body: AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
// Main content area
|
||||||
|
Expanded(child: _buildContent(context, ref, serverUrl)),
|
||||||
|
// Animated drawer panel
|
||||||
|
if (isWide)
|
||||||
|
SizedBox(
|
||||||
|
height: double.infinity,
|
||||||
|
width: animation.value * 400, // Max width of 400px
|
||||||
|
child: Container(
|
||||||
|
child:
|
||||||
|
animation.value > 0.1
|
||||||
|
? FileInfoSheet(item: item, onClose: showInfoSheet)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAppBarActions(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
VoidCallback showInfoSheet,
|
||||||
|
) {
|
||||||
|
final actions = <Widget>[];
|
||||||
|
|
||||||
|
// Add content-specific actions
|
||||||
|
switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
|
case 'image':
|
||||||
|
if (!kIsWeb) {
|
||||||
|
actions.add(
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.save_alt),
|
||||||
|
onPressed: () async => _saveToGallery(ref),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// HD/SD toggle will be handled in the image content overlay
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (!kIsWeb) {
|
||||||
|
actions.add(
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.save_alt),
|
||||||
|
onPressed: () async => _downloadFile(ref),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add info button
|
||||||
|
actions.add(
|
||||||
|
IconButton(icon: Icon(Icons.info_outline), onPressed: showInfoSheet),
|
||||||
|
);
|
||||||
|
|
||||||
|
actions.add(const Gap(8));
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveToGallery(WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
showSnackBar('Saving image...');
|
||||||
|
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
var extName = extension(item.name).trim();
|
||||||
|
if (extName.isEmpty) {
|
||||||
|
extName = item.mimeType?.split('/').lastOrNull ?? 'jpeg';
|
||||||
|
}
|
||||||
|
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||||
|
|
||||||
|
await client.download(
|
||||||
|
'/drive/files/${item.id}',
|
||||||
|
filePath,
|
||||||
|
queryParameters: {'original': true},
|
||||||
|
);
|
||||||
|
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||||
|
await Gal.putImage(filePath, album: 'Solar Network');
|
||||||
|
showSnackBar('Image saved to gallery');
|
||||||
|
} else {
|
||||||
|
await FileSaver.instance.saveFile(
|
||||||
|
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||||
|
file: File(filePath),
|
||||||
|
);
|
||||||
|
showSnackBar('Image saved to $filePath');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showErrorAlert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadFile(WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
showSnackBar('Downloading file...');
|
||||||
|
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
var extName = extension(item.name).trim();
|
||||||
|
if (extName.isEmpty) {
|
||||||
|
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
|
||||||
|
}
|
||||||
|
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||||
|
|
||||||
|
await client.download(
|
||||||
|
'/drive/files/${item.id}',
|
||||||
|
filePath,
|
||||||
|
queryParameters: {'original': true},
|
||||||
|
);
|
||||||
|
|
||||||
|
await FileSaver.instance.saveFile(
|
||||||
|
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||||
|
file: File(filePath),
|
||||||
|
);
|
||||||
|
showSnackBar('File saved to downloads');
|
||||||
|
} catch (e) {
|
||||||
|
showErrorAlert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(BuildContext context, WidgetRef ref, String serverUrl) {
|
||||||
|
final uri = '$serverUrl/drive/files/${item.id}';
|
||||||
|
|
||||||
|
return switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
|
'image' => ImageFileContent(item: item, uri: uri),
|
||||||
|
'video' => VideoFileContent(item: item, uri: uri),
|
||||||
|
'audio' => AudioFileContent(item: item, uri: uri),
|
||||||
|
_ when item.mimeType == 'application/pdf' => PdfFileContent(uri: uri),
|
||||||
|
_ when item.mimeType?.startsWith('text/') == true => TextFileContent(
|
||||||
|
uri: uri,
|
||||||
|
),
|
||||||
|
_ => GenericFileContent(item: item),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,122 +1,65 @@
|
|||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:cross_file/cross_file.dart';
|
||||||
import 'package:fl_chart/fl_chart.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/file_list.dart';
|
||||||
import 'package:island/pods/file_pool.dart';
|
import 'package:island/services/file_uploader.dart';
|
||||||
import 'package:island/utils/format.dart';
|
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:island/widgets/content/cloud_files.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
import 'package:island/widgets/file_list_view.dart';
|
||||||
|
import 'package:island/widgets/usage_overview.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
|
||||||
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
part 'file_list.g.dart';
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
class CloudFileListNotifier extends _$CloudFileListNotifier
|
|
||||||
with CursorPagingNotifierMixin<SnCloudFile> {
|
|
||||||
String? _poolId;
|
|
||||||
bool _includeRecycled = false;
|
|
||||||
|
|
||||||
void setFilters(String? poolId, bool includeRecycled) {
|
|
||||||
_poolId = poolId;
|
|
||||||
_includeRecycled = includeRecycled;
|
|
||||||
ref.invalidateSelf();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<CursorPagingData<SnCloudFile>> build() => fetch(cursor: null);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<CursorPagingData<SnCloudFile>> fetch({required String? cursor}) async {
|
|
||||||
final client = ref.read(apiClientProvider);
|
|
||||||
final offset = cursor == null ? 0 : int.parse(cursor);
|
|
||||||
final take = 20;
|
|
||||||
|
|
||||||
final queryParameters = <String, dynamic>{'offset': offset, 'take': take};
|
|
||||||
|
|
||||||
// Add filter parameters
|
|
||||||
if (_poolId != null) {
|
|
||||||
queryParameters['pool'] = _poolId!;
|
|
||||||
}
|
|
||||||
if (_includeRecycled) {
|
|
||||||
queryParameters['recycled'] = 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
final response = await client.get(
|
|
||||||
'/drive/files/me',
|
|
||||||
queryParameters: queryParameters,
|
|
||||||
);
|
|
||||||
|
|
||||||
final List<SnCloudFile> items =
|
|
||||||
(response.data as List)
|
|
||||||
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
|
|
||||||
.toList();
|
|
||||||
final total = int.parse(response.headers.value('X-Total') ?? '0');
|
|
||||||
|
|
||||||
final hasMore = offset + items.length < total;
|
|
||||||
final nextCursor = hasMore ? (offset + items.length).toString() : null;
|
|
||||||
|
|
||||||
return CursorPagingData(
|
|
||||||
items: items,
|
|
||||||
hasMore: hasMore,
|
|
||||||
nextCursor: nextCursor,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
|
|
||||||
final client = ref.read(apiClientProvider);
|
|
||||||
final response = await client.get('/drive/billing/usage');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
@riverpod
|
|
||||||
Future<Map<String, dynamic>?> billingQuota(Ref ref) async {
|
|
||||||
final client = ref.read(apiClientProvider);
|
|
||||||
final response = await client.get('/drive/billing/quota');
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
class FileListScreen extends HookConsumerWidget {
|
class FileListScreen extends HookConsumerWidget {
|
||||||
const FileListScreen({super.key});
|
const FileListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
// Filter state
|
// Path navigation state
|
||||||
final selectedPool = useState<String?>(null);
|
final currentPath = useState<String>('/');
|
||||||
final includeRecycled = useState(false);
|
final mode = useState<FileListMode>(FileListMode.normal);
|
||||||
|
|
||||||
final usageAsync = ref.watch(billingUsageProvider);
|
final usageAsync = ref.watch(billingUsageProvider);
|
||||||
final quotaAsync = ref.watch(billingQuotaProvider);
|
final quotaAsync = ref.watch(billingQuotaProvider);
|
||||||
|
|
||||||
// Update notifier filters when state changes
|
final viewMode = useState(FileListViewMode.list);
|
||||||
useEffect(() {
|
|
||||||
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
|
|
||||||
notifier.setFilters(selectedPool.value, includeRecycled.value);
|
|
||||||
return null;
|
|
||||||
}, [selectedPool.value, includeRecycled.value]);
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
appBar: AppBar(title: Text('Files'), leading: const PageBackButton()),
|
isNoBackground: false,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text('Files'),
|
||||||
|
leading: const PageBackButton(),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.bar_chart),
|
||||||
|
onPressed:
|
||||||
|
() => _showUsageSheet(
|
||||||
|
context,
|
||||||
|
usageAsync.value,
|
||||||
|
quotaAsync.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
],
|
||||||
|
),
|
||||||
body: usageAsync.when(
|
body: usageAsync.when(
|
||||||
data:
|
data:
|
||||||
(usage) => quotaAsync.when(
|
(usage) => quotaAsync.when(
|
||||||
data:
|
data:
|
||||||
(quota) => _buildQuotaUI(
|
(quota) => FileListView(
|
||||||
usage,
|
usage: usage,
|
||||||
quota,
|
quota: quota,
|
||||||
ref,
|
currentPath: currentPath,
|
||||||
selectedPool,
|
onPickAndUpload:
|
||||||
includeRecycled,
|
() => _pickAndUploadFile(ref, currentPath.value),
|
||||||
|
onShowCreateDirectory: _showCreateDirectoryDialog,
|
||||||
|
mode: mode,
|
||||||
|
viewMode: viewMode,
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (e, _) => Center(child: Text('Error loading quota')),
|
error: (e, _) => Center(child: Text('Error loading quota')),
|
||||||
@@ -127,430 +70,138 @@ class FileListScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildQuotaUI(
|
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async {
|
||||||
Map<String, dynamic>? usage,
|
|
||||||
Map<String, dynamic>? quota,
|
|
||||||
WidgetRef ref,
|
|
||||||
ValueNotifier<String?> selectedPool,
|
|
||||||
ValueNotifier<bool> includeRecycled,
|
|
||||||
) {
|
|
||||||
if (usage == null) return const SizedBox.shrink();
|
|
||||||
return CustomScrollView(
|
|
||||||
slivers: [
|
|
||||||
const SliverGap(8),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatCard(
|
|
||||||
'All Uploads',
|
|
||||||
'${((usage['total_usage_bytes'] as num) / (1024 * 1024 * 1024)).toStringAsFixed(3)} GiB',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatCard(
|
|
||||||
'All Files',
|
|
||||||
'${usage['total_file_count']}',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatCard(
|
|
||||||
'Quota',
|
|
||||||
'${usage['total_quota']} MiB',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: _buildStatCard(
|
|
||||||
'Used Quota',
|
|
||||||
'${((usage['used_quota'] as num) / (usage['total_quota'] as num) * 100).toStringAsFixed(2)}%',
|
|
||||||
progress:
|
|
||||||
(usage['used_quota'] as num) /
|
|
||||||
(usage['total_quota'] as num),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 8),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text('Pool Usage'),
|
|
||||||
SizedBox(
|
|
||||||
height: 200,
|
|
||||||
child: PieChart(_buildPoolChartData(usage)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text('Verbose Quota'),
|
|
||||||
SizedBox(
|
|
||||||
height: 200,
|
|
||||||
child: PieChart(_buildQuotaChartData(quota)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
).padding(horizontal: 8),
|
|
||||||
),
|
|
||||||
const SliverGap(8),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: _buildFilters(ref, selectedPool, includeRecycled),
|
|
||||||
),
|
|
||||||
const SliverGap(8),
|
|
||||||
PagingHelperSliverView(
|
|
||||||
provider: cloudFileListNotifierProvider,
|
|
||||||
futureRefreshable: cloudFileListNotifierProvider.future,
|
|
||||||
notifierRefreshable: cloudFileListNotifierProvider.notifier,
|
|
||||||
contentBuilder:
|
|
||||||
(data, widgetCount, endItemView) => SliverList.builder(
|
|
||||||
itemCount: widgetCount,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (index == widgetCount - 1) {
|
|
||||||
return endItemView;
|
|
||||||
}
|
|
||||||
|
|
||||||
final item = data.items[index];
|
|
||||||
final itemType = item.mimeType?.split('/').firstOrNull;
|
|
||||||
return ListTile(
|
|
||||||
leading: ClipRRect(
|
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
||||||
child: SizedBox(
|
|
||||||
height: 48,
|
|
||||||
width: 48,
|
|
||||||
child: switch (itemType) {
|
|
||||||
'image' => CloudImageWidget(file: item),
|
|
||||||
'audio' =>
|
|
||||||
const Icon(Symbols.audio_file, fill: 1).center(),
|
|
||||||
'video' =>
|
|
||||||
const Icon(Symbols.video_file, fill: 1).center(),
|
|
||||||
_ =>
|
|
||||||
const Icon(Symbols.body_system, fill: 1).center(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title:
|
|
||||||
item.name.isEmpty
|
|
||||||
? Text('untitled').tr().italic()
|
|
||||||
: Text(
|
|
||||||
item.name,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
subtitle: Text(formatFileSize(item.size)),
|
|
||||||
onTap: () {
|
|
||||||
showModalBottomSheet(
|
|
||||||
useRootNavigator: true,
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
builder: (context) => FileInfoSheet(item: item),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
trailing: IconButton(
|
|
||||||
icon: const Icon(Symbols.delete),
|
|
||||||
onPressed: () async {
|
|
||||||
final confirmed = await showConfirmAlert(
|
|
||||||
'confirmDeleteFile'.tr(),
|
|
||||||
'deleteFile'.tr(),
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
if (context.mounted) showLoadingModal(context);
|
|
||||||
try {
|
|
||||||
final client = ref.read(apiClientProvider);
|
|
||||||
await client.delete('/drive/files/${item.id}');
|
|
||||||
ref.invalidate(cloudFileListNotifierProvider);
|
|
||||||
} catch (e) {
|
|
||||||
showSnackBar('failedToDeleteFile'.tr());
|
|
||||||
} finally {
|
|
||||||
if (context.mounted) hideLoadingModal(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PieChartData _buildPoolChartData(Map<String, dynamic> usage) {
|
|
||||||
final pools = usage['pool_usages'] as List<dynamic>;
|
|
||||||
final colors = [
|
|
||||||
Colors.blue,
|
|
||||||
Colors.green,
|
|
||||||
Colors.orange,
|
|
||||||
Colors.red,
|
|
||||||
Colors.purple,
|
|
||||||
];
|
|
||||||
return PieChartData(
|
|
||||||
sections:
|
|
||||||
pools.asMap().entries.map((entry) {
|
|
||||||
final pool = entry.value as Map<String, dynamic>;
|
|
||||||
final title = pool['pool_name'] as String;
|
|
||||||
final truncatedTitle =
|
|
||||||
title.length > 8 ? '${title.substring(0, 8)}...' : title;
|
|
||||||
return PieChartSectionData(
|
|
||||||
value: (pool['usage_bytes'] as num).toDouble(),
|
|
||||||
title: truncatedTitle,
|
|
||||||
color: colors[entry.key % colors.length],
|
|
||||||
radius: 60,
|
|
||||||
titleStyle: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PieChartData _buildQuotaChartData(Map<String, dynamic>? quota) {
|
|
||||||
if (quota == null) return PieChartData(sections: []);
|
|
||||||
return PieChartData(
|
|
||||||
sections: [
|
|
||||||
PieChartSectionData(
|
|
||||||
value: (quota['based_quota'] as num).toDouble(),
|
|
||||||
title: 'Base',
|
|
||||||
color: Colors.green,
|
|
||||||
radius: 60,
|
|
||||||
titleStyle: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
PieChartSectionData(
|
|
||||||
value: (quota['extra_quota'] as num).toDouble(),
|
|
||||||
title: 'Extra',
|
|
||||||
color: Colors.orange,
|
|
||||||
radius: 60,
|
|
||||||
titleStyle: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFilters(
|
|
||||||
WidgetRef ref,
|
|
||||||
ValueNotifier<String?> selectedPool,
|
|
||||||
ValueNotifier<bool> includeRecycled,
|
|
||||||
) {
|
|
||||||
final poolsAsync = ref.watch(poolsProvider);
|
|
||||||
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'filters'.tr(),
|
|
||||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final isWide = constraints.maxWidth > 600;
|
|
||||||
return isWide
|
|
||||||
? Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: poolsAsync.when(
|
|
||||||
data:
|
|
||||||
(pools) => DropdownButtonFormField<String?>(
|
|
||||||
value: selectedPool.value,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Pool',
|
|
||||||
border: const OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
DropdownMenuItem<String?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('allPools'.tr()),
|
|
||||||
),
|
|
||||||
...pools.map(
|
|
||||||
(pool) => DropdownMenuItem<String?>(
|
|
||||||
value: pool.id,
|
|
||||||
child: Text(pool.name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged:
|
|
||||||
(value) => selectedPool.value = value,
|
|
||||||
),
|
|
||||||
loading: () => const CircularProgressIndicator(),
|
|
||||||
error: (e, _) => const Text('Error loading pools'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
Expanded(
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text('includeRecycled'.tr()),
|
|
||||||
const Gap(8),
|
|
||||||
Switch(
|
|
||||||
value: includeRecycled.value,
|
|
||||||
onChanged:
|
|
||||||
(value) => includeRecycled.value = value,
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Symbols.delete_sweep),
|
|
||||||
tooltip: 'deleteRecycledFiles'.tr(),
|
|
||||||
onPressed:
|
|
||||||
includeRecycled.value
|
|
||||||
? () => _deleteRecycledFiles(ref)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
poolsAsync.when(
|
|
||||||
data:
|
|
||||||
(pools) => DropdownButtonFormField<String?>(
|
|
||||||
value: selectedPool.value,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Pool',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
DropdownMenuItem<String?>(
|
|
||||||
value: null,
|
|
||||||
child: Text('allPools'.tr()),
|
|
||||||
),
|
|
||||||
...pools.map(
|
|
||||||
(pool) => DropdownMenuItem<String?>(
|
|
||||||
value: pool.id,
|
|
||||||
child: Text(pool.name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged:
|
|
||||||
(value) => selectedPool.value = value,
|
|
||||||
),
|
|
||||||
loading: () => const CircularProgressIndicator(),
|
|
||||||
error: (e, _) => const Text('Error loading pools'),
|
|
||||||
),
|
|
||||||
const Gap(16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text('includeRecycled'.tr()),
|
|
||||||
const Gap(8),
|
|
||||||
Switch(
|
|
||||||
value: includeRecycled.value,
|
|
||||||
onChanged:
|
|
||||||
(value) => includeRecycled.value = value,
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Symbols.delete_sweep),
|
|
||||||
tooltip: 'deleteRecycledFiles'.tr(),
|
|
||||||
onPressed:
|
|
||||||
includeRecycled.value
|
|
||||||
? () => _deleteRecycledFiles(ref)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).padding(horizontal: 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteRecycledFiles(WidgetRef ref) async {
|
|
||||||
final confirmed = await showConfirmAlert(
|
|
||||||
'confirmDeleteRecycledFiles'.tr(),
|
|
||||||
'deleteRecycledFiles'.tr(),
|
|
||||||
);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
if (ref.context.mounted) showLoadingModal(ref.context);
|
|
||||||
try {
|
try {
|
||||||
final client = ref.read(apiClientProvider);
|
final result = await FilePicker.platform.pickFiles(
|
||||||
await client.delete('/drive/files/recycled');
|
allowMultiple: true,
|
||||||
ref.invalidate(cloudFileListNotifierProvider);
|
withData: false,
|
||||||
showSnackBar('recycledFilesDeleted'.tr());
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
for (final file in result.files) {
|
||||||
|
if (file.path != null) {
|
||||||
|
// Create UniversalFile from the picked file
|
||||||
|
final universalFile = UniversalFile(
|
||||||
|
data: XFile(file.path!),
|
||||||
|
type: UniversalFileType.file,
|
||||||
|
displayName: file.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Upload the file with the current path
|
||||||
|
final completer = FileUploader.createCloudFile(
|
||||||
|
fileData: universalFile,
|
||||||
|
ref: ref,
|
||||||
|
path: currentPath,
|
||||||
|
onProgress: (progress, _) {
|
||||||
|
// Progress is handled by the upload tasks system
|
||||||
|
if (progress != null) {
|
||||||
|
debugPrint('Upload progress: ${(progress * 100).toInt()}%');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
completer.future
|
||||||
|
.then((uploadedFile) {
|
||||||
|
if (uploadedFile != null) {
|
||||||
|
ref.invalidate(cloudFileListNotifierProvider);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catchError((error) {
|
||||||
|
showSnackBar('Failed to upload file: $error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showSnackBar('failedToDeleteRecycledFiles'.tr());
|
showSnackBar('Error picking file: $e');
|
||||||
} finally {
|
|
||||||
if (ref.context.mounted) hideLoadingModal(ref.context);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildStatCard(String label, String value, {double? progress}) {
|
Future<void> _showCreateDirectoryDialog(
|
||||||
return Card(
|
BuildContext context,
|
||||||
child: Padding(
|
ValueNotifier<String> currentPath,
|
||||||
padding: const EdgeInsets.all(16),
|
) async {
|
||||||
child: Column(
|
final controller = TextEditingController(text: currentPath.value);
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
String? newPath;
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
void handleChangeDirectory(BuildContext context) {
|
||||||
Text(label, style: const TextStyle(fontSize: 14)),
|
newPath = controller.text.trim();
|
||||||
Row(
|
if (newPath!.isNotEmpty) {
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
// Normalize the path
|
||||||
|
String fullPath = newPath!;
|
||||||
|
|
||||||
|
// Ensure it starts with /
|
||||||
|
if (!fullPath.startsWith('/')) {
|
||||||
|
fullPath = '/$fullPath';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove double slashes and normalize
|
||||||
|
fullPath = fullPath.replaceAll(RegExp(r'/+'), '/');
|
||||||
|
|
||||||
|
currentPath.value = fullPath;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder:
|
||||||
|
(context) => AlertDialog(
|
||||||
|
title: const Text('Navigate to Directory'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Gap(8),
|
||||||
value,
|
TextField(
|
||||||
style: const TextStyle(
|
controller: controller,
|
||||||
fontSize: 24,
|
decoration: const InputDecoration(
|
||||||
fontWeight: FontWeight.bold,
|
labelText: 'Directory path',
|
||||||
|
hintText: 'e.g., documents, projects/my-app',
|
||||||
|
helperText:
|
||||||
|
'Enter a directory path. The directory will be created when you upload files to it.',
|
||||||
|
helperMaxLines: 3,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
onSubmitted: (_) {
|
||||||
|
handleChangeDirectory(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
if (progress != null) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SizedBox(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
child: CircularProgressIndicator(value: progress),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
actions: [
|
||||||
),
|
TextButton(
|
||||||
),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => handleChangeDirectory(context),
|
||||||
|
label: const Text('Go to Directory'),
|
||||||
|
icon: const Icon(Symbols.arrow_right_alt),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showUsageSheet(
|
||||||
|
BuildContext context,
|
||||||
|
Map<String, dynamic>? usage,
|
||||||
|
Map<String, dynamic>? quota,
|
||||||
|
) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder:
|
||||||
|
(context) => SheetScaffold(
|
||||||
|
titleText: 'Usage Overview',
|
||||||
|
child: UsageOverviewWidget(
|
||||||
|
usage: usage,
|
||||||
|
quota: quota,
|
||||||
|
).padding(horizontal: 8, vertical: 16),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:island/pods/network.dart';
|
|||||||
import 'package:island/talker.dart';
|
import 'package:island/talker.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/models/poll.dart';
|
import 'package:island/models/poll.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/content/sheet.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -393,7 +393,7 @@ class PollEditorScreen extends ConsumerWidget {
|
|||||||
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
|
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
Navigator.of(context).maybePop(res.data);
|
Navigator.of(context).maybePop(SnPoll.fromJson(res.data));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showErrorAlert(e);
|
showErrorAlert(e);
|
||||||
}
|
}
|
||||||
@@ -415,23 +415,46 @@ class PollEditorScreen extends ConsumerWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return AppScaffold(
|
return SheetScaffold(
|
||||||
isNoBackground: false,
|
titleText: model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr(),
|
||||||
appBar: AppBar(
|
actions: [
|
||||||
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
|
if (kDebugMode)
|
||||||
actions: [
|
IconButton(
|
||||||
if (kDebugMode)
|
tooltip: 'pollPreviewJsonDebug'.tr(),
|
||||||
IconButton(
|
onPressed: () {
|
||||||
tooltip: 'pollPreviewJsonDebug'.tr(),
|
_showDebugPreview(context, model);
|
||||||
onPressed: () {
|
},
|
||||||
_showDebugPreview(context, model);
|
icon: const Icon(Icons.visibility_outlined),
|
||||||
},
|
),
|
||||||
icon: const Icon(Icons.visibility_outlined),
|
],
|
||||||
),
|
heightFactor: 0.9,
|
||||||
const Gap(8),
|
onClose: () async {
|
||||||
],
|
final confirmed = await showDialog<bool>(
|
||||||
),
|
context: context,
|
||||||
body: Column(
|
builder:
|
||||||
|
(ctx) => AlertDialog(
|
||||||
|
title: Text('confirm'.tr()),
|
||||||
|
content: Text('pollConfirmDiscard'.tr()),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: Text('cancel'.tr()),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Theme.of(ctx).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: Text('discard'.tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
ValueListenableBuilder<Map<int, double>>(
|
ValueListenableBuilder<Map<int, double?>>(
|
||||||
valueListenable: state.attachmentProgress,
|
valueListenable: state.attachmentProgress,
|
||||||
builder: (context, progressMap, _) {
|
builder: (context, progressMap, _) {
|
||||||
return Wrap(
|
return Wrap(
|
||||||
|
|||||||
@@ -237,7 +237,9 @@ class PostSearchScreen extends HookConsumerWidget {
|
|||||||
controller: pubNameController,
|
controller: pubNameController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'pubName'.tr(),
|
labelText: 'pubName'.tr(),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onChanged:
|
onChanged:
|
||||||
(value) => onSearchWithFilters(searchController.text),
|
(value) => onSearchWithFilters(searchController.text),
|
||||||
@@ -247,7 +249,9 @@ class PostSearchScreen extends HookConsumerWidget {
|
|||||||
controller: realmController,
|
controller: realmController,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'realm'.tr(),
|
labelText: 'realm'.tr(),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onChanged:
|
onChanged:
|
||||||
(value) => onSearchWithFilters(searchController.text),
|
(value) => onSearchWithFilters(searchController.text),
|
||||||
|
|||||||
@@ -73,10 +73,33 @@ class _PublisherBasisWidget extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
bottom: -24,
|
bottom: -24,
|
||||||
left: 16,
|
left: 16,
|
||||||
child: ProfilePictureWidget(
|
child: GestureDetector(
|
||||||
file: data.picture,
|
child: Badge(
|
||||||
radius: 32,
|
isLabelVisible: data.type == 0,
|
||||||
borderRadius: data.type == 0 ? null : 12,
|
padding: EdgeInsets.all(3),
|
||||||
|
label: Icon(
|
||||||
|
Symbols.launch,
|
||||||
|
size: 12,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
offset: Offset(0, 48),
|
||||||
|
child: ProfilePictureWidget(
|
||||||
|
file: data.picture,
|
||||||
|
radius: 32,
|
||||||
|
borderRadius: data.type == 0 ? null : 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (data.account?.name != null) {
|
||||||
|
Navigator.pop(context, true);
|
||||||
|
context.pushNamed(
|
||||||
|
'accountProfile',
|
||||||
|
pathParameters: {'name': data.account!.name},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ class EditRealmScreen extends HookConsumerWidget {
|
|||||||
try {
|
try {
|
||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
client: ref.read(apiClientProvider),
|
ref: ref,
|
||||||
fileData: UniversalFile(
|
fileData: UniversalFile(
|
||||||
data: result,
|
data: result,
|
||||||
type: UniversalFileType.image,
|
type: UniversalFileType.image,
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
import 'package:island/screens/notification.dart';
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
import 'package:island/widgets/navigation/conditional_bottom_nav.dart';
|
import 'package:island/widgets/navigation/conditional_bottom_nav.dart';
|
||||||
import 'package:island/widgets/navigation/fab_menu.dart';
|
import 'package:island/widgets/navigation/fab_menu.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
@@ -21,6 +23,8 @@ const kTabRoutes = [
|
|||||||
'/chat',
|
'/chat',
|
||||||
'/realms',
|
'/realms',
|
||||||
'/account',
|
'/account',
|
||||||
|
'/files',
|
||||||
|
'/thought',
|
||||||
'/creators',
|
'/creators',
|
||||||
'/developers',
|
'/developers',
|
||||||
];
|
];
|
||||||
@@ -66,19 +70,40 @@ class TabsScreen extends HookConsumerWidget {
|
|||||||
icon: Badge.count(
|
icon: Badge.count(
|
||||||
count: notificationUnreadCount.value ?? 0,
|
count: notificationUnreadCount.value ?? 0,
|
||||||
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
|
isLabelVisible: (notificationUnreadCount.value ?? 0) > 0,
|
||||||
child: const Icon(Symbols.person_rounded),
|
child: Consumer(
|
||||||
|
child: const Icon(Symbols.account_circle_rounded),
|
||||||
|
builder: (context, ref, fallbackChild) {
|
||||||
|
final userInfo = ref.watch(userInfoProvider);
|
||||||
|
if (userInfo.value?.profile.picture != null) {
|
||||||
|
return ProfilePictureWidget(
|
||||||
|
file: userInfo.value!.profile.picture,
|
||||||
|
radius: 12,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fallbackChild!;
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (wideScreen)
|
if (wideScreen)
|
||||||
NavigationDestination(
|
...([
|
||||||
label: 'creatorHub'.tr(),
|
NavigationDestination(
|
||||||
icon: const Icon(Symbols.design_services_rounded),
|
label: 'files'.tr(),
|
||||||
),
|
icon: const Icon(Symbols.folder_rounded),
|
||||||
if (wideScreen)
|
),
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
label: 'developerHub'.tr(),
|
label: 'aiThought'.tr(),
|
||||||
icon: const Icon(Symbols.data_object_rounded),
|
icon: const Icon(Symbols.bubble_chart),
|
||||||
),
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
label: 'creatorHub'.tr(),
|
||||||
|
icon: const Icon(Symbols.design_services_rounded),
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
label: 'developerHub'.tr(),
|
||||||
|
icon: const Icon(Symbols.data_object_rounded),
|
||||||
|
),
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
|
|
||||||
int getCurrentIndex() {
|
int getCurrentIndex() {
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import "dart:convert";
|
|
||||||
import "dart:math" as math;
|
|
||||||
import "package:dio/dio.dart";
|
|
||||||
import "package:easy_localization/easy_localization.dart";
|
import "package:easy_localization/easy_localization.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
@@ -9,18 +6,22 @@ import "package:riverpod_annotation/riverpod_annotation.dart";
|
|||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:island/models/thought.dart";
|
import "package:island/models/thought.dart";
|
||||||
import "package:island/pods/network.dart";
|
import "package:island/pods/network.dart";
|
||||||
import "package:island/pods/userinfo.dart";
|
|
||||||
import "package:island/widgets/alert.dart";
|
import "package:island/widgets/alert.dart";
|
||||||
import "package:island/widgets/app_scaffold.dart";
|
import "package:island/widgets/app_scaffold.dart";
|
||||||
import "package:island/widgets/response.dart";
|
import "package:island/widgets/response.dart";
|
||||||
import "package:island/widgets/thought/thought_sequence_list.dart";
|
import "package:island/widgets/thought/thought_sequence_list.dart";
|
||||||
import "package:island/widgets/thought/thought_shared.dart";
|
import "package:island/widgets/thought/thought_shared.dart";
|
||||||
import "package:material_symbols_icons/material_symbols_icons.dart";
|
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
|
||||||
import "package:collection/collection.dart";
|
|
||||||
|
|
||||||
part 'think.g.dart';
|
part 'think.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<bool> thoughtAvailableStaus(Ref ref) async {
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/insight/billing/status');
|
||||||
|
return response.data['status'] == 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
@riverpod
|
@riverpod
|
||||||
Future<List<SnThinkingThought>> thoughtSequence(
|
Future<List<SnThinkingThought>> thoughtSequence(
|
||||||
Ref ref,
|
Ref ref,
|
||||||
@@ -35,6 +36,13 @@ Future<List<SnThinkingThought>> thoughtSequence(
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<ThoughtServicesResponse> thoughtServices(Ref ref) async {
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
final response = await apiClient.get('/insight/thought/services');
|
||||||
|
return ThoughtServicesResponse.fromJson(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
class ThoughtScreen extends HookConsumerWidget {
|
class ThoughtScreen extends HookConsumerWidget {
|
||||||
const ThoughtScreen({super.key});
|
const ThoughtScreen({super.key});
|
||||||
|
|
||||||
@@ -46,203 +54,32 @@ class ThoughtScreen extends HookConsumerWidget {
|
|||||||
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
|
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
|
||||||
: const AsyncValue<List<SnThinkingThought>>.data([]);
|
: const AsyncValue<List<SnThinkingThought>>.data([]);
|
||||||
|
|
||||||
final localThoughts = useState<List<SnThinkingThought>>([]);
|
// Extract sequence ID from loaded thoughts for the chat interface
|
||||||
final currentTopic = useState<String?>('aiThought'.tr());
|
final sequenceIdFromThoughts = thoughts.maybeWhen(
|
||||||
|
data: (thoughts) {
|
||||||
final messageController = useTextEditingController();
|
if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
|
||||||
final scrollController = useScrollController();
|
return thoughts.first.sequenceId;
|
||||||
final isStreaming = useState(false);
|
|
||||||
final streamingText = useState<String>('');
|
|
||||||
final functionCalls = useState<List<String>>([]);
|
|
||||||
final reasoningChunks = useState<List<String>>([]);
|
|
||||||
|
|
||||||
final listController = useMemoized(() => ListController(), []);
|
|
||||||
|
|
||||||
// Scroll animation notifiers
|
|
||||||
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
|
||||||
|
|
||||||
// Update local thoughts when provider data changes
|
|
||||||
useEffect(() {
|
|
||||||
thoughts.whenData((data) {
|
|
||||||
// Server returns messages in DESC order (newest first), keep as-is for UI
|
|
||||||
localThoughts.value = data;
|
|
||||||
// Update topic from the first thought's sequence
|
|
||||||
if (data.isNotEmpty && data.first.sequence?.topic != null) {
|
|
||||||
currentTopic.value = data.first.sequence!.topic;
|
|
||||||
} else {
|
|
||||||
currentTopic.value = 'aiThought'.tr();
|
|
||||||
}
|
}
|
||||||
});
|
return null;
|
||||||
return null;
|
},
|
||||||
}, [thoughts]);
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
// Scroll to bottom when thoughts change or streaming state changes
|
// Get initial thoughts and topic from provider
|
||||||
useEffect(() {
|
final initialThoughts = thoughts.valueOrNull;
|
||||||
if (localThoughts.value.isNotEmpty || isStreaming.value) {
|
final initialTopic =
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
(initialThoughts?.isNotEmpty ?? false) &&
|
||||||
scrollController.animateTo(
|
initialThoughts!.first.sequence?.topic != null
|
||||||
0,
|
? initialThoughts.first.sequence!.topic
|
||||||
duration: const Duration(milliseconds: 300),
|
: 'aiThought'.tr();
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [localThoughts.value.length, isStreaming.value]);
|
|
||||||
|
|
||||||
// Add scroll listener for gradient animations
|
final statusAsync = ref.watch(thoughtAvailableStausProvider);
|
||||||
useEffect(() {
|
|
||||||
void onScroll() {
|
|
||||||
// Update gradient animations
|
|
||||||
final pixels = scrollController.position.pixels;
|
|
||||||
|
|
||||||
// Bottom gradient: appears when not at bottom (pixels > 0)
|
|
||||||
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollController.addListener(onScroll);
|
|
||||||
return () => scrollController.removeListener(onScroll);
|
|
||||||
}, [scrollController]);
|
|
||||||
|
|
||||||
void sendMessage() async {
|
|
||||||
if (messageController.text.trim().isEmpty) return;
|
|
||||||
|
|
||||||
final userMessage = messageController.text.trim();
|
|
||||||
|
|
||||||
// Add user message to local thoughts
|
|
||||||
final userInfo = ref.read(userInfoProvider);
|
|
||||||
final now = DateTime.now();
|
|
||||||
final userThought = SnThinkingThought(
|
|
||||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
content: userMessage,
|
|
||||||
files: [],
|
|
||||||
role: ThinkingThoughtRole.user,
|
|
||||||
sequenceId: selectedSequenceId.value ?? '',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
sequence:
|
|
||||||
selectedSequenceId.value != null
|
|
||||||
? thoughts.value?.firstOrNull?.sequence ??
|
|
||||||
SnThinkingSequence(
|
|
||||||
id: selectedSequenceId.value!,
|
|
||||||
accountId: '',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
)
|
|
||||||
: SnThinkingSequence(
|
|
||||||
id: '',
|
|
||||||
accountId: userInfo.value!.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
localThoughts.value = [userThought, ...localThoughts.value];
|
|
||||||
|
|
||||||
final request = StreamThinkingRequest(
|
|
||||||
userMessage: userMessage,
|
|
||||||
sequenceId: selectedSequenceId.value,
|
|
||||||
accpetProposals: ['post_create'],
|
|
||||||
attachedMessages: [], // Message datas
|
|
||||||
attachedPosts: [], // ID list for posts
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
isStreaming.value = true;
|
|
||||||
streamingText.value = '';
|
|
||||||
functionCalls.value = [];
|
|
||||||
reasoningChunks.value = [];
|
|
||||||
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
final response = await apiClient.post(
|
|
||||||
'/insight/thought',
|
|
||||||
data: request.toJson(),
|
|
||||||
options: Options(
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
sendTimeout: Duration(minutes: 1),
|
|
||||||
receiveTimeout: Duration(minutes: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final stream = response.data.stream;
|
|
||||||
final lineBuffer = StringBuffer();
|
|
||||||
|
|
||||||
stream.listen(
|
|
||||||
(data) {
|
|
||||||
final chunk = utf8.decode(data);
|
|
||||||
lineBuffer.write(chunk);
|
|
||||||
final lines = lineBuffer.toString().split('\n');
|
|
||||||
lineBuffer.clear();
|
|
||||||
lineBuffer.write(lines.last); // keep incomplete line
|
|
||||||
|
|
||||||
for (final line in lines.sublist(0, lines.length - 1)) {
|
|
||||||
if (line.trim().isEmpty) continue;
|
|
||||||
try {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
final jsonStr = line.substring(6);
|
|
||||||
final event = jsonDecode(jsonStr);
|
|
||||||
final type = event['type'];
|
|
||||||
final eventData = event['data'];
|
|
||||||
if (type == 'text') {
|
|
||||||
streamingText.value += eventData;
|
|
||||||
} else if (type == 'function_call') {
|
|
||||||
functionCalls.value = [
|
|
||||||
...functionCalls.value,
|
|
||||||
JsonEncoder.withIndent(' ').convert(eventData),
|
|
||||||
];
|
|
||||||
} else if (type == 'reasoning') {
|
|
||||||
reasoningChunks.value = [
|
|
||||||
...reasoningChunks.value,
|
|
||||||
eventData,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else if (line.startsWith('topic: ')) {
|
|
||||||
final jsonStr = line.substring(7);
|
|
||||||
final event = jsonDecode(jsonStr);
|
|
||||||
currentTopic.value = event['data'];
|
|
||||||
} else if (line.startsWith('thought: ')) {
|
|
||||||
final jsonStr = line.substring(9);
|
|
||||||
final event = jsonDecode(jsonStr);
|
|
||||||
final aiThought = SnThinkingThought.fromJson(event['data']);
|
|
||||||
localThoughts.value = [aiThought, ...localThoughts.value];
|
|
||||||
if (selectedSequenceId.value == null &&
|
|
||||||
aiThought.sequenceId.isNotEmpty) {
|
|
||||||
selectedSequenceId.value = aiThought.sequenceId;
|
|
||||||
}
|
|
||||||
isStreaming.value = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore parsing errors for individual events
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
if (isStreaming.value) {
|
|
||||||
isStreaming.value = false;
|
|
||||||
showErrorAlert('thoughtParseError'.tr());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) {
|
|
||||||
isStreaming.value = false;
|
|
||||||
if (error is DioException && error.response?.data is ResponseBody) {
|
|
||||||
showErrorAlert('toughtParseError'.tr());
|
|
||||||
} else {
|
|
||||||
showErrorAlert(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
messageController.clear();
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
|
||||||
} catch (error) {
|
|
||||||
isStreaming.value = false;
|
|
||||||
showErrorAlert(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AppScaffold(
|
return AppScaffold(
|
||||||
isNoBackground: false,
|
isNoBackground: false,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(currentTopic.value ?? 'aiThought'.tr()),
|
title: Text(initialTopic ?? 'aiThought'.tr()),
|
||||||
|
leading: const PageBackButton(),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.history),
|
icon: const Icon(Symbols.history),
|
||||||
@@ -259,137 +96,96 @@ class ThoughtScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (localThoughts.value.isNotEmpty &&
|
|
||||||
!isStreaming.value &&
|
|
||||||
localThoughts.value.last.role == ThinkingThoughtRole.assistant)
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Symbols.add),
|
|
||||||
tooltip: 'thoughtNewConversation'.tr(),
|
|
||||||
onPressed: () {
|
|
||||||
// Clear current conversation and start new one
|
|
||||||
selectedSequenceId.value = null;
|
|
||||||
localThoughts.value = [];
|
|
||||||
currentTopic.value = 'aiThought'.tr();
|
|
||||||
messageController.clear();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: statusAsync.maybeWhen(
|
||||||
children: [
|
data: (status) {
|
||||||
// Thoughts list
|
final retry = useMemoized(
|
||||||
Center(
|
() => () async {
|
||||||
child: Container(
|
showLoadingModal(context);
|
||||||
constraints: BoxConstraints(maxWidth: 640),
|
try {
|
||||||
child: Column(
|
await ref
|
||||||
|
.read(apiClientProvider)
|
||||||
|
.post('/insight/billing/retry');
|
||||||
|
showSnackBar('Retried billing process');
|
||||||
|
ref.invalidate(thoughtAvailableStausProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('Failed to retry billing');
|
||||||
|
}
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
},
|
||||||
|
[context, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
final thoughtsBody = thoughts.when(
|
||||||
|
data:
|
||||||
|
(thoughtList) => ThoughtChatInterface(
|
||||||
|
initialThoughts: thoughtList,
|
||||||
|
initialSequenceId: sequenceIdFromThoughts,
|
||||||
|
initialTopic: initialTopic,
|
||||||
|
isDisabled: !status,
|
||||||
|
),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error:
|
||||||
|
(error, _) => ResponseErrorWidget(
|
||||||
|
error: error,
|
||||||
|
onRetry:
|
||||||
|
() =>
|
||||||
|
selectedSequenceId.value != null
|
||||||
|
? ref.invalidate(
|
||||||
|
thoughtSequenceProvider(
|
||||||
|
selectedSequenceId.value!,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return status
|
||||||
|
? thoughtsBody
|
||||||
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
MaterialBanner(
|
||||||
child: thoughts.when(
|
leading: const Icon(Symbols.error),
|
||||||
data:
|
content: const Text(
|
||||||
(thoughtList) => SuperListView.builder(
|
'You have unpaid orders. Please settle your payment to continue using the service.',
|
||||||
listController: listController,
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
controller: scrollController,
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
top: 16,
|
|
||||||
bottom:
|
|
||||||
MediaQuery.of(context).padding.bottom +
|
|
||||||
80, // Leave space for thought input
|
|
||||||
),
|
|
||||||
reverse: true,
|
|
||||||
itemCount:
|
|
||||||
localThoughts.value.length +
|
|
||||||
(isStreaming.value ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (isStreaming.value && index == 0) {
|
|
||||||
return ThoughtItem(
|
|
||||||
isStreaming: true,
|
|
||||||
streamingText: streamingText.value,
|
|
||||||
reasoningChunks: reasoningChunks.value,
|
|
||||||
streamingFunctionCalls: functionCalls.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final thoughtIndex =
|
|
||||||
isStreaming.value ? index - 1 : index;
|
|
||||||
final thought = localThoughts.value[thoughtIndex];
|
|
||||||
return ThoughtItem(
|
|
||||||
thought: thought,
|
|
||||||
thoughtIndex: thoughtIndex,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
loading:
|
|
||||||
() =>
|
|
||||||
const Center(child: CircularProgressIndicator()),
|
|
||||||
error:
|
|
||||||
(error, _) => ResponseErrorWidget(
|
|
||||||
error: error,
|
|
||||||
onRetry:
|
|
||||||
() =>
|
|
||||||
selectedSequenceId.value != null
|
|
||||||
? ref.invalidate(
|
|
||||||
thoughtSequenceProvider(
|
|
||||||
selectedSequenceId.value!,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
retry();
|
||||||
|
},
|
||||||
|
child: Text('retry'.tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
Expanded(child: thoughtsBody),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
),
|
orElse:
|
||||||
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
|
() => thoughts.when(
|
||||||
AnimatedBuilder(
|
data:
|
||||||
animation: bottomGradientNotifier.value,
|
(thoughtList) => ThoughtChatInterface(
|
||||||
builder:
|
initialThoughts: thoughtList,
|
||||||
(context, child) => Positioned(
|
initialTopic: initialTopic,
|
||||||
left: 0,
|
),
|
||||||
right: 0,
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
bottom: 0,
|
error:
|
||||||
child: Opacity(
|
(error, _) => ResponseErrorWidget(
|
||||||
opacity: bottomGradientNotifier.value.value,
|
error: error,
|
||||||
child: Container(
|
onRetry:
|
||||||
height: math.min(
|
() =>
|
||||||
MediaQuery.of(context).size.height * 0.1,
|
selectedSequenceId.value != null
|
||||||
128,
|
? ref.invalidate(
|
||||||
),
|
thoughtSequenceProvider(
|
||||||
decoration: BoxDecoration(
|
selectedSequenceId.value!,
|
||||||
gradient: LinearGradient(
|
),
|
||||||
begin: Alignment.bottomCenter,
|
)
|
||||||
end: Alignment.topCenter,
|
: null,
|
||||||
colors: [
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainer.withOpacity(0.8),
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainer.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
// Thought Input positioned above gradient (higher z-index)
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0, // At the very bottom, above gradient
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(maxWidth: 640),
|
|
||||||
child: ThoughtInput(
|
|
||||||
messageController: messageController,
|
|
||||||
isStreaming: isStreaming.value,
|
|
||||||
onSend: sendMessage,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ part of 'think.dart';
|
|||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$thoughtAvailableStausHash() =>
|
||||||
|
r'720e04e56bff8c4d4ca6854ce997da4e7926c84c';
|
||||||
|
|
||||||
|
/// See also [thoughtAvailableStaus].
|
||||||
|
@ProviderFor(thoughtAvailableStaus)
|
||||||
|
final thoughtAvailableStausProvider = AutoDisposeFutureProvider<bool>.internal(
|
||||||
|
thoughtAvailableStaus,
|
||||||
|
name: r'thoughtAvailableStausProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$thoughtAvailableStausHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef ThoughtAvailableStausRef = AutoDisposeFutureProviderRef<bool>;
|
||||||
String _$thoughtSequenceHash() => r'2a93c0a04f9a720ba474c02a36502940fb7f3ed7';
|
String _$thoughtSequenceHash() => r'2a93c0a04f9a720ba474c02a36502940fb7f3ed7';
|
||||||
|
|
||||||
/// Copied from Dart SDK
|
/// Copied from Dart SDK
|
||||||
@@ -152,5 +171,25 @@ class _ThoughtSequenceProviderElement
|
|||||||
String get sequenceId => (origin as ThoughtSequenceProvider).sequenceId;
|
String get sequenceId => (origin as ThoughtSequenceProvider).sequenceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _$thoughtServicesHash() => r'0ddeaec713ecfcdc9786c197f3d4cb41d36c26a5';
|
||||||
|
|
||||||
|
/// See also [thoughtServices].
|
||||||
|
@ProviderFor(thoughtServices)
|
||||||
|
final thoughtServicesProvider =
|
||||||
|
AutoDisposeFutureProvider<ThoughtServicesResponse>.internal(
|
||||||
|
thoughtServices,
|
||||||
|
name: r'thoughtServicesProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$thoughtServicesHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef ThoughtServicesRef =
|
||||||
|
AutoDisposeFutureProviderRef<ThoughtServicesResponse>;
|
||||||
// ignore_for_file: type=lint
|
// ignore_for_file: type=lint
|
||||||
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
import "dart:convert";
|
|
||||||
import "dart:math" as math;
|
|
||||||
import "package:dio/dio.dart";
|
|
||||||
import "package:easy_localization/easy_localization.dart";
|
import "package:easy_localization/easy_localization.dart";
|
||||||
import "package:flutter/material.dart";
|
import "package:flutter/material.dart";
|
||||||
import "package:flutter_hooks/flutter_hooks.dart";
|
import "package:flutter_hooks/flutter_hooks.dart";
|
||||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||||
import "package:island/models/thought.dart";
|
|
||||||
import "package:island/pods/network.dart";
|
import "package:island/pods/network.dart";
|
||||||
import "package:island/pods/userinfo.dart";
|
import "package:island/screens/thought/think.dart";
|
||||||
import "package:island/widgets/alert.dart";
|
import "package:island/widgets/alert.dart";
|
||||||
import "package:island/widgets/content/sheet.dart";
|
import "package:island/widgets/content/sheet.dart";
|
||||||
import "package:island/widgets/thought/thought_shared.dart";
|
import "package:island/widgets/thought/thought_shared.dart";
|
||||||
import "package:super_sliver_list/super_sliver_list.dart";
|
import "package:material_symbols_icons/material_symbols_icons.dart";
|
||||||
|
|
||||||
class ThoughtSheet extends HookConsumerWidget {
|
class ThoughtSheet extends HookConsumerWidget {
|
||||||
final List<Map<String, dynamic>> attachedMessages;
|
final List<Map<String, dynamic>> attachedMessages;
|
||||||
@@ -42,275 +38,68 @@ class ThoughtSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final sequenceId = useState<String?>(null);
|
final chatState = useThoughtChat(
|
||||||
final localThoughts = useState<List<SnThinkingThought>>([]);
|
ref,
|
||||||
final currentTopic = useState<String?>('aiThought'.tr());
|
attachedMessages: attachedMessages,
|
||||||
|
attachedPosts: attachedPosts,
|
||||||
|
);
|
||||||
|
|
||||||
final messageController = useTextEditingController();
|
final statusAsync = ref.watch(thoughtAvailableStausProvider);
|
||||||
final scrollController = useScrollController();
|
|
||||||
final isStreaming = useState(false);
|
|
||||||
final streamingText = useState<String>('');
|
|
||||||
final functionCalls = useState<List<String>>([]);
|
|
||||||
final reasoningChunks = useState<List<String>>([]);
|
|
||||||
|
|
||||||
final listController = useMemoized(() => ListController(), []);
|
|
||||||
|
|
||||||
// Scroll animation notifiers
|
|
||||||
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
|
|
||||||
|
|
||||||
// Scroll to bottom when thoughts change or streaming state changes
|
|
||||||
useEffect(() {
|
|
||||||
if (localThoughts.value.isNotEmpty || isStreaming.value) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
scrollController.animateTo(
|
|
||||||
0,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [localThoughts.value.length, isStreaming.value]);
|
|
||||||
|
|
||||||
// Add scroll listener for gradient animations
|
|
||||||
useEffect(() {
|
|
||||||
void onScroll() {
|
|
||||||
// Update gradient animations
|
|
||||||
final pixels = scrollController.position.pixels;
|
|
||||||
|
|
||||||
// Bottom gradient: appears when not at bottom (pixels > 0)
|
|
||||||
bottomGradientNotifier.value.value = (pixels / 500.0).clamp(0.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollController.addListener(onScroll);
|
|
||||||
return () => scrollController.removeListener(onScroll);
|
|
||||||
}, [scrollController]);
|
|
||||||
|
|
||||||
void sendMessage() async {
|
|
||||||
if (messageController.text.trim().isEmpty) return;
|
|
||||||
|
|
||||||
final userMessage = messageController.text.trim();
|
|
||||||
|
|
||||||
// Add user message to local thoughts
|
|
||||||
final userInfo = ref.read(userInfoProvider);
|
|
||||||
final now = DateTime.now();
|
|
||||||
final userThought = SnThinkingThought(
|
|
||||||
id: 'user-${DateTime.now().millisecondsSinceEpoch}',
|
|
||||||
content: userMessage,
|
|
||||||
files: [],
|
|
||||||
role: ThinkingThoughtRole.user,
|
|
||||||
sequenceId: sequenceId.value ?? '',
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
sequence: SnThinkingSequence(
|
|
||||||
id: sequenceId.value ?? '',
|
|
||||||
accountId: userInfo.value!.id,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
localThoughts.value = [userThought, ...localThoughts.value];
|
|
||||||
|
|
||||||
final request = StreamThinkingRequest(
|
|
||||||
userMessage: userMessage,
|
|
||||||
sequenceId: sequenceId.value,
|
|
||||||
accpetProposals: ['post_create'],
|
|
||||||
attachedMessages: attachedMessages,
|
|
||||||
attachedPosts: attachedPosts,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
isStreaming.value = true;
|
|
||||||
streamingText.value = '';
|
|
||||||
functionCalls.value = [];
|
|
||||||
reasoningChunks.value = [];
|
|
||||||
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
final response = await apiClient.post(
|
|
||||||
'/insight/thought',
|
|
||||||
data: request.toJson(),
|
|
||||||
options: Options(
|
|
||||||
responseType: ResponseType.stream,
|
|
||||||
sendTimeout: Duration(minutes: 1),
|
|
||||||
receiveTimeout: Duration(minutes: 1),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final stream = response.data.stream;
|
|
||||||
final lineBuffer = StringBuffer();
|
|
||||||
|
|
||||||
stream.listen(
|
|
||||||
(data) {
|
|
||||||
final chunk = utf8.decode(data);
|
|
||||||
lineBuffer.write(chunk);
|
|
||||||
final lines = lineBuffer.toString().split('\n');
|
|
||||||
lineBuffer.clear();
|
|
||||||
lineBuffer.write(lines.last); // keep incomplete line
|
|
||||||
|
|
||||||
for (final line in lines.sublist(0, lines.length - 1)) {
|
|
||||||
if (line.trim().isEmpty) continue;
|
|
||||||
try {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
final jsonStr = line.substring(6);
|
|
||||||
final event = jsonDecode(jsonStr);
|
|
||||||
final type = event['type'];
|
|
||||||
final eventData = event['data'];
|
|
||||||
if (type == 'text') {
|
|
||||||
streamingText.value += eventData;
|
|
||||||
} else if (type == 'function_call') {
|
|
||||||
functionCalls.value = [
|
|
||||||
...functionCalls.value,
|
|
||||||
JsonEncoder.withIndent(' ').convert(eventData),
|
|
||||||
];
|
|
||||||
} else if (type == 'reasoning') {
|
|
||||||
reasoningChunks.value = [
|
|
||||||
...reasoningChunks.value,
|
|
||||||
eventData,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
} else if (line.startsWith('topic: ')) {
|
|
||||||
final jsonStr = line.substring(7);
|
|
||||||
final event = jsonDecode(jsonStr);
|
|
||||||
currentTopic.value = event['data'];
|
|
||||||
} else if (line.startsWith('thought: ')) {
|
|
||||||
final jsonStr = line.substring(9);
|
|
||||||
final event = jsonDecode(jsonStr);
|
|
||||||
final aiThought = SnThinkingThought.fromJson(event['data']);
|
|
||||||
localThoughts.value = [aiThought, ...localThoughts.value];
|
|
||||||
if (sequenceId.value == null &&
|
|
||||||
aiThought.sequenceId.isNotEmpty) {
|
|
||||||
sequenceId.value = aiThought.sequenceId;
|
|
||||||
}
|
|
||||||
isStreaming.value = false;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore parsing errors for individual events
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone: () {
|
|
||||||
if (isStreaming.value) {
|
|
||||||
isStreaming.value = false;
|
|
||||||
showErrorAlert('thoughtParseError'.tr());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error) {
|
|
||||||
isStreaming.value = false;
|
|
||||||
if (error is DioException && error.response?.data is ResponseBody) {
|
|
||||||
showErrorAlert('toughtParseError'.tr());
|
|
||||||
} else {
|
|
||||||
showErrorAlert(error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
messageController.clear();
|
|
||||||
FocusManager.instance.primaryFocus?.unfocus();
|
|
||||||
} catch (error) {
|
|
||||||
isStreaming.value = false;
|
|
||||||
showErrorAlert(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return SheetScaffold(
|
return SheetScaffold(
|
||||||
titleText: currentTopic.value ?? 'aiThought'.tr(),
|
titleText: chatState.currentTopic.value ?? 'aiThought'.tr(),
|
||||||
child: Stack(
|
child: statusAsync.maybeWhen(
|
||||||
children: [
|
data: (status) {
|
||||||
// Thoughts list
|
final retry = useMemoized(
|
||||||
Center(
|
() => () async {
|
||||||
child: Container(
|
showLoadingModal(context);
|
||||||
constraints: BoxConstraints(maxWidth: 640),
|
try {
|
||||||
child: Column(
|
await ref
|
||||||
|
.read(apiClientProvider)
|
||||||
|
.post('/insight/billing/retry');
|
||||||
|
showSnackBar('Retried billing process');
|
||||||
|
ref.invalidate(thoughtAvailableStausProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('Failed to retry billing');
|
||||||
|
}
|
||||||
|
if (context.mounted) hideLoadingModal(context);
|
||||||
|
},
|
||||||
|
[context, ref],
|
||||||
|
);
|
||||||
|
|
||||||
|
final chatInterface = ThoughtChatInterface(
|
||||||
|
attachedMessages: attachedMessages,
|
||||||
|
attachedPosts: attachedPosts,
|
||||||
|
isDisabled: !status,
|
||||||
|
);
|
||||||
|
return status
|
||||||
|
? chatInterface
|
||||||
|
: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
MaterialBanner(
|
||||||
child: SuperListView.builder(
|
leading: const Icon(Symbols.error),
|
||||||
listController: listController,
|
content: const Text(
|
||||||
controller: scrollController,
|
'You have unpaid orders. Please settle your payment to continue using the service.',
|
||||||
padding: EdgeInsets.only(
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
top: 16,
|
|
||||||
bottom:
|
|
||||||
MediaQuery.of(context).padding.bottom +
|
|
||||||
80, // Leave space for thought input
|
|
||||||
),
|
|
||||||
reverse: true,
|
|
||||||
itemCount:
|
|
||||||
localThoughts.value.length +
|
|
||||||
(isStreaming.value ? 1 : 0),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
if (isStreaming.value && index == 0) {
|
|
||||||
return ThoughtItem(
|
|
||||||
isStreaming: true,
|
|
||||||
streamingText: streamingText.value,
|
|
||||||
reasoningChunks: reasoningChunks.value,
|
|
||||||
streamingFunctionCalls: functionCalls.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final thoughtIndex =
|
|
||||||
isStreaming.value ? index - 1 : index;
|
|
||||||
final thought = localThoughts.value[thoughtIndex];
|
|
||||||
return ThoughtItem(
|
|
||||||
thought: thought,
|
|
||||||
thoughtIndex: thoughtIndex,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
retry();
|
||||||
|
},
|
||||||
|
child: Text('retry'.tr()),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
Expanded(child: chatInterface),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
orElse:
|
||||||
|
() => ThoughtChatInterface(
|
||||||
|
attachedMessages: attachedMessages,
|
||||||
|
attachedPosts: attachedPosts,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// Bottom gradient - appears when scrolling towards newer thoughts (behind thought input)
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: bottomGradientNotifier.value,
|
|
||||||
builder:
|
|
||||||
(context, child) => Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: Opacity(
|
|
||||||
opacity: bottomGradientNotifier.value.value,
|
|
||||||
child: Container(
|
|
||||||
height: math.min(
|
|
||||||
MediaQuery.of(context).size.height * 0.1,
|
|
||||||
128,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.bottomCenter,
|
|
||||||
end: Alignment.topCenter,
|
|
||||||
colors: [
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainer.withOpacity(0.8),
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainer.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Thought Input positioned above gradient (higher z-index)
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0, // At the very bottom, above gradient
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
constraints: BoxConstraints(maxWidth: 640),
|
|
||||||
child: ThoughtInput(
|
|
||||||
messageController: messageController,
|
|
||||||
isStreaming: isStreaming.value,
|
|
||||||
onSend: sendMessage,
|
|
||||||
attachedMessages: attachedMessages,
|
|
||||||
attachedPosts: attachedPosts,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ class TrayService {
|
|||||||
await trayManager.setIcon(
|
await trayManager.setIcon(
|
||||||
Platform.isWindows
|
Platform.isWindows
|
||||||
? 'assets/icons/icon.ico'
|
? 'assets/icons/icon.ico'
|
||||||
: 'assets/icons/icon-outline.svg',
|
: 'assets/icons/icon-tray.png',
|
||||||
|
isTemplate: Platform.isMacOS,
|
||||||
);
|
);
|
||||||
|
|
||||||
final menu = Menu(
|
final menu = Menu(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ class CreateFundSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _CreateFundSheetState extends State<CreateFundSheet> {
|
class _CreateFundSheetState extends State<CreateFundSheet> {
|
||||||
final amountController = TextEditingController();
|
final amountController = TextEditingController();
|
||||||
|
final splitsController = TextEditingController(text: '1');
|
||||||
final messageController = TextEditingController();
|
final messageController = TextEditingController();
|
||||||
String selectedCurrency = 'golds';
|
String selectedCurrency = 'golds';
|
||||||
int selectedSplitType = 0; // 0: even, 1: random
|
int selectedSplitType = 0; // 0: even, 1: random
|
||||||
@@ -64,6 +65,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
amountController.dispose();
|
amountController.dispose();
|
||||||
messageController.dispose();
|
messageController.dispose();
|
||||||
|
splitsController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,17 +105,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
labelText: 'enterAmount'.tr(),
|
labelText: 'enterAmount'.tr(),
|
||||||
hintText: '0.00',
|
hintText: '0.00',
|
||||||
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
|
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
enabledBorder: OutlineInputBorder(
|
borderRadius: const BorderRadius.all(
|
||||||
borderSide: BorderSide(
|
Radius.circular(12),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -136,17 +130,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: selectedCurrency,
|
value: selectedCurrency,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
enabledBorder: OutlineInputBorder(
|
borderRadius: const BorderRadius.all(
|
||||||
borderSide: BorderSide(
|
Radius.circular(12),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -173,49 +159,84 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Split Type Section (only show when there are 2+ recipients)
|
const Gap(16),
|
||||||
if (selectedRecipients.length >= 2) ...[
|
|
||||||
const Gap(16),
|
// Amount of Splits Section
|
||||||
Text(
|
Text(
|
||||||
'splitType'.tr(),
|
'amountOfSplits'.tr(),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
TextField(
|
||||||
|
controller: splitsController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'enterNumberOfSplits'.tr(),
|
||||||
|
hintText:
|
||||||
|
selectedRecipients.isNotEmpty
|
||||||
|
? selectedRecipients.length.toString()
|
||||||
|
: '1',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Gap(8),
|
onTapOutside:
|
||||||
Row(
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
children: [
|
onChanged: (value) {
|
||||||
Expanded(
|
if (value.isEmpty && selectedRecipients.isNotEmpty) {
|
||||||
child: RadioListTile<int>(
|
splitsController.text =
|
||||||
title: Text('evenSplit'.tr()),
|
selectedRecipients.length.toString();
|
||||||
subtitle: Text('equalAmountEach'.tr()),
|
}
|
||||||
value: 0,
|
},
|
||||||
groupValue: selectedSplitType,
|
),
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
const Gap(16),
|
||||||
setState(() => selectedSplitType = value);
|
Text(
|
||||||
}
|
'splitType'.tr(),
|
||||||
},
|
style: TextStyle(
|
||||||
),
|
fontSize: 16,
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
Expanded(
|
color: Theme.of(context).colorScheme.primary,
|
||||||
child: RadioListTile<int>(
|
|
||||||
title: Text('randomSplit'.tr()),
|
|
||||||
subtitle: Text('randomAmountEach'.tr()),
|
|
||||||
value: 1,
|
|
||||||
groupValue: selectedSplitType,
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
setState(() => selectedSplitType = value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<int>(
|
||||||
|
title: Text('evenSplit'.tr()),
|
||||||
|
subtitle: Text('equalAmountEach'.tr()),
|
||||||
|
value: 0,
|
||||||
|
groupValue: selectedSplitType,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() => selectedSplitType = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: RadioListTile<int>(
|
||||||
|
title: Text('randomSplit'.tr()),
|
||||||
|
subtitle: Text('randomAmountEach'.tr()),
|
||||||
|
value: 1,
|
||||||
|
groupValue: selectedSplitType,
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() => selectedSplitType = value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
|
|
||||||
@@ -370,17 +391,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
labelText: 'personalMessage'.tr(),
|
labelText: 'personalMessage'.tr(),
|
||||||
hintText: 'addPersonalMessageForRecipients'.tr(),
|
hintText: 'addPersonalMessageForRecipients'.tr(),
|
||||||
alignLabelWithHint: true,
|
alignLabelWithHint: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
enabledBorder: OutlineInputBorder(
|
borderRadius: const BorderRadius.all(
|
||||||
borderSide: BorderSide(
|
Radius.circular(12),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -520,14 +533,15 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
|
|
||||||
Future<void> _createFund() async {
|
Future<void> _createFund() async {
|
||||||
final amount = double.tryParse(amountController.text);
|
final amount = double.tryParse(amountController.text);
|
||||||
|
final splits = int.tryParse(splitsController.text);
|
||||||
|
|
||||||
if (amount == null || amount <= 0) {
|
if (amount == null || amount <= 0) {
|
||||||
showErrorAlert('invalidAmount'.tr());
|
showErrorAlert('invalidAmount'.tr());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedRecipients.isEmpty) {
|
if (splits == null || splits <= 0) {
|
||||||
showErrorAlert('noRecipientsSelected'.tr());
|
showErrorAlert('invalidNumberOfSplits'.tr());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,6 +549,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
|
|||||||
'currency': selectedCurrency,
|
'currency': selectedCurrency,
|
||||||
'total_amount': amount,
|
'total_amount': amount,
|
||||||
'split_type': selectedSplitType,
|
'split_type': selectedSplitType,
|
||||||
|
'amount_of_splits': splits,
|
||||||
'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(),
|
'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(),
|
||||||
'message':
|
'message':
|
||||||
messageController.text.trim().isEmpty
|
messageController.text.trim().isEmpty
|
||||||
@@ -610,17 +625,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
|
|||||||
labelText: 'enterAmount'.tr(),
|
labelText: 'enterAmount'.tr(),
|
||||||
hintText: '0.00',
|
hintText: '0.00',
|
||||||
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
|
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
enabledBorder: OutlineInputBorder(
|
borderRadius: const BorderRadius.all(
|
||||||
borderSide: BorderSide(
|
Radius.circular(12),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -643,17 +650,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
|
|||||||
DropdownButtonFormField<String>(
|
DropdownButtonFormField<String>(
|
||||||
value: selectedCurrency,
|
value: selectedCurrency,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
enabledBorder: OutlineInputBorder(
|
borderRadius: const BorderRadius.all(
|
||||||
borderSide: BorderSide(
|
Radius.circular(12),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -817,17 +816,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
|
|||||||
labelText: 'transferRemark'.tr(),
|
labelText: 'transferRemark'.tr(),
|
||||||
hintText: 'addRemarkForTransfer'.tr(),
|
hintText: 'addRemarkForTransfer'.tr(),
|
||||||
alignLabelWithHint: true,
|
alignLabelWithHint: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
enabledBorder: OutlineInputBorder(
|
borderRadius: const BorderRadius.all(
|
||||||
borderSide: BorderSide(
|
Radius.circular(12),
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.outline.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1863,6 +1854,6 @@ class WalletScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const Map<String, IconData> kCurrencyIconData = {
|
const Map<String, IconData> kCurrencyIconData = {
|
||||||
'points': Symbols.toll,
|
'points': Symbols.bolt,
|
||||||
'golds': Symbols.attach_money,
|
'golds': Symbols.diamond,
|
||||||
};
|
};
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -16,3 +16,10 @@ class PostCreatedEvent {
|
|||||||
class ChatRoomsRefreshEvent {
|
class ChatRoomsRefreshEvent {
|
||||||
const ChatRoomsRefreshEvent();
|
const ChatRoomsRefreshEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Event fired when OIDC auth callback is received
|
||||||
|
class OidcAuthCallbackEvent {
|
||||||
|
final String challengeId;
|
||||||
|
|
||||||
|
const OidcAuthCallbackEvent(this.challengeId);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'package:convert/convert.dart';
|
||||||
import 'package:cross_file/cross_file.dart';
|
import 'package:cross_file/cross_file.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/upload_tasks.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:native_exif/native_exif.dart';
|
import 'package:native_exif/native_exif.dart';
|
||||||
import 'package:path/path.dart' show extension;
|
import 'package:path/path.dart' show extension;
|
||||||
@@ -16,16 +17,57 @@ class FileUploader {
|
|||||||
|
|
||||||
FileUploader(this._client);
|
FileUploader(this._client);
|
||||||
|
|
||||||
/// Calculates the MD5 hash of a file.
|
/// Calculates the MD5 hash of file bytes.
|
||||||
Future<String> _calculateFileHash(XFile file) async {
|
String _calculateFileHash(Uint8List bytes) {
|
||||||
final bytes = await file.readAsBytes();
|
|
||||||
final digest = md5.convert(bytes);
|
final digest = md5.convert(bytes);
|
||||||
return digest.toString();
|
return digest.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Calculates the MD5 hash from a stream.
|
||||||
|
Future<String> _calculateFileHashFromStream(Stream<List<int>> stream) async {
|
||||||
|
final accumulator = AccumulatorSink<Digest>();
|
||||||
|
final converter = md5.startChunkedConversion(accumulator);
|
||||||
|
await for (final chunk in stream) {
|
||||||
|
converter.add(chunk);
|
||||||
|
}
|
||||||
|
converter.close();
|
||||||
|
final digest = accumulator.events.single;
|
||||||
|
return digest.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads the next chunk from a stream subscription.
|
||||||
|
Future<Uint8List> _readNextChunk(
|
||||||
|
StreamSubscription<List<int>> subscription,
|
||||||
|
int size,
|
||||||
|
) async {
|
||||||
|
final completer = Completer<Uint8List>();
|
||||||
|
final buffer = <int>[];
|
||||||
|
int remaining = size;
|
||||||
|
|
||||||
|
void onData(List<int> data) {
|
||||||
|
buffer.addAll(data);
|
||||||
|
remaining -= data.length;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
subscription.pause();
|
||||||
|
completer.complete(Uint8List.fromList(buffer.sublist(0, size)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void onDone() {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(Uint8List.fromList(buffer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription.onData(onData);
|
||||||
|
subscription.onDone(onDone);
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
/// Creates an upload task for the given file.
|
/// Creates an upload task for the given file.
|
||||||
Future<Map<String, dynamic>> createUploadTask({
|
Future<Map<String, dynamic>> createUploadTask({
|
||||||
required XFile file,
|
required dynamic fileData,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
required String contentType,
|
required String contentType,
|
||||||
String? poolId,
|
String? poolId,
|
||||||
@@ -33,9 +75,19 @@ class FileUploader {
|
|||||||
String? encryptPassword,
|
String? encryptPassword,
|
||||||
String? expiredAt,
|
String? expiredAt,
|
||||||
int? chunkSize,
|
int? chunkSize,
|
||||||
|
String? path,
|
||||||
}) async {
|
}) async {
|
||||||
final hash = await _calculateFileHash(file);
|
String hash;
|
||||||
final fileSize = await file.length();
|
int fileSize;
|
||||||
|
if (fileData is XFile) {
|
||||||
|
fileSize = await fileData.length();
|
||||||
|
hash = await _calculateFileHashFromStream(fileData.openRead());
|
||||||
|
} else if (fileData is Uint8List) {
|
||||||
|
hash = _calculateFileHash(fileData);
|
||||||
|
fileSize = fileData.length;
|
||||||
|
} else {
|
||||||
|
throw ArgumentError('Invalid fileData type');
|
||||||
|
}
|
||||||
|
|
||||||
final response = await _client.post(
|
final response = await _client.post(
|
||||||
'/drive/files/upload/create',
|
'/drive/files/upload/create',
|
||||||
@@ -49,6 +101,7 @@ class FileUploader {
|
|||||||
'encrypt_password': encryptPassword,
|
'encrypt_password': encryptPassword,
|
||||||
'expired_at': expiredAt,
|
'expired_at': expiredAt,
|
||||||
'chunk_size': chunkSize,
|
'chunk_size': chunkSize,
|
||||||
|
'path': path,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -60,6 +113,7 @@ class FileUploader {
|
|||||||
required String taskId,
|
required String taskId,
|
||||||
required int chunkIndex,
|
required int chunkIndex,
|
||||||
required Uint8List chunkData,
|
required Uint8List chunkData,
|
||||||
|
ProgressCallback? onSendProgress,
|
||||||
}) async {
|
}) async {
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'chunk': MultipartFile.fromBytes(
|
'chunk': MultipartFile.fromBytes(
|
||||||
@@ -71,19 +125,26 @@ class FileUploader {
|
|||||||
await _client.post(
|
await _client.post(
|
||||||
'/drive/files/upload/chunk/$taskId/$chunkIndex',
|
'/drive/files/upload/chunk/$taskId/$chunkIndex',
|
||||||
data: formData,
|
data: formData,
|
||||||
|
onSendProgress: onSendProgress,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Completes the upload and returns the CloudFile object.
|
/// Completes the upload and returns the CloudFile object.
|
||||||
Future<SnCloudFile> completeUpload(String taskId) async {
|
Future<SnCloudFile> completeUpload(String taskId) async {
|
||||||
final response = await _client.post('/drive/files/upload/complete/$taskId');
|
final response = await _client.post(
|
||||||
|
'/drive/files/upload/complete/$taskId',
|
||||||
|
options: Options(
|
||||||
|
sendTimeout: Duration(minutes: 1),
|
||||||
|
receiveTimeout: Duration(minutes: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return SnCloudFile.fromJson(response.data);
|
return SnCloudFile.fromJson(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uploads a file in chunks using the multi-part API.
|
/// Uploads a file in chunks using the multi-part API.
|
||||||
Future<SnCloudFile> uploadFile({
|
Future<SnCloudFile> uploadFile({
|
||||||
required XFile file,
|
required dynamic fileData,
|
||||||
required String fileName,
|
required String fileName,
|
||||||
required String contentType,
|
required String contentType,
|
||||||
String? poolId,
|
String? poolId,
|
||||||
@@ -91,10 +152,13 @@ class FileUploader {
|
|||||||
String? encryptPassword,
|
String? encryptPassword,
|
||||||
String? expiredAt,
|
String? expiredAt,
|
||||||
int? customChunkSize,
|
int? customChunkSize,
|
||||||
|
String? path,
|
||||||
|
Function(double? progress, Duration estimate)? onProgress,
|
||||||
}) async {
|
}) async {
|
||||||
// Step 1: Create upload task
|
// Step 1: Create upload task
|
||||||
|
onProgress?.call(null, Duration.zero);
|
||||||
final createResponse = await createUploadTask(
|
final createResponse = await createUploadTask(
|
||||||
file: file,
|
fileData: fileData,
|
||||||
fileName: fileName,
|
fileName: fileName,
|
||||||
contentType: contentType,
|
contentType: contentType,
|
||||||
poolId: poolId,
|
poolId: poolId,
|
||||||
@@ -102,6 +166,7 @@ class FileUploader {
|
|||||||
encryptPassword: encryptPassword,
|
encryptPassword: encryptPassword,
|
||||||
expiredAt: expiredAt,
|
expiredAt: expiredAt,
|
||||||
chunkSize: customChunkSize,
|
chunkSize: customChunkSize,
|
||||||
|
path: path,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (createResponse['file_exists'] == true) {
|
if (createResponse['file_exists'] == true) {
|
||||||
@@ -112,50 +177,74 @@ class FileUploader {
|
|||||||
final taskId = createResponse['task_id'] as String;
|
final taskId = createResponse['task_id'] as String;
|
||||||
final chunkSize = createResponse['chunk_size'] as int;
|
final chunkSize = createResponse['chunk_size'] as int;
|
||||||
final chunksCount = createResponse['chunks_count'] as int;
|
final chunksCount = createResponse['chunks_count'] as int;
|
||||||
|
int totalSize;
|
||||||
|
if (fileData is XFile) {
|
||||||
|
totalSize = await fileData.length();
|
||||||
|
} else if (fileData is Uint8List) {
|
||||||
|
totalSize = fileData.length;
|
||||||
|
} else {
|
||||||
|
throw ArgumentError('Invalid fileData type');
|
||||||
|
}
|
||||||
|
|
||||||
// Step 2: Upload chunks
|
// Step 2: Upload chunks
|
||||||
final stream = file.openRead();
|
int bytesUploaded = 0;
|
||||||
final chunks = <Uint8List>[];
|
if (fileData is XFile) {
|
||||||
int bytesRead = 0;
|
// Use stream for XFile
|
||||||
final buffer = BytesBuilder();
|
final subscription = fileData.openRead().listen(null);
|
||||||
|
subscription.pause();
|
||||||
await for (final chunk in stream) {
|
for (int i = 0; i < chunksCount; i++) {
|
||||||
buffer.add(chunk);
|
subscription.resume();
|
||||||
bytesRead += chunk.length;
|
final chunkData = await _readNextChunk(subscription, chunkSize);
|
||||||
|
await uploadChunk(
|
||||||
if (bytesRead >= chunkSize) {
|
taskId: taskId,
|
||||||
chunks.add(buffer.takeBytes());
|
chunkIndex: i,
|
||||||
bytesRead = 0;
|
chunkData: chunkData,
|
||||||
|
onSendProgress: (sent, total) {
|
||||||
|
final overallProgress = (bytesUploaded + sent) / totalSize;
|
||||||
|
onProgress?.call(overallProgress, Duration.zero);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
bytesUploaded += chunkData.length;
|
||||||
|
}
|
||||||
|
subscription.cancel();
|
||||||
|
} else if (fileData is Uint8List) {
|
||||||
|
// Use old way for Uint8List
|
||||||
|
final chunks = <Uint8List>[];
|
||||||
|
for (int i = 0; i < fileData.length; i += chunkSize) {
|
||||||
|
final end =
|
||||||
|
i + chunkSize > fileData.length ? fileData.length : i + chunkSize;
|
||||||
|
chunks.add(Uint8List.fromList(fileData.sublist(i, end)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Add remaining bytes as last chunk
|
// Upload each chunk
|
||||||
if (buffer.length > 0) {
|
for (int i = 0; i < chunks.length; i++) {
|
||||||
chunks.add(buffer.takeBytes());
|
await uploadChunk(
|
||||||
}
|
taskId: taskId,
|
||||||
|
chunkIndex: i,
|
||||||
// Ensure we have the correct number of chunks
|
chunkData: chunks[i],
|
||||||
if (chunks.length != chunksCount) {
|
onSendProgress: (sent, total) {
|
||||||
throw Exception(
|
final overallProgress = (bytesUploaded + sent) / totalSize;
|
||||||
'Chunk count mismatch: expected $chunksCount, got ${chunks.length}',
|
onProgress?.call(overallProgress, Duration.zero);
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
|
bytesUploaded += chunks[i].length;
|
||||||
// Upload each chunk
|
}
|
||||||
for (int i = 0; i < chunks.length; i++) {
|
} else {
|
||||||
await uploadChunk(taskId: taskId, chunkIndex: i, chunkData: chunks[i]);
|
throw ArgumentError('Invalid fileData type');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Complete upload
|
// Step 3: Complete upload
|
||||||
|
onProgress?.call(null, Duration.zero);
|
||||||
return await completeUpload(taskId);
|
return await completeUpload(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Completer<SnCloudFile?> createCloudFile({
|
static Completer<SnCloudFile?> createCloudFile({
|
||||||
required UniversalFile fileData,
|
required UniversalFile fileData,
|
||||||
required Dio client,
|
required WidgetRef ref,
|
||||||
String? poolId,
|
String? poolId,
|
||||||
|
String? path,
|
||||||
FileUploadMode? mode,
|
FileUploadMode? mode,
|
||||||
Function(double progress, Duration estimate)? onProgress,
|
Function(double? progress, Duration estimate)? onProgress,
|
||||||
}) {
|
}) {
|
||||||
final completer = Completer<SnCloudFile?>();
|
final completer = Completer<SnCloudFile?>();
|
||||||
|
|
||||||
@@ -191,8 +280,9 @@ class FileUploader {
|
|||||||
.then(
|
.then(
|
||||||
(_) => _processUpload(
|
(_) => _processUpload(
|
||||||
fileData,
|
fileData,
|
||||||
client,
|
ref,
|
||||||
poolId,
|
poolId,
|
||||||
|
path,
|
||||||
onProgress,
|
onProgress,
|
||||||
completer,
|
completer,
|
||||||
),
|
),
|
||||||
@@ -201,8 +291,9 @@ class FileUploader {
|
|||||||
debugPrint('Error removing GPS EXIF data: $e');
|
debugPrint('Error removing GPS EXIF data: $e');
|
||||||
return _processUpload(
|
return _processUpload(
|
||||||
fileData,
|
fileData,
|
||||||
client,
|
ref,
|
||||||
poolId,
|
poolId,
|
||||||
|
path,
|
||||||
onProgress,
|
onProgress,
|
||||||
completer,
|
completer,
|
||||||
);
|
);
|
||||||
@@ -212,33 +303,41 @@ class FileUploader {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_processUpload(fileData, client, poolId, onProgress, completer);
|
_processUpload(fileData, ref, poolId, path, onProgress, completer);
|
||||||
return completer;
|
return completer;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to process the upload
|
// Helper method to process the upload with enhanced uploader
|
||||||
static Completer<SnCloudFile?> _processUpload(
|
static Completer<SnCloudFile?> _processUpload(
|
||||||
UniversalFile fileData,
|
UniversalFile fileData,
|
||||||
Dio client,
|
WidgetRef ref,
|
||||||
String? poolId,
|
String? poolId,
|
||||||
Function(double progress, Duration estimate)? onProgress,
|
String? path,
|
||||||
|
Function(double? progress, Duration estimate)? onProgress,
|
||||||
Completer<SnCloudFile?> completer,
|
Completer<SnCloudFile?> completer,
|
||||||
) {
|
) {
|
||||||
String actualMimetype = getMimeType(fileData);
|
String actualMimetype = getMimeType(fileData);
|
||||||
late XFile file;
|
|
||||||
String actualFilename = fileData.displayName ?? 'randomly_file';
|
String actualFilename = fileData.displayName ?? 'randomly_file';
|
||||||
Uint8List? byteData;
|
Uint8List? bytes;
|
||||||
|
|
||||||
// Handle the data based on what's in the UniversalFile
|
// Handle the data based on what's in the UniversalFile
|
||||||
final data = fileData.data;
|
final data = fileData.data;
|
||||||
|
|
||||||
if (data is XFile) {
|
if (data is XFile) {
|
||||||
file = data;
|
_performUpload(
|
||||||
actualFilename = fileData.displayName ?? data.name;
|
fileData: data,
|
||||||
|
fileName: fileData.displayName ?? data.name,
|
||||||
|
path: path,
|
||||||
|
contentType: actualMimetype,
|
||||||
|
ref: ref,
|
||||||
|
poolId: poolId,
|
||||||
|
onProgress: onProgress,
|
||||||
|
completer: completer,
|
||||||
|
);
|
||||||
|
return completer;
|
||||||
} else if (data is List<int> || data is Uint8List) {
|
} else if (data is List<int> || data is Uint8List) {
|
||||||
byteData = data is List<int> ? Uint8List.fromList(data) : data;
|
bytes = data is List<int> ? Uint8List.fromList(data) : data;
|
||||||
actualFilename = fileData.displayName ?? 'uploaded_file';
|
actualFilename = fileData.displayName ?? 'uploaded_file';
|
||||||
file = XFile.fromData(byteData!, mimeType: actualMimetype);
|
|
||||||
} else if (data is SnCloudFile) {
|
} else if (data is SnCloudFile) {
|
||||||
// If the file is already on the cloud, just return it
|
// If the file is already on the cloud, just return it
|
||||||
completer.complete(data);
|
completer.complete(data);
|
||||||
@@ -252,28 +351,56 @@ class FileUploader {
|
|||||||
return completer;
|
return completer;
|
||||||
}
|
}
|
||||||
|
|
||||||
final uploader = FileUploader(client);
|
if (bytes != null) {
|
||||||
|
_performUpload(
|
||||||
|
fileData: bytes,
|
||||||
|
fileName: actualFilename,
|
||||||
|
contentType: actualMimetype,
|
||||||
|
path: path,
|
||||||
|
ref: ref,
|
||||||
|
poolId: poolId,
|
||||||
|
onProgress: onProgress,
|
||||||
|
completer: completer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return completer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper method to perform the actual upload with enhanced uploader
|
||||||
|
static void _performUpload({
|
||||||
|
required dynamic fileData,
|
||||||
|
required String fileName,
|
||||||
|
required String contentType,
|
||||||
|
required WidgetRef ref,
|
||||||
|
String? poolId,
|
||||||
|
String? path,
|
||||||
|
Function(double? progress, Duration estimate)? onProgress,
|
||||||
|
required Completer<SnCloudFile?> completer,
|
||||||
|
}) {
|
||||||
|
// Use the enhanced uploader with task tracking
|
||||||
|
final uploader = ref.read(enhancedFileUploaderProvider);
|
||||||
|
|
||||||
// Call progress start
|
// Call progress start
|
||||||
onProgress?.call(0.0, Duration.zero);
|
onProgress?.call(null, Duration.zero);
|
||||||
uploader
|
uploader
|
||||||
.uploadFile(
|
.uploadFile(
|
||||||
file: file,
|
fileData: fileData,
|
||||||
fileName: actualFilename,
|
fileName: fileName,
|
||||||
contentType: actualMimetype,
|
contentType: contentType,
|
||||||
poolId: poolId,
|
poolId: poolId,
|
||||||
|
path: path,
|
||||||
|
onProgress: onProgress,
|
||||||
)
|
)
|
||||||
.then((result) {
|
.then((result) {
|
||||||
// Call progress end
|
// Call progress end
|
||||||
onProgress?.call(1.0, Duration.zero);
|
onProgress?.call(null, Duration.zero);
|
||||||
completer.complete(result);
|
completer.complete(result);
|
||||||
})
|
})
|
||||||
.catchError((e) {
|
.catchError((e) {
|
||||||
completer.completeError(e);
|
completer.completeError(e);
|
||||||
throw e;
|
throw e;
|
||||||
});
|
});
|
||||||
|
|
||||||
return completer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the MIME type of a UniversalFile.
|
/// Gets the MIME type of a UniversalFile.
|
||||||
|
|||||||
66
lib/utils/file_icon_utils.dart
Normal file
66
lib/utils/file_icon_utils.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
import '../models/file.dart';
|
||||||
|
import '../widgets/content/cloud_files.dart';
|
||||||
|
|
||||||
|
/// Returns an appropriate icon widget for the given file based on its MIME type
|
||||||
|
Widget getFileIcon(
|
||||||
|
SnCloudFile file, {
|
||||||
|
required double size,
|
||||||
|
bool tinyPreview = true,
|
||||||
|
}) {
|
||||||
|
final itemType = file.mimeType?.split('/').firstOrNull;
|
||||||
|
final mimeType = file.mimeType ?? '';
|
||||||
|
final extension = file.name.split('.').lastOrNull?.toLowerCase() ?? '';
|
||||||
|
|
||||||
|
// For images, show the actual image thumbnail
|
||||||
|
if (itemType == 'image' && tinyPreview) {
|
||||||
|
return CloudImageWidget(file: file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return icon based on MIME type or file extension
|
||||||
|
final icon = switch ((itemType, mimeType, extension)) {
|
||||||
|
('image', _, _) => Symbols.image,
|
||||||
|
('audio', _, _) => Symbols.audio_file,
|
||||||
|
('video', _, _) => Symbols.video_file,
|
||||||
|
('application', 'application/pdf', _) => Symbols.picture_as_pdf,
|
||||||
|
('application', 'application/zip', _) => Symbols.archive,
|
||||||
|
('application', 'application/x-rar-compressed', _) => Symbols.archive,
|
||||||
|
(
|
||||||
|
'application',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
_,
|
||||||
|
) ||
|
||||||
|
('application', 'application/msword', _) => Symbols.description,
|
||||||
|
(
|
||||||
|
'application',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
_,
|
||||||
|
) ||
|
||||||
|
('application', 'application/vnd.ms-excel', _) => Symbols.table_chart,
|
||||||
|
(
|
||||||
|
'application',
|
||||||
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
_,
|
||||||
|
) ||
|
||||||
|
('application', 'application/vnd.ms-powerpoint', _) => Symbols.slideshow,
|
||||||
|
('text', _, _) => Symbols.article,
|
||||||
|
('application', _, 'js') ||
|
||||||
|
('application', _, 'dart') ||
|
||||||
|
('application', _, 'py') ||
|
||||||
|
('application', _, 'java') ||
|
||||||
|
('application', _, 'cpp') ||
|
||||||
|
('application', _, 'c') ||
|
||||||
|
('application', _, 'cs') => Symbols.code,
|
||||||
|
('application', _, 'json') ||
|
||||||
|
('application', _, 'xml') => Symbols.data_object,
|
||||||
|
(_, _, 'md') => Symbols.article,
|
||||||
|
(_, _, 'html') => Symbols.web,
|
||||||
|
(_, _, 'css') => Symbols.css,
|
||||||
|
_ => Symbols.description, // Default icon
|
||||||
|
};
|
||||||
|
|
||||||
|
return Icon(icon, size: size, fill: 1).center();
|
||||||
|
}
|
||||||
@@ -5,5 +5,11 @@ String formatFileSize(int bytes) {
|
|||||||
if (bytes < 1024 * 1024 * 1024) {
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
||||||
}
|
}
|
||||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
if (bytes < 1024 * 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024 * 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} TB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024 * 1024 * 1024)).toStringAsFixed(2)} PB';
|
||||||
}
|
}
|
||||||
|
|||||||
208
lib/widgets/account/friends_overview.dart
Normal file
208
lib/widgets/account/friends_overview.dart
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:island/models/account.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/widgets/account/account_pfc.dart';
|
||||||
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
|
||||||
|
part 'friends_overview.g.dart';
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
Future<List<SnFriendOverviewItem>> friendsOverview(Ref ref) async {
|
||||||
|
final apiClient = ref.watch(apiClientProvider);
|
||||||
|
final resp = await apiClient.get('/pass/friends/overview');
|
||||||
|
return (resp.data as List<dynamic>)
|
||||||
|
.map((e) => SnFriendOverviewItem.fromJson(e))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FriendsOverviewWidget extends HookConsumerWidget {
|
||||||
|
final bool hideWhenEmpty;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
|
||||||
|
const FriendsOverviewWidget({
|
||||||
|
super.key,
|
||||||
|
this.hideWhenEmpty = false,
|
||||||
|
this.padding,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
// Set up periodic refresh every minute
|
||||||
|
useEffect(() {
|
||||||
|
final timer = Timer.periodic(const Duration(minutes: 1), (_) {
|
||||||
|
ref.invalidate(friendsOverviewProvider);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => timer.cancel(); // Cleanup when widget is disposed
|
||||||
|
}, const []);
|
||||||
|
|
||||||
|
final friendsOverviewAsync = ref.watch(friendsOverviewProvider);
|
||||||
|
|
||||||
|
return friendsOverviewAsync.when(
|
||||||
|
data: (friends) {
|
||||||
|
// Filter for online friends
|
||||||
|
final onlineFriends =
|
||||||
|
friends.where((friend) => friend.status.isOnline).toList();
|
||||||
|
|
||||||
|
if (onlineFriends.isEmpty && hideWhenEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final card = Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
spacing: 8,
|
||||||
|
children: [const Icon(Symbols.group), Text('Friends Online')],
|
||||||
|
).padding(horizontal: 16).height(48),
|
||||||
|
if (onlineFriends.isEmpty)
|
||||||
|
Container(
|
||||||
|
height: 80,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'No friends online',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 4),
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: onlineFriends.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final friend = onlineFriends[index];
|
||||||
|
return AccountPfcGestureDetector(
|
||||||
|
uname: friend.account.name,
|
||||||
|
child: _FriendTile(friend: friend),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget result = card;
|
||||||
|
if (padding != null) {
|
||||||
|
result = Padding(padding: padding!, child: result);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
loading:
|
||||||
|
() => const SizedBox(
|
||||||
|
height: 80,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (error, stack) => const SizedBox.shrink(), // Hide on error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FriendTile extends ConsumerWidget {
|
||||||
|
final SnFriendOverviewItem friend;
|
||||||
|
|
||||||
|
const _FriendTile({required this.friend});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final serverUrl = ref.watch(serverUrlProvider);
|
||||||
|
|
||||||
|
String? uri;
|
||||||
|
if (friend.account.profile.picture != null) {
|
||||||
|
uri = '$serverUrl/drive/files/${friend.account.profile.picture!.id}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 60,
|
||||||
|
margin: const EdgeInsets.only(right: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Avatar with online indicator
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
backgroundImage:
|
||||||
|
uri != null ? CachedNetworkImageProvider(uri) : null,
|
||||||
|
child:
|
||||||
|
uri == null
|
||||||
|
? Text(
|
||||||
|
friend.account.nick.isNotEmpty
|
||||||
|
? friend.account.nick[0].toUpperCase()
|
||||||
|
: friend.account.name[0].toUpperCase(),
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
// Online indicator - show play arrow if user has activities, otherwise green dot
|
||||||
|
Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
friend.activities.isNotEmpty
|
||||||
|
? Colors.blue.withOpacity(0.8)
|
||||||
|
: Colors.green,
|
||||||
|
shape:
|
||||||
|
friend.activities.isNotEmpty
|
||||||
|
? BoxShape.rectangle
|
||||||
|
: BoxShape.circle,
|
||||||
|
borderRadius:
|
||||||
|
friend.activities.isNotEmpty
|
||||||
|
? BorderRadius.circular(4)
|
||||||
|
: null,
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
friend.activities.isNotEmpty
|
||||||
|
? Icon(
|
||||||
|
Symbols.play_arrow,
|
||||||
|
size: 10,
|
||||||
|
color: Colors.white,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
// Name (truncated if too long)
|
||||||
|
Text(
|
||||||
|
friend.account.nick.isNotEmpty
|
||||||
|
? friend.account.nick
|
||||||
|
: friend.account.name,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).center();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
lib/widgets/account/friends_overview.g.dart
Normal file
30
lib/widgets/account/friends_overview.g.dart
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'friends_overview.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
String _$friendsOverviewHash() => r'5ef86c6849804c97abd3df094f120c7dd5e938db';
|
||||||
|
|
||||||
|
/// See also [friendsOverview].
|
||||||
|
@ProviderFor(friendsOverview)
|
||||||
|
final friendsOverviewProvider =
|
||||||
|
AutoDisposeFutureProvider<List<SnFriendOverviewItem>>.internal(
|
||||||
|
friendsOverview,
|
||||||
|
name: r'friendsOverviewProvider',
|
||||||
|
debugGetCreateSourceHash:
|
||||||
|
const bool.fromEnvironment('dart.vm.product')
|
||||||
|
? null
|
||||||
|
: _$friendsOverviewHash,
|
||||||
|
dependencies: null,
|
||||||
|
allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@Deprecated('Will be removed in 3.0. Use Ref instead')
|
||||||
|
// ignore: unused_element
|
||||||
|
typedef FriendsOverviewRef =
|
||||||
|
AutoDisposeFutureProviderRef<List<SnFriendOverviewItem>>;
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package
|
||||||
@@ -240,7 +240,11 @@ class _PurchaseGiftSheetState extends State<PurchaseGiftSheet> {
|
|||||||
labelText: 'personalMessage'.tr(),
|
labelText: 'personalMessage'.tr(),
|
||||||
hintText: 'addPersonalMessageForRecipient'.tr(),
|
hintText: 'addPersonalMessageForRecipient'.tr(),
|
||||||
alignLabelWithHint: true,
|
alignLabelWithHint: true,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Theme.of(
|
color: Theme.of(
|
||||||
@@ -925,7 +929,9 @@ class StellarProgramTab extends HookConsumerWidget {
|
|||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
isDense: true,
|
isDense: true,
|
||||||
hintText: 'enterGiftCode'.tr(),
|
hintText: 'enterGiftCode'.tr(),
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:island/route.dart';
|
|||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
import 'package:island/pods/websocket.dart';
|
import 'package:island/pods/websocket.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/widgets/upload_overlay.dart';
|
||||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
@@ -198,6 +199,7 @@ class WindowScaffold extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
_WebSocketIndicator(),
|
_WebSocketIndicator(),
|
||||||
|
const UploadOverlay(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -213,7 +215,11 @@ class WindowScaffold extends HookConsumerWidget {
|
|||||||
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
|
||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [Positioned.fill(child: child), _WebSocketIndicator()],
|
children: [
|
||||||
|
Positioned.fill(child: child),
|
||||||
|
_WebSocketIndicator(),
|
||||||
|
const UploadOverlay(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:app_links/app_links.dart';
|
import 'dart:io';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:protocol_handler/protocol_handler.dart';
|
||||||
import 'package:island/pods/activity/activity_rpc.dart';
|
import 'package:island/pods/activity/activity_rpc.dart';
|
||||||
import 'package:island/pods/websocket.dart';
|
import 'package:island/pods/websocket.dart';
|
||||||
import 'package:island/route.dart';
|
import 'package:island/route.dart';
|
||||||
import 'package:island/screens/tray_manager.dart';
|
import 'package:island/screens/tray_manager.dart';
|
||||||
|
import 'package:island/services/event_bus.dart';
|
||||||
import 'package:island/services/notify.dart';
|
import 'package:island/services/notify.dart';
|
||||||
import 'package:island/services/sharing_intent.dart';
|
import 'package:island/services/sharing_intent.dart';
|
||||||
import 'package:island/services/update_service.dart';
|
import 'package:island/services/update_service.dart';
|
||||||
@@ -15,57 +17,61 @@ import 'package:island/widgets/tour/tour.dart';
|
|||||||
import 'package:tray_manager/tray_manager.dart';
|
import 'package:tray_manager/tray_manager.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
class AppWrapper extends HookConsumerWidget with TrayListener {
|
class AppWrapper extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
const AppWrapper({super.key, required this.child});
|
const AppWrapper({super.key, required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<AppWrapper> createState() => _AppWrapperState();
|
||||||
useEffect(() {
|
}
|
||||||
StreamSubscription? ntySubs;
|
|
||||||
StreamSubscription? appLinksSubs;
|
|
||||||
Future(() async {
|
|
||||||
final appLinks = AppLinks();
|
|
||||||
|
|
||||||
if (context.mounted) ntySubs = setupNotificationListener(context, ref);
|
class _AppWrapperState extends ConsumerState<AppWrapper>
|
||||||
|
with ProtocolListener, TrayListener {
|
||||||
|
StreamSubscription? ntySubs;
|
||||||
|
bool networkStateShowing = false;
|
||||||
|
|
||||||
final sharingService = SharingIntentService();
|
@override
|
||||||
if (context.mounted) sharingService.initialize(context);
|
void initState() {
|
||||||
if (context.mounted) UpdateService().checkForUpdates(context);
|
super.initState();
|
||||||
|
protocolHandler.addListener(this);
|
||||||
|
Future(() async {
|
||||||
|
if (mounted) ntySubs = setupNotificationListener(context, ref);
|
||||||
|
|
||||||
TrayService.instance.initialize(this);
|
final sharingService = SharingIntentService();
|
||||||
|
if (mounted) sharingService.initialize(context);
|
||||||
|
if (mounted) UpdateService().checkForUpdates(context);
|
||||||
|
|
||||||
ref.read(rpcServerStateProvider.notifier).start();
|
TrayService.instance.initialize(this);
|
||||||
|
|
||||||
final initialUri = await appLinks.getLatestLink();
|
ref.read(rpcServerStateProvider.notifier).start();
|
||||||
if (initialUri != null && context.mounted) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_handleDeepLink(initialUri, ref);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
appLinksSubs = appLinks.uriLinkStream.listen((uri) {
|
final initialUrl = await protocolHandler.getInitialUrl();
|
||||||
_handleDeepLink(uri, ref);
|
if (initialUrl != null && mounted) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_handleDeepLink(Uri.parse(initialUrl), ref);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return () {
|
@override
|
||||||
ref.read(rpcServerProvider).stop();
|
void dispose() {
|
||||||
TrayService.instance.dispose(this);
|
protocolHandler.removeListener(this);
|
||||||
ntySubs?.cancel();
|
ref.read(rpcServerProvider).stop();
|
||||||
appLinksSubs?.cancel();
|
TrayService.instance.dispose(this);
|
||||||
};
|
ntySubs?.cancel();
|
||||||
}, const []);
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final wsNotifier = ref.watch(websocketStateProvider.notifier);
|
final wsNotifier = ref.watch(websocketStateProvider.notifier);
|
||||||
final websocketState = ref.watch(websocketStateProvider);
|
final websocketState = ref.watch(websocketStateProvider);
|
||||||
|
|
||||||
final networkStateShowing = useState(false);
|
|
||||||
|
|
||||||
if (websocketState == WebSocketState.duplicateDevice()) {
|
if (websocketState == WebSocketState.duplicateDevice()) {
|
||||||
if (!networkStateShowing.value) {
|
if (!networkStateShowing) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
networkStateShowing.value = true;
|
setState(() => networkStateShowing = true);
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
@@ -73,12 +79,17 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
|
|||||||
builder:
|
builder:
|
||||||
(context) =>
|
(context) =>
|
||||||
NetworkStatusSheet(onReconnect: () => wsNotifier.connect()),
|
NetworkStatusSheet(onReconnect: () => wsNotifier.connect()),
|
||||||
).then((_) => networkStateShowing.value = false);
|
).then((_) => setState(() => networkStateShowing = false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return TourTriggerWidget(key: UniqueKey(), child: child);
|
return TourTriggerWidget(key: UniqueKey(), child: widget.child);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onProtocolUrlReceived(String url) {
|
||||||
|
_handleDeepLink(Uri.parse(url), ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _trayIconPrimaryAction() {
|
void _trayIconPrimaryAction() {
|
||||||
@@ -105,14 +116,31 @@ class AppWrapper extends HookConsumerWidget with TrayListener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleDeepLink(Uri uri, WidgetRef ref) {
|
void _handleDeepLink(Uri uri, WidgetRef ref) {
|
||||||
|
String path = '/${uri.host}${uri.path}';
|
||||||
|
|
||||||
|
// Special handling for OIDC auth callback
|
||||||
|
if (path == '/auth/callback' &&
|
||||||
|
uri.queryParameters.containsKey('challenge')) {
|
||||||
|
final challenge = uri.queryParameters['challenge']!;
|
||||||
|
eventBus.fire(OidcAuthCallbackEvent(challenge));
|
||||||
|
if (!kIsWeb &&
|
||||||
|
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||||
|
windowManager.show();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final router = ref.read(routerProvider);
|
final router = ref.read(routerProvider);
|
||||||
String path = '/${uri.path}';
|
|
||||||
if (uri.queryParameters.isNotEmpty) {
|
if (uri.queryParameters.isNotEmpty) {
|
||||||
path =
|
path =
|
||||||
Uri.parse(
|
Uri.parse(
|
||||||
path,
|
path,
|
||||||
).replace(queryParameters: uri.queryParameters).toString();
|
).replace(queryParameters: uri.queryParameters).toString();
|
||||||
}
|
}
|
||||||
router.go(path);
|
router.push(path);
|
||||||
|
if (!kIsWeb &&
|
||||||
|
(Platform.isWindows || Platform.isLinux || Platform.isMacOS)) {
|
||||||
|
windowManager.show();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import "package:island/models/account.dart";
|
|||||||
import "package:island/models/autocomplete_response.dart";
|
import "package:island/models/autocomplete_response.dart";
|
||||||
import "package:island/models/chat.dart";
|
import "package:island/models/chat.dart";
|
||||||
import "package:island/models/file.dart";
|
import "package:island/models/file.dart";
|
||||||
|
import "package:island/models/poll.dart";
|
||||||
import "package:island/models/publisher.dart";
|
import "package:island/models/publisher.dart";
|
||||||
|
import "package:island/models/wallet.dart";
|
||||||
import "package:island/models/realm.dart";
|
import "package:island/models/realm.dart";
|
||||||
import "package:island/models/sticker.dart";
|
import "package:island/models/sticker.dart";
|
||||||
import "package:island/pods/config.dart";
|
import "package:island/pods/config.dart";
|
||||||
@@ -26,6 +28,169 @@ import "package:styled_widget/styled_widget.dart";
|
|||||||
import "package:material_symbols_icons/symbols.dart";
|
import "package:material_symbols_icons/symbols.dart";
|
||||||
import "package:island/widgets/stickers/sticker_picker.dart";
|
import "package:island/widgets/stickers/sticker_picker.dart";
|
||||||
import "package:island/pods/chat/chat_subscribe.dart";
|
import "package:island/pods/chat/chat_subscribe.dart";
|
||||||
|
import "package:island/widgets/post/compose_poll.dart";
|
||||||
|
import "package:island/widgets/post/compose_fund.dart";
|
||||||
|
|
||||||
|
void _insertPlaceholder(TextEditingController controller, String placeholder) {
|
||||||
|
final text = controller.text;
|
||||||
|
final selection = controller.selection;
|
||||||
|
final start = selection.start >= 0 ? selection.start : text.length;
|
||||||
|
final end = selection.end >= 0 ? selection.end : text.length;
|
||||||
|
final newText = text.replaceRange(start, end, placeholder);
|
||||||
|
controller.value = TextEditingValue(
|
||||||
|
text: newText,
|
||||||
|
selection: TextSelection.collapsed(offset: start + placeholder.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kInputDrawerExpandedHeight = 180.0;
|
||||||
|
|
||||||
|
class _ExpandedSection extends StatelessWidget {
|
||||||
|
final TextEditingController messageController;
|
||||||
|
final SnPoll? selectedPoll;
|
||||||
|
final Function(SnPoll?) onPollSelected;
|
||||||
|
final SnWalletFund? selectedFund;
|
||||||
|
final Function(SnWalletFund?) onFundSelected;
|
||||||
|
|
||||||
|
const _ExpandedSection({
|
||||||
|
required this.messageController,
|
||||||
|
this.selectedPoll,
|
||||||
|
required this.onPollSelected,
|
||||||
|
this.selectedFund,
|
||||||
|
required this.onFundSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
key: const ValueKey('expanded'),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(width: 1, color: Theme.of(context).dividerColor),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.only(top: 8, bottom: 3),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(32)),
|
||||||
|
child: DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TabBar(
|
||||||
|
splashBorderRadius: const BorderRadius.all(Radius.circular(40)),
|
||||||
|
tabs: [Tab(text: 'Features'), Tab(text: 'Stickers')],
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: kInputDrawerExpandedHeight,
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height:
|
||||||
|
kInputDrawerExpandedHeight -
|
||||||
|
48, // subtract tab bar height approx
|
||||||
|
child: GridView(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
gridDelegate:
|
||||||
|
const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: 120,
|
||||||
|
childAspectRatio: 1, // 1:1 aspect ratio
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(8),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final poll = await showModalBottomSheet<SnPoll>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => const ComposePollSheet(),
|
||||||
|
);
|
||||||
|
if (poll != null) {
|
||||||
|
onPollSelected(poll);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainer,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.poll),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'Poll',
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(8),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final fund =
|
||||||
|
await showModalBottomSheet<SnWalletFund>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder:
|
||||||
|
(context) => const ComposeFundSheet(),
|
||||||
|
);
|
||||||
|
if (fund != null) {
|
||||||
|
onFundSelected(fund);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Card(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainer,
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.currency_exchange),
|
||||||
|
const Gap(4),
|
||||||
|
Text(
|
||||||
|
'fund'.tr(),
|
||||||
|
style:
|
||||||
|
Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StickerPickerEmbedded(
|
||||||
|
height: kInputDrawerExpandedHeight,
|
||||||
|
onPick:
|
||||||
|
(placeholder) => _insertPlaceholder(
|
||||||
|
messageController,
|
||||||
|
placeholder,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class ChatInput extends HookConsumerWidget {
|
class ChatInput extends HookConsumerWidget {
|
||||||
final TextEditingController messageController;
|
final TextEditingController messageController;
|
||||||
@@ -44,7 +209,11 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
final Function(int) onDeleteAttachment;
|
final Function(int) onDeleteAttachment;
|
||||||
final Function(int, int) onMoveAttachment;
|
final Function(int, int) onMoveAttachment;
|
||||||
final Function(List<UniversalFile>) onAttachmentsChanged;
|
final Function(List<UniversalFile>) onAttachmentsChanged;
|
||||||
final Map<String, Map<int, double>> attachmentProgress;
|
final Map<String, Map<int, double?>> attachmentProgress;
|
||||||
|
final SnPoll? selectedPoll;
|
||||||
|
final Function(SnPoll?) onPollSelected;
|
||||||
|
final SnWalletFund? selectedFund;
|
||||||
|
final Function(SnWalletFund?) onFundSelected;
|
||||||
|
|
||||||
const ChatInput({
|
const ChatInput({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -65,12 +234,17 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
required this.onMoveAttachment,
|
required this.onMoveAttachment,
|
||||||
required this.onAttachmentsChanged,
|
required this.onAttachmentsChanged,
|
||||||
required this.attachmentProgress,
|
required this.attachmentProgress,
|
||||||
|
this.selectedPoll,
|
||||||
|
required this.onPollSelected,
|
||||||
|
this.selectedFund,
|
||||||
|
required this.onFundSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final inputFocusNode = useFocusNode();
|
final inputFocusNode = useFocusNode();
|
||||||
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
|
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
|
||||||
|
final isExpanded = useState(false);
|
||||||
|
|
||||||
void send() {
|
void send() {
|
||||||
inputFocusNode.requestFocus();
|
inputFocusNode.requestFocus();
|
||||||
@@ -281,6 +455,195 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
key: ValueKey('no-attachments'),
|
key: ValueKey('no-attachments'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, -0.25),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SizeTransition(
|
||||||
|
sizeFactor: animation,
|
||||||
|
axisAlignment: -1.0,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
selectedPoll != null
|
||||||
|
? Container(
|
||||||
|
key: const ValueKey('selected-poll'),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.outline.withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.how_to_vote,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
selectedPoll!.title ?? 'Poll',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall!
|
||||||
|
.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
onPressed: () => onPollSelected(null),
|
||||||
|
tooltip: 'clear'.tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(
|
||||||
|
key: ValueKey('no-selected-poll'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, -0.25),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SizeTransition(
|
||||||
|
sizeFactor: animation,
|
||||||
|
axisAlignment: -1.0,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
selectedFund != null
|
||||||
|
? Container(
|
||||||
|
key: const ValueKey('selected-fund'),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.outline.withOpacity(0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.only(
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
top: 8,
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.currency_exchange,
|
||||||
|
size: 18,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${selectedFund!.totalAmount.toStringAsFixed(2)} ${selectedFund!.currency}',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall!.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (selectedFund!.message != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
selectedFund!.message!,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall!.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
color:
|
||||||
|
Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: const Icon(Icons.close, size: 18),
|
||||||
|
onPressed: () => onFundSelected(null),
|
||||||
|
tooltip: 'clear'.tr(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(
|
||||||
|
key: ValueKey('no-selected-fund'),
|
||||||
|
),
|
||||||
|
),
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
switchInCurve: Curves.easeOutCubic,
|
switchInCurve: Curves.easeOutCubic,
|
||||||
@@ -426,43 +789,28 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'stickers'.tr(),
|
tooltip:
|
||||||
icon: const Icon(Symbols.add_reaction),
|
isExpanded.value ? 'collapse'.tr() : 'more'.tr(),
|
||||||
|
icon: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
transitionBuilder:
|
||||||
|
(child, animation) => FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
isExpanded.value
|
||||||
|
? const Icon(
|
||||||
|
Symbols.close,
|
||||||
|
key: ValueKey('close'),
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Symbols.add,
|
||||||
|
key: ValueKey('add'),
|
||||||
|
),
|
||||||
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final size = MediaQuery.of(context).size;
|
isExpanded.value = !isExpanded.value;
|
||||||
showStickerPickerPopover(
|
|
||||||
context,
|
|
||||||
Offset(
|
|
||||||
20,
|
|
||||||
size.height -
|
|
||||||
480 -
|
|
||||||
MediaQuery.of(context).padding.bottom,
|
|
||||||
),
|
|
||||||
onPick: (placeholder) {
|
|
||||||
// Insert placeholder at current cursor position
|
|
||||||
final text = messageController.text;
|
|
||||||
final selection = messageController.selection;
|
|
||||||
final start =
|
|
||||||
selection.start >= 0
|
|
||||||
? selection.start
|
|
||||||
: text.length;
|
|
||||||
final end =
|
|
||||||
selection.end >= 0
|
|
||||||
? selection.end
|
|
||||||
: text.length;
|
|
||||||
final newText = text.replaceRange(
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
placeholder,
|
|
||||||
);
|
|
||||||
messageController.value = TextEditingValue(
|
|
||||||
text: newText,
|
|
||||||
selection: TextSelection.collapsed(
|
|
||||||
offset: start + placeholder.length,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
UploadMenu(
|
UploadMenu(
|
||||||
@@ -659,6 +1007,37 @@ class ChatInput extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||||
|
return SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(animation),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: SizeTransition(
|
||||||
|
sizeFactor: animation,
|
||||||
|
axisAlignment: -1.0,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child:
|
||||||
|
isExpanded.value
|
||||||
|
? _ExpandedSection(
|
||||||
|
messageController: messageController,
|
||||||
|
selectedPoll: selectedPoll,
|
||||||
|
onPollSelected: onPollSelected,
|
||||||
|
selectedFund: selectedFund,
|
||||||
|
onFundSelected: onFundSelected,
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(key: ValueKey('collapsed')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -144,7 +144,11 @@ class ChatLinkAttachment extends HookConsumerWidget {
|
|||||||
helperText: 'fileIdHint'.tr(),
|
helperText: 'fileIdHint'.tr(),
|
||||||
helperMaxLines: 3,
|
helperMaxLines: 3,
|
||||||
errorText: errorMessage.value,
|
errorText: errorMessage.value,
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(
|
||||||
|
Radius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onTapOutside:
|
onTapOutside:
|
||||||
(_) =>
|
(_) =>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class MessageItem extends HookConsumerWidget {
|
|||||||
final LocalChatMessage message;
|
final LocalChatMessage message;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
final Function(String action)? onAction;
|
final Function(String action)? onAction;
|
||||||
final Map<int, double>? progress;
|
final Map<int, double?>? progress;
|
||||||
final bool showAvatar;
|
final bool showAvatar;
|
||||||
final Function(String messageId) onJump;
|
final Function(String messageId) onJump;
|
||||||
final bool isSelectionMode;
|
final bool isSelectionMode;
|
||||||
@@ -689,7 +689,7 @@ class MessageHoverActionMenu extends StatelessWidget {
|
|||||||
class MessageItemDisplayBubble extends HookConsumerWidget {
|
class MessageItemDisplayBubble extends HookConsumerWidget {
|
||||||
final LocalChatMessage message;
|
final LocalChatMessage message;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
final Map<int, double>? progress;
|
final Map<int, double?>? progress;
|
||||||
final bool showAvatar;
|
final bool showAvatar;
|
||||||
final Function(String messageId) onJump;
|
final Function(String messageId) onJump;
|
||||||
final String? translatedText;
|
final String? translatedText;
|
||||||
@@ -821,7 +821,7 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
|
|||||||
class MessageItemDisplayIRC extends HookConsumerWidget {
|
class MessageItemDisplayIRC extends HookConsumerWidget {
|
||||||
final LocalChatMessage message;
|
final LocalChatMessage message;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
final Map<int, double>? progress;
|
final Map<int, double?>? progress;
|
||||||
final bool showAvatar;
|
final bool showAvatar;
|
||||||
final Function(String messageId) onJump;
|
final Function(String messageId) onJump;
|
||||||
final String? translatedText;
|
final String? translatedText;
|
||||||
@@ -949,7 +949,7 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
|
|||||||
class MessageItemDisplayDiscord extends HookConsumerWidget {
|
class MessageItemDisplayDiscord extends HookConsumerWidget {
|
||||||
final LocalChatMessage message;
|
final LocalChatMessage message;
|
||||||
final bool isCurrentUser;
|
final bool isCurrentUser;
|
||||||
final Map<int, double>? progress;
|
final Map<int, double?>? progress;
|
||||||
final bool showAvatar;
|
final bool showAvatar;
|
||||||
final Function(String messageId) onJump;
|
final Function(String messageId) onJump;
|
||||||
final String? translatedText;
|
final String? translatedText;
|
||||||
@@ -1238,7 +1238,7 @@ class MessageQuoteWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FileUploadProgressWidget extends StatelessWidget {
|
class FileUploadProgressWidget extends StatelessWidget {
|
||||||
final Map<int, double>? progress;
|
final Map<int, double?>? progress;
|
||||||
final Color textColor;
|
final Color textColor;
|
||||||
final bool hasContent;
|
final bool hasContent;
|
||||||
|
|
||||||
@@ -1266,7 +1266,9 @@ class FileUploadProgressWidget extends StatelessWidget {
|
|||||||
'fileUploadingProgress'.tr(
|
'fileUploadingProgress'.tr(
|
||||||
args: [
|
args: [
|
||||||
(entry.key + 1).toString(),
|
(entry.key + 1).toString(),
|
||||||
(entry.value * 100).toStringAsFixed(1),
|
entry.value != null
|
||||||
|
? (entry.value! * 100).toStringAsFixed(1)
|
||||||
|
: '0.0',
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -104,9 +104,7 @@ class CheckInWidget extends HookConsumerWidget {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err is DioException) {
|
if (err is DioException) {
|
||||||
if (err.response?.statusCode == 423 && context.mounted) {
|
if (err.response?.statusCode == 423 && context.mounted) {
|
||||||
final captchaTk = await Navigator.of(
|
final captchaTk = await CaptchaScreen.show(context);
|
||||||
context,
|
|
||||||
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
|
|
||||||
if (captchaTk == null) return;
|
if (captchaTk == null) return;
|
||||||
return await checkIn(captchatTk: captchaTk);
|
return await checkIn(captchatTk: captchaTk);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (progress != null)
|
if (progress != null)
|
||||||
Text(
|
Text(
|
||||||
'${progress!.toStringAsFixed(2)}%',
|
'${(progress! * 100).toStringAsFixed(2)}%',
|
||||||
style: TextStyle(color: Colors.white),
|
style: TextStyle(color: Colors.white),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@@ -411,10 +411,7 @@ class AttachmentPreview extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Gap(6),
|
Gap(6),
|
||||||
Center(
|
Center(
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(value: progress),
|
||||||
value:
|
|
||||||
progress != null ? progress! / 100.0 : null,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -215,7 +215,6 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (files.length == 1) {
|
if (files.length == 1) {
|
||||||
final isImage = files.first.mimeType?.startsWith('image') ?? false;
|
final isImage = files.first.mimeType?.startsWith('image') ?? false;
|
||||||
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
|
|
||||||
final widgetItem = ClipRRect(
|
final widgetItem = ClipRRect(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
child: _CloudFileListEntry(
|
child: _CloudFileListEntry(
|
||||||
@@ -243,11 +242,7 @@ class CloudFileList extends HookConsumerWidget {
|
|||||||
minWidth: minWidth ?? 0,
|
minWidth: minWidth ?? 0,
|
||||||
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
maxWidth: files.length == 1 ? maxWidth : double.infinity,
|
||||||
),
|
),
|
||||||
height: isAudio ? 120 : null,
|
child: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
|
||||||
child:
|
|
||||||
isAudio
|
|
||||||
? widgetItem
|
|
||||||
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +408,8 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
|
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
|
||||||
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
|
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
|
||||||
|
|
||||||
|
final ratio = meta['ratio'] as num?;
|
||||||
|
|
||||||
final fit = BoxFit.cover;
|
final fit = BoxFit.cover;
|
||||||
|
|
||||||
Widget bg = const SizedBox.shrink();
|
Widget bg = const SizedBox.shrink();
|
||||||
@@ -484,7 +481,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
|
|||||||
onTap?.call();
|
onTap?.call();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: content,
|
child: AspectRatio(aspectRatio: ratio?.toDouble() ?? 1, child: content),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:island/models/file.dart';
|
import 'package:island/models/file.dart';
|
||||||
import 'package:island/pods/network.dart';
|
|
||||||
import 'package:island/services/file_uploader.dart';
|
import 'package:island/services/file_uploader.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/attachment_preview.dart';
|
import 'package:island/widgets/content/attachment_preview.dart';
|
||||||
@@ -61,7 +60,7 @@ class CloudFilePicker extends HookConsumerWidget {
|
|||||||
final cloudFile =
|
final cloudFile =
|
||||||
await FileUploader.createCloudFile(
|
await FileUploader.createCloudFile(
|
||||||
fileData: file,
|
fileData: file,
|
||||||
client: ref.read(apiClientProvider),
|
ref: ref,
|
||||||
onProgress: (progress, _) {
|
onProgress: (progress, _) {
|
||||||
uploadProgress.value = progress;
|
uploadProgress.value = progress;
|
||||||
},
|
},
|
||||||
@@ -112,23 +111,28 @@ class CloudFilePicker extends HookConsumerWidget {
|
|||||||
|
|
||||||
void pickImage() async {
|
void pickImage() async {
|
||||||
showLoadingModal(context);
|
showLoadingModal(context);
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final ImagePicker picker = ImagePicker();
|
||||||
allowMultiple: allowMultiple,
|
List<XFile> results;
|
||||||
type: FileType.image,
|
if (allowMultiple) {
|
||||||
);
|
results = await picker.pickMultiImage();
|
||||||
if (result == null || result.files.isEmpty) {
|
} else {
|
||||||
|
final XFile? result = await picker.pickImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
);
|
||||||
|
results = result != null ? [result] : [];
|
||||||
|
}
|
||||||
|
if (results.isEmpty) {
|
||||||
if (context.mounted) hideLoadingModal(context);
|
if (context.mounted) hideLoadingModal(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final newFiles =
|
final newFiles =
|
||||||
result.files.map((e) {
|
results
|
||||||
final xfile =
|
.map(
|
||||||
e.bytes != null
|
(xfile) =>
|
||||||
? XFile.fromData(e.bytes!, name: e.name)
|
UniversalFile(data: xfile, type: UniversalFileType.image),
|
||||||
: XFile(e.path!);
|
)
|
||||||
return UniversalFile(data: xfile, type: UniversalFileType.image);
|
.toList();
|
||||||
}).toList();
|
|
||||||
|
|
||||||
if (!allowMultiple) {
|
if (!allowMultiple) {
|
||||||
files.value = newFiles;
|
files.value = newFiles;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
@@ -14,15 +13,14 @@ import 'package:island/pods/network.dart';
|
|||||||
import 'package:island/services/time.dart';
|
import 'package:island/services/time.dart';
|
||||||
import 'package:island/utils/format.dart';
|
import 'package:island/utils/format.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/content/audio.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:path/path.dart' show extension;
|
import 'package:path/path.dart' show extension;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
|
||||||
import 'package:island/widgets/data_saving_gate.dart';
|
import 'package:island/widgets/data_saving_gate.dart';
|
||||||
import 'package:island/widgets/content/file_info_sheet.dart';
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
|
|
||||||
|
import 'file_viewer_contents.dart';
|
||||||
import 'image.dart';
|
import 'image.dart';
|
||||||
import 'video.dart';
|
import 'video.dart';
|
||||||
|
|
||||||
@@ -68,8 +66,6 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (item.mimeType == 'application/pdf') {
|
if (item.mimeType == 'application/pdf') {
|
||||||
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
|
|
||||||
|
|
||||||
Future<void> downloadFile() async {
|
Future<void> downloadFile() async {
|
||||||
try {
|
try {
|
||||||
showSnackBar('Downloading file...');
|
showSnackBar('Downloading file...');
|
||||||
@@ -109,7 +105,7 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
pdfViewer,
|
PdfFileContent(uri: uri),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
left: 8,
|
left: 8,
|
||||||
@@ -205,14 +201,6 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.mimeType?.startsWith('text/') == true) {
|
if (item.mimeType?.startsWith('text/') == true) {
|
||||||
final textFuture = useMemoized(
|
|
||||||
() => ref
|
|
||||||
.read(apiClientProvider)
|
|
||||||
.get(uri)
|
|
||||||
.then((response) => response.data as String),
|
|
||||||
[uri],
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> downloadFile() async {
|
Future<void> downloadFile() async {
|
||||||
try {
|
try {
|
||||||
showSnackBar('Downloading file...');
|
showSnackBar('Downloading file...');
|
||||||
@@ -252,29 +240,9 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
FutureBuilder<String>(
|
Padding(
|
||||||
future: textFuture,
|
padding: const EdgeInsets.fromLTRB(20, 68, 20, 20),
|
||||||
builder: (context, snapshot) {
|
child: TextFileContent(uri: uri),
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
} else if (snapshot.hasError) {
|
|
||||||
return Center(
|
|
||||||
child: Text('Error loading text: ${snapshot.error}'),
|
|
||||||
);
|
|
||||||
} else if (snapshot.hasData) {
|
|
||||||
return SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20 + 48, 20, 20),
|
|
||||||
child: SelectableText(
|
|
||||||
snapshot.data!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const Center(child: Text('No content'));
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 8,
|
top: 8,
|
||||||
@@ -371,21 +339,13 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
var content = switch (item.mimeType?.split('/').firstOrNull) {
|
||||||
'image' =>
|
'image' => AspectRatio(
|
||||||
ratio == 1.0
|
aspectRatio: ratio,
|
||||||
? IntrinsicHeight(
|
child:
|
||||||
child:
|
(useInternalGate && dataSaving && !unlocked.value)
|
||||||
(useInternalGate && dataSaving && !unlocked.value)
|
? dataPlaceHolder(Symbols.image)
|
||||||
? dataPlaceHolder(Symbols.image)
|
: cloudImage(),
|
||||||
: cloudImage(),
|
),
|
||||||
)
|
|
||||||
: AspectRatio(
|
|
||||||
aspectRatio: ratio,
|
|
||||||
child:
|
|
||||||
(useInternalGate && dataSaving && !unlocked.value)
|
|
||||||
? dataPlaceHolder(Symbols.image)
|
|
||||||
: cloudImage(),
|
|
||||||
),
|
|
||||||
'video' => AspectRatio(
|
'video' => AspectRatio(
|
||||||
aspectRatio: ratio,
|
aspectRatio: ratio,
|
||||||
child:
|
child:
|
||||||
@@ -393,14 +353,7 @@ class CloudFileWidget extends HookConsumerWidget {
|
|||||||
? dataPlaceHolder(Symbols.play_arrow)
|
? dataPlaceHolder(Symbols.play_arrow)
|
||||||
: cloudVideo(),
|
: cloudVideo(),
|
||||||
),
|
),
|
||||||
'audio' => Center(
|
'audio' => AudioFileContent(item: item, uri: uri),
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
|
||||||
),
|
|
||||||
child: UniversalAudio(uri: uri, filename: item.name),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_ => Builder(
|
_ => Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
Future<void> downloadFile() async {
|
Future<void> downloadFile() async {
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import 'dart:math' as math;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:island/models/embed.dart';
|
import 'package:island/models/embed.dart';
|
||||||
import 'package:island/services/responsive.dart';
|
|
||||||
import 'package:island/utils/mapping.dart';
|
import 'package:island/utils/mapping.dart';
|
||||||
import 'package:island/widgets/content/embed/link.dart';
|
import 'package:island/widgets/content/embed/link.dart';
|
||||||
import 'package:island/widgets/poll/poll_submit.dart';
|
import 'package:island/widgets/poll/poll_submit.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:island/widgets/wallet/fund_envelope.dart';
|
||||||
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||||
|
|
||||||
class EmbedListWidget extends StatelessWidget {
|
class EmbedListWidget extends StatelessWidget {
|
||||||
final List<dynamic> embeds;
|
final List<dynamic> embeds;
|
||||||
@@ -26,46 +24,108 @@ class EmbedListWidget extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final normalizedEmbeds =
|
||||||
|
embeds
|
||||||
|
.map((e) => convertMapKeysToSnakeCase(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final linkEmbeds =
|
||||||
|
normalizedEmbeds.where((e) => e['type'] == 'link').toList();
|
||||||
|
final otherEmbeds =
|
||||||
|
normalizedEmbeds.where((e) => e['type'] != 'link').toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children:
|
children: [
|
||||||
embeds
|
if (linkEmbeds.isNotEmpty)
|
||||||
.map((embedData) => convertMapKeysToSnakeCase(embedData))
|
Container(
|
||||||
.map(
|
margin: EdgeInsets.only(
|
||||||
(embedData) => switch (embedData['type']) {
|
top: 8,
|
||||||
'link' => EmbedLinkWidget(
|
left: renderingPadding.horizontal,
|
||||||
link: SnScrappedLink.fromJson(embedData),
|
right: renderingPadding.horizontal,
|
||||||
maxWidth:
|
),
|
||||||
maxWidth ??
|
decoration: BoxDecoration(
|
||||||
math.min(
|
border: Border.all(color: Theme.of(context).dividerColor),
|
||||||
MediaQuery.of(context).size.width,
|
borderRadius: BorderRadius.circular(8),
|
||||||
kWideScreenWidth,
|
),
|
||||||
),
|
child: Theme(
|
||||||
margin: EdgeInsets.only(
|
data: Theme.of(
|
||||||
top: 4,
|
context,
|
||||||
bottom: 4,
|
).copyWith(dividerColor: Colors.transparent),
|
||||||
left: renderingPadding.horizontal,
|
child: ExpansionTile(
|
||||||
right: renderingPadding.horizontal,
|
initiallyExpanded: true,
|
||||||
),
|
dense: true,
|
||||||
|
leading: const Icon(Symbols.link),
|
||||||
|
title: Text('${linkEmbeds.length} links'),
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child:
|
||||||
|
linkEmbeds.length == 1
|
||||||
|
? EmbedLinkWidget(
|
||||||
|
link: SnScrappedLink.fromJson(linkEmbeds.first),
|
||||||
|
)
|
||||||
|
: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children:
|
||||||
|
linkEmbeds
|
||||||
|
.map(
|
||||||
|
(embedData) => EmbedLinkWidget(
|
||||||
|
link: SnScrappedLink.fromJson(
|
||||||
|
embedData,
|
||||||
|
),
|
||||||
|
maxWidth:
|
||||||
|
200, // Fixed width for horizontal scroll
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
'poll' => Card(
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...otherEmbeds.map(
|
||||||
|
(embedData) => switch (embedData['type']) {
|
||||||
|
'poll' => Card(
|
||||||
|
margin: EdgeInsets.symmetric(
|
||||||
|
horizontal: renderingPadding.horizontal,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
embedData['id'] == null
|
||||||
|
? const Text('Poll was unavailable...')
|
||||||
|
: PollSubmit(
|
||||||
|
pollId: embedData['id'],
|
||||||
|
onSubmit: (_) {},
|
||||||
|
isReadonly: !isInteractive,
|
||||||
|
isInitiallyExpanded: isFullPost,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'fund' =>
|
||||||
|
embedData['id'] == null
|
||||||
|
? const Text('Fund envelope was unavailable...')
|
||||||
|
: FundEnvelopeWidget(
|
||||||
|
fundId: embedData['id'],
|
||||||
margin: EdgeInsets.symmetric(
|
margin: EdgeInsets.symmetric(
|
||||||
horizontal: renderingPadding.horizontal,
|
horizontal: renderingPadding.horizontal,
|
||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
child:
|
|
||||||
embedData['id'] == null
|
|
||||||
? const Text('Poll was unavailable...')
|
|
||||||
: PollSubmit(
|
|
||||||
pollId: embedData['id'],
|
|
||||||
onSubmit: (_) {},
|
|
||||||
isReadonly: !isInteractive,
|
|
||||||
isInitiallyExpanded: isFullPost,
|
|
||||||
).padding(horizontal: 16, vertical: 12),
|
|
||||||
),
|
),
|
||||||
_ => Text('Unable show embed: ${embedData['type']}'),
|
_ => Text('Unable show embed: ${embedData['type']}'),
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
.toList(),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import 'package:url_launcher/url_launcher_string.dart';
|
|||||||
|
|
||||||
class FileInfoSheet extends StatelessWidget {
|
class FileInfoSheet extends StatelessWidget {
|
||||||
final SnCloudFile item;
|
final SnCloudFile item;
|
||||||
|
final VoidCallback? onClose;
|
||||||
const FileInfoSheet({super.key, required this.item});
|
const FileInfoSheet({super.key, required this.item, this.onClose});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -22,6 +22,7 @@ class FileInfoSheet extends StatelessWidget {
|
|||||||
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
|
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
return SheetScaffold(
|
return SheetScaffold(
|
||||||
|
onClose: onClose,
|
||||||
titleText: 'fileInfoTitle'.tr(),
|
titleText: 'fileInfoTitle'.tr(),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
313
lib/widgets/content/file_viewer_contents.dart
Normal file
313
lib/widgets/content/file_viewer_contents.dart
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:file_saver/file_saver.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/pods/config.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/utils/format.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/content/audio.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:island/widgets/content/file_info_sheet.dart';
|
||||||
|
import 'package:island/widgets/content/video.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:path/path.dart' show extension;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
|
||||||
|
class PdfFileContent extends HookConsumerWidget {
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const PdfFileContent({required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
|
||||||
|
return pdfViewer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextFileContent extends HookConsumerWidget {
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const TextFileContent({required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final textFuture = useMemoized(
|
||||||
|
() => ref
|
||||||
|
.read(apiClientProvider)
|
||||||
|
.get(uri)
|
||||||
|
.then((response) => response.data as String),
|
||||||
|
[uri],
|
||||||
|
);
|
||||||
|
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: textFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
} else if (snapshot.hasError) {
|
||||||
|
return Center(child: Text('Error loading text: ${snapshot.error}'));
|
||||||
|
} else if (snapshot.hasData) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: SelectableText(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(fontFamily: 'monospace', fontSize: 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Center(child: Text('No content'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImageFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const ImageFileContent({required this.item, required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final photoViewController = useMemoized(() => PhotoViewController(), []);
|
||||||
|
final rotation = useState(0);
|
||||||
|
final showOriginal = useState(false);
|
||||||
|
|
||||||
|
final shadow = [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 5.0, offset: Offset(1.0, 1.0)),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: PhotoView(
|
||||||
|
backgroundDecoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.9),
|
||||||
|
),
|
||||||
|
controller: photoViewController,
|
||||||
|
imageProvider: CloudImageWidget.provider(
|
||||||
|
fileId: item.id,
|
||||||
|
serverUrl: ref.watch(serverUrlProvider),
|
||||||
|
original: showOriginal.value,
|
||||||
|
),
|
||||||
|
customSize: MediaQuery.of(context).size,
|
||||||
|
basePosition: Alignment.center,
|
||||||
|
filterQuality: FilterQuality.high,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Controls overlay
|
||||||
|
Positioned(
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 16,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.remove, color: Colors.white, shadows: shadow),
|
||||||
|
onPressed: () {
|
||||||
|
photoViewController.scale =
|
||||||
|
(photoViewController.scale ?? 1) - 0.05;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.add, color: Colors.white, shadows: shadow),
|
||||||
|
onPressed: () {
|
||||||
|
photoViewController.scale =
|
||||||
|
(photoViewController.scale ?? 1) + 0.05;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_left,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
rotation.value = (rotation.value - 1) % 4;
|
||||||
|
photoViewController.rotation = rotation.value * -math.pi / 2;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.rotate_right,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
rotation.value = (rotation.value + 1) % 4;
|
||||||
|
photoViewController.rotation = rotation.value * -math.pi / 2;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
showOriginal.value = !showOriginal.value;
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
showOriginal.value ? Symbols.hd : Symbols.sd,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: shadow,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VideoFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const VideoFileContent({required this.item, required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
var ratio =
|
||||||
|
item.fileMeta?['ratio'] is num
|
||||||
|
? item.fileMeta!['ratio'].toDouble()
|
||||||
|
: 1.0;
|
||||||
|
if (ratio == 0) ratio = 1.0;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: ratio,
|
||||||
|
child: UniversalVideo(uri: uri, autoplay: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AudioFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
final String uri;
|
||||||
|
|
||||||
|
const AudioFileContent({required this.item, required this.uri, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
|
||||||
|
),
|
||||||
|
child: UniversalAudio(uri: uri, filename: item.name),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GenericFileContent extends HookConsumerWidget {
|
||||||
|
final SnCloudFile item;
|
||||||
|
|
||||||
|
const GenericFileContent({required this.item, super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
Future<void> downloadFile() async {
|
||||||
|
try {
|
||||||
|
showSnackBar('Downloading file...');
|
||||||
|
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
var extName = extension(item.name).trim();
|
||||||
|
if (extName.isEmpty) {
|
||||||
|
extName = item.mimeType?.split('/').lastOrNull ?? 'bin';
|
||||||
|
}
|
||||||
|
final filePath = '${tempDir.path}/${item.id}.$extName';
|
||||||
|
|
||||||
|
await client.download(
|
||||||
|
'/drive/files/${item.id}',
|
||||||
|
filePath,
|
||||||
|
queryParameters: {'original': true},
|
||||||
|
);
|
||||||
|
|
||||||
|
await FileSaver.instance.saveFile(
|
||||||
|
name: item.name.isEmpty ? '${item.id}.$extName' : item.name,
|
||||||
|
file: File(filePath),
|
||||||
|
);
|
||||||
|
showSnackBar('File saved to downloads');
|
||||||
|
} catch (e) {
|
||||||
|
showErrorAlert(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.all(32),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.insert_drive_file,
|
||||||
|
size: 64,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
item.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
formatFileSize(item.size),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(24),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: downloadFile,
|
||||||
|
icon: const Icon(Symbols.download),
|
||||||
|
label: Text('download'),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
useRootNavigator: true,
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => FileInfoSheet(item: item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Symbols.info),
|
||||||
|
label: Text('info'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ class SheetScaffold extends StatelessWidget {
|
|||||||
final Widget child;
|
final Widget child;
|
||||||
final double heightFactor;
|
final double heightFactor;
|
||||||
final double? height;
|
final double? height;
|
||||||
|
final VoidCallback? onClose;
|
||||||
const SheetScaffold({
|
const SheetScaffold({
|
||||||
super.key,
|
super.key,
|
||||||
this.title,
|
this.title,
|
||||||
@@ -16,6 +17,7 @@ class SheetScaffold extends StatelessWidget {
|
|||||||
this.actions = const [],
|
this.actions = const [],
|
||||||
this.heightFactor = 0.8,
|
this.heightFactor = 0.8,
|
||||||
this.height,
|
this.height,
|
||||||
|
this.onClose,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -50,7 +52,11 @@ class SheetScaffold extends StatelessWidget {
|
|||||||
...actions,
|
...actions,
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Symbols.close),
|
icon: const Icon(Symbols.close),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed:
|
||||||
|
() =>
|
||||||
|
onClose != null
|
||||||
|
? onClose?.call()
|
||||||
|
: Navigator.pop(context),
|
||||||
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
style: IconButton.styleFrom(minimumSize: const Size(36, 36)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
class UniversalVideo extends StatelessWidget {
|
class UniversalVideo extends StatelessWidget {
|
||||||
final String uri;
|
final String uri;
|
||||||
final double aspectRatio;
|
final double? aspectRatio;
|
||||||
final bool autoplay;
|
final bool autoplay;
|
||||||
const UniversalVideo({
|
const UniversalVideo({
|
||||||
super.key,
|
super.key,
|
||||||
required this.uri,
|
required this.uri,
|
||||||
required this.aspectRatio,
|
this.aspectRatio,
|
||||||
this.autoplay = false,
|
this.autoplay = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
hintText: 'Enter access token',
|
hintText: 'Enter access token',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
),
|
),
|
||||||
@@ -96,7 +98,7 @@ class DebugSheet extends HookConsumerWidget {
|
|||||||
'Unable to check for updates',
|
'Unable to check for updates',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
),
|
),
|
||||||
const Divider(height: 8),
|
const Divider(height: 8),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|||||||
957
lib/widgets/file_list_view.dart
Normal file
957
lib/widgets/file_list_view.dart
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
import 'package:desktop_drop/desktop_drop.dart';
|
||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||||
|
import 'package:gap/gap.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:island/models/file_list_item.dart';
|
||||||
|
import 'package:island/models/file.dart';
|
||||||
|
import 'package:island/pods/file_list.dart';
|
||||||
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/services/file_uploader.dart';
|
||||||
|
import 'package:island/services/responsive.dart';
|
||||||
|
import 'package:island/utils/file_icon_utils.dart';
|
||||||
|
import 'package:island/utils/format.dart';
|
||||||
|
import 'package:island/widgets/alert.dart';
|
||||||
|
import 'package:island/widgets/content/cloud_files.dart';
|
||||||
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
|
||||||
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
|
enum FileListMode { normal, unindexed }
|
||||||
|
|
||||||
|
enum FileListViewMode { list, waterfall }
|
||||||
|
|
||||||
|
class FileListView extends HookConsumerWidget {
|
||||||
|
final Map<String, dynamic>? usage;
|
||||||
|
final Map<String, dynamic>? quota;
|
||||||
|
final ValueNotifier<String> currentPath;
|
||||||
|
final VoidCallback onPickAndUpload;
|
||||||
|
final Function(BuildContext, ValueNotifier<String>) onShowCreateDirectory;
|
||||||
|
final ValueNotifier<FileListMode> mode;
|
||||||
|
final ValueNotifier<FileListViewMode> viewMode;
|
||||||
|
|
||||||
|
const FileListView({
|
||||||
|
required this.usage,
|
||||||
|
required this.quota,
|
||||||
|
required this.currentPath,
|
||||||
|
required this.onPickAndUpload,
|
||||||
|
required this.onShowCreateDirectory,
|
||||||
|
required this.mode,
|
||||||
|
required this.viewMode,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final dragging = useState(false);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (mode.value == FileListMode.normal) {
|
||||||
|
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
|
||||||
|
notifier.setPath(currentPath.value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [currentPath.value, mode.value]);
|
||||||
|
|
||||||
|
if (usage == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final bodyWidget = switch (mode.value) {
|
||||||
|
FileListMode.unindexed => PagingHelperSliverView(
|
||||||
|
provider: unindexedFileListNotifierProvider,
|
||||||
|
futureRefreshable: unindexedFileListNotifierProvider.future,
|
||||||
|
notifierRefreshable: unindexedFileListNotifierProvider.notifier,
|
||||||
|
contentBuilder:
|
||||||
|
(data, widgetCount, endItemView) =>
|
||||||
|
data.items.isEmpty
|
||||||
|
? SliverToBoxAdapter(
|
||||||
|
child: _buildEmptyUnindexedFilesHint(ref),
|
||||||
|
)
|
||||||
|
: _buildUnindexedFileListContent(
|
||||||
|
data.items,
|
||||||
|
widgetCount,
|
||||||
|
endItemView,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
viewMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_ => PagingHelperSliverView(
|
||||||
|
provider: cloudFileListNotifierProvider,
|
||||||
|
futureRefreshable: cloudFileListNotifierProvider.future,
|
||||||
|
notifierRefreshable: cloudFileListNotifierProvider.notifier,
|
||||||
|
contentBuilder:
|
||||||
|
(data, widgetCount, endItemView) =>
|
||||||
|
data.items.isEmpty
|
||||||
|
? SliverToBoxAdapter(
|
||||||
|
child: _buildEmptyDirectoryHint(ref, currentPath),
|
||||||
|
)
|
||||||
|
: _buildFileListContent(
|
||||||
|
data.items,
|
||||||
|
widgetCount,
|
||||||
|
endItemView,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
currentPath,
|
||||||
|
viewMode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
return DropTarget(
|
||||||
|
onDragDone: (details) async {
|
||||||
|
dragging.value = false;
|
||||||
|
// Handle file upload
|
||||||
|
for (final file in details.files) {
|
||||||
|
final universalFile = UniversalFile(
|
||||||
|
data: file,
|
||||||
|
type: UniversalFileType.file,
|
||||||
|
displayName: file.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
final completer = FileUploader.createCloudFile(
|
||||||
|
fileData: universalFile,
|
||||||
|
ref: ref,
|
||||||
|
path: currentPath.value,
|
||||||
|
onProgress: (progress, _) {
|
||||||
|
// Progress is handled by the upload tasks system
|
||||||
|
if (progress != null) {
|
||||||
|
debugPrint('Upload progress: ${(progress * 100).toInt()}%');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
completer.future
|
||||||
|
.then((uploadedFile) {
|
||||||
|
if (uploadedFile != null) {
|
||||||
|
ref.invalidate(cloudFileListNotifierProvider);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catchError((error) {
|
||||||
|
showSnackBar('Failed to upload file: $error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragEntered: (details) {
|
||||||
|
dragging.value = true;
|
||||||
|
},
|
||||||
|
onDragExited: (details) {
|
||||||
|
dragging.value = false;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
color:
|
||||||
|
dragging.value
|
||||||
|
? Theme.of(context).primaryColor.withOpacity(0.1)
|
||||||
|
: null,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Gap(8),
|
||||||
|
_buildPathNavigation(ref, currentPath),
|
||||||
|
const Gap(8),
|
||||||
|
if (mode.value == FileListMode.normal && currentPath.value == '/')
|
||||||
|
_buildUnindexedFilesEntry(ref).padding(bottom: 12),
|
||||||
|
Expanded(
|
||||||
|
child: CustomScrollView(
|
||||||
|
slivers: [bodyWidget, const SliverGap(12)],
|
||||||
|
).padding(
|
||||||
|
horizontal:
|
||||||
|
viewMode.value == FileListViewMode.waterfall ? 12 : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileListContent(
|
||||||
|
List<FileListItem> items,
|
||||||
|
int widgetCount,
|
||||||
|
Widget endItemView,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
ValueNotifier<String> currentPath,
|
||||||
|
ValueNotifier<FileListViewMode> currentViewMode,
|
||||||
|
) {
|
||||||
|
return switch (currentViewMode.value) {
|
||||||
|
// Waterfall mode
|
||||||
|
FileListViewMode.waterfall => SliverMasonryGrid(
|
||||||
|
gridDelegate: SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: isWideScreen(context) ? 340 : 240,
|
||||||
|
),
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= items.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
return item.map(
|
||||||
|
file: (fileItem) => _buildWaterfallFileTile(fileItem, ref, context),
|
||||||
|
folder:
|
||||||
|
(folderItem) =>
|
||||||
|
_buildWaterfallFolderTile(folderItem, currentPath, context),
|
||||||
|
unindexedFile: (unindexedFileItem) {
|
||||||
|
// Should not happen
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, childCount: widgetCount),
|
||||||
|
),
|
||||||
|
// ListView mode
|
||||||
|
_ => SliverList.builder(
|
||||||
|
itemCount: widgetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
return item.map(
|
||||||
|
file: (fileItem) {
|
||||||
|
final file = fileItem.fileIndex.file;
|
||||||
|
return ListTile(
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
child: getFileIcon(file, size: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title:
|
||||||
|
file.name.isEmpty
|
||||||
|
? Text('untitled').tr().italic()
|
||||||
|
: Text(
|
||||||
|
file.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(formatFileSize(file.size)),
|
||||||
|
onTap: () {
|
||||||
|
context.push('/files/${fileItem.fileIndex.id}', extra: file);
|
||||||
|
},
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Symbols.delete),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirmed = await showConfirmAlert(
|
||||||
|
'confirmDeleteFile'.tr(),
|
||||||
|
'deleteFile'.tr(),
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showLoadingModal(context);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
await client.delete(
|
||||||
|
'/drive/index/remove/${fileItem.fileIndex.id}',
|
||||||
|
);
|
||||||
|
ref.invalidate(cloudFileListNotifierProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('failedToDeleteFile'.tr());
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
folder:
|
||||||
|
(folderItem) => ListTile(
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
child: const Icon(Symbols.folder, fill: 1).center(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
folderItem.folderName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: const Text('Folder'),
|
||||||
|
onTap: () {
|
||||||
|
final newPath =
|
||||||
|
currentPath.value == '/'
|
||||||
|
? '/${folderItem.folderName}'
|
||||||
|
: '${currentPath.value}/${folderItem.folderName}';
|
||||||
|
currentPath.value = newPath;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
unindexedFile: (unindexedFileItem) {
|
||||||
|
// Should not happen in normal mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPathNavigation(
|
||||||
|
WidgetRef ref,
|
||||||
|
ValueNotifier<String> currentPath,
|
||||||
|
) {
|
||||||
|
Widget pathContent;
|
||||||
|
if (mode.value == FileListMode.unindexed) {
|
||||||
|
pathContent = Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Unindexed Files',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else if (currentPath.value == '/') {
|
||||||
|
pathContent = Text(
|
||||||
|
'Root Directory',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final pathParts =
|
||||||
|
currentPath.value
|
||||||
|
.split('/')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
final breadcrumbs = <Widget>[];
|
||||||
|
|
||||||
|
// Add root
|
||||||
|
breadcrumbs.add(
|
||||||
|
InkWell(onTap: () => currentPath.value = '/', child: Text('Root')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add path parts
|
||||||
|
String currentPathBuilder = '';
|
||||||
|
for (int i = 0; i < pathParts.length; i++) {
|
||||||
|
currentPathBuilder += '/${pathParts[i]}';
|
||||||
|
final path = currentPathBuilder;
|
||||||
|
|
||||||
|
breadcrumbs.add(const Text(' / '));
|
||||||
|
if (i == pathParts.length - 1) {
|
||||||
|
// Current directory
|
||||||
|
breadcrumbs.add(
|
||||||
|
Text(pathParts[i], style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Clickable parent directory
|
||||||
|
breadcrumbs.add(
|
||||||
|
InkWell(
|
||||||
|
onTap: () => currentPath.value = path,
|
||||||
|
child: Text(pathParts[i]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pathContent = Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: breadcrumbs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 64,
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
mode.value == FileListMode.unindexed
|
||||||
|
? Symbols.inventory_2
|
||||||
|
: currentPath.value != '/'
|
||||||
|
? Symbols.arrow_back
|
||||||
|
: Symbols.folder,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
if (mode.value == FileListMode.unindexed) {
|
||||||
|
mode.value = FileListMode.normal;
|
||||||
|
currentPath.value = '/';
|
||||||
|
} else {
|
||||||
|
final pathParts =
|
||||||
|
currentPath.value
|
||||||
|
.split('/')
|
||||||
|
.where((part) => part.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
if (pathParts.isNotEmpty) {
|
||||||
|
pathParts.removeLast();
|
||||||
|
currentPath.value =
|
||||||
|
pathParts.isEmpty ? '/' : '/${pathParts.join('/')}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Expanded(child: pathContent),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
viewMode.value == FileListViewMode.list
|
||||||
|
? Symbols.view_module
|
||||||
|
: Symbols.list,
|
||||||
|
),
|
||||||
|
onPressed:
|
||||||
|
() =>
|
||||||
|
viewMode.value =
|
||||||
|
viewMode.value == FileListViewMode.list
|
||||||
|
? FileListViewMode.waterfall
|
||||||
|
: FileListViewMode.list,
|
||||||
|
tooltip:
|
||||||
|
viewMode.value == FileListViewMode.list
|
||||||
|
? 'Switch to Waterfall View'
|
||||||
|
: 'Switch to List View',
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (mode.value == FileListMode.normal) ...[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.create_new_folder),
|
||||||
|
onPressed:
|
||||||
|
() => onShowCreateDirectory(ref.context, currentPath),
|
||||||
|
tooltip: 'Create Directory',
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.upload_file),
|
||||||
|
onPressed: onPickAndUpload,
|
||||||
|
tooltip: 'Upload File',
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).padding(horizontal: 8),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUnindexedFilesEntry(WidgetRef ref) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Theme.of(ref.context).colorScheme.outline),
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
),
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.inventory_2).padding(horizontal: 8),
|
||||||
|
const Gap(8),
|
||||||
|
const Text('Unindexed Files').bold(),
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Symbols.chevron_right).padding(horizontal: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
mode.value = FileListMode.unindexed;
|
||||||
|
currentPath.value = '/';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyDirectoryHint(
|
||||||
|
WidgetRef ref,
|
||||||
|
ValueNotifier<String> currentPath,
|
||||||
|
) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.fromLTRB(12, 0, 12, 16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.folder_off, size: 64, color: Colors.grey),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
'This directory is empty',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(ref.context).textTheme.bodyLarge?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'Upload files or create subdirectories to populate this path.\n'
|
||||||
|
'Directories are created implicitly when you upload files to them.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
ref.context,
|
||||||
|
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: onPickAndUpload,
|
||||||
|
icon: const Icon(Symbols.upload_file),
|
||||||
|
label: const Text('Upload Files'),
|
||||||
|
),
|
||||||
|
const Gap(12),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
() => onShowCreateDirectory(ref.context, currentPath),
|
||||||
|
icon: const Icon(Symbols.create_new_folder),
|
||||||
|
label: const Text('Create Directory'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallFileTile(
|
||||||
|
FileItem fileItem,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
return _buildWaterfallFileTileBase(
|
||||||
|
fileItem.fileIndex.file,
|
||||||
|
() => '/files/${fileItem.fileIndex.id}',
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.delete),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirmed = await showConfirmAlert(
|
||||||
|
'confirmDeleteFile'.tr(),
|
||||||
|
'deleteFile'.tr(),
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showLoadingModal(context);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
await client.delete(
|
||||||
|
'/drive/index/remove/${fileItem.fileIndex.id}',
|
||||||
|
);
|
||||||
|
ref.invalidate(cloudFileListNotifierProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('failedToDeleteFile'.tr());
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallFileTileBase(
|
||||||
|
SnCloudFile file,
|
||||||
|
String Function() getRoutePath,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
List<Widget>? actions,
|
||||||
|
) {
|
||||||
|
final meta = file.fileMeta is Map ? (file.fileMeta as Map) : const {};
|
||||||
|
final ratio =
|
||||||
|
meta['ratio'] is num ? (meta['ratio'] as num).toDouble() : 1.0;
|
||||||
|
final itemType = file.mimeType?.split('/').first;
|
||||||
|
final uri =
|
||||||
|
'${ref.read(apiClientProvider).options.baseUrl}/drive/files/${file.id}';
|
||||||
|
|
||||||
|
Widget previewWidget;
|
||||||
|
switch (itemType) {
|
||||||
|
case 'image':
|
||||||
|
previewWidget = CloudImageWidget(
|
||||||
|
file: file,
|
||||||
|
aspectRatio: ratio,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'video':
|
||||||
|
previewWidget = CloudVideoWidget(item: file);
|
||||||
|
break;
|
||||||
|
case 'audio':
|
||||||
|
previewWidget = getFileIcon(file, size: 48);
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
previewWidget = Container(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
child: FutureBuilder<String>(
|
||||||
|
future: ref
|
||||||
|
.read(apiClientProvider)
|
||||||
|
.get(uri)
|
||||||
|
.then((response) => response.data as String),
|
||||||
|
builder:
|
||||||
|
(context, snapshot) =>
|
||||||
|
snapshot.hasData
|
||||||
|
? SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 9,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
),
|
||||||
|
maxLines: 20,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'application' when file.mimeType == 'application/pdf':
|
||||||
|
previewWidget = SfPdfViewer.network(
|
||||||
|
uri,
|
||||||
|
canShowScrollStatus: false,
|
||||||
|
canShowScrollHead: false,
|
||||||
|
enableDoubleTapZooming: false,
|
||||||
|
pageSpacing: 0,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
previewWidget = getFileIcon(file, size: 48);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
context.push(getRoutePath(), extra: file);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(8),
|
||||||
|
topRight: Radius.circular(8),
|
||||||
|
),
|
||||||
|
child: AspectRatio(
|
||||||
|
aspectRatio: ratio,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Container(color: Colors.white, child: previewWidget),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
getFileIcon(file, size: 24, tinyPreview: false),
|
||||||
|
const Gap(16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
formatFileSize(file.size),
|
||||||
|
maxLines: 1,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall!.copyWith(fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (actions != null) ...actions,
|
||||||
|
],
|
||||||
|
).padding(horizontal: 16, vertical: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallFolderTile(
|
||||||
|
FolderItem folderItem,
|
||||||
|
ValueNotifier<String> currentPath,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
final newPath =
|
||||||
|
currentPath.value == '/'
|
||||||
|
? '/${folderItem.folderName}'
|
||||||
|
: '${currentPath.value}/${folderItem.folderName}';
|
||||||
|
currentPath.value = newPath;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Theme.of(context).colorScheme.outline.withOpacity(0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Symbols.folder,
|
||||||
|
fill: 1,
|
||||||
|
size: 24,
|
||||||
|
color: Theme.of(context).colorScheme.primaryFixedDim,
|
||||||
|
),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
folderItem.folderName,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildUnindexedFileListContent(
|
||||||
|
List<FileListItem> items,
|
||||||
|
int widgetCount,
|
||||||
|
Widget endItemView,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
ValueNotifier<FileListViewMode> currentViewMode,
|
||||||
|
) {
|
||||||
|
return switch (currentViewMode.value) {
|
||||||
|
// Waterfall mode
|
||||||
|
FileListViewMode.waterfall => SliverMasonryGrid(
|
||||||
|
gridDelegate: SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: isWideScreen(context) ? 340 : 240,
|
||||||
|
),
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index >= items.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
return item.map(
|
||||||
|
file: (fileItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
folder: (folderItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
unindexedFile:
|
||||||
|
(unindexedFileItem) => _buildWaterfallUnindexedFileTile(
|
||||||
|
unindexedFileItem,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, childCount: widgetCount),
|
||||||
|
),
|
||||||
|
// ListView mode
|
||||||
|
_ => SliverList.builder(
|
||||||
|
itemCount: widgetCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index == widgetCount - 1) {
|
||||||
|
return endItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
final item = items[index];
|
||||||
|
return item.map(
|
||||||
|
file: (fileItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
folder: (folderItem) {
|
||||||
|
// Should not happen in unindexed mode
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
unindexedFile:
|
||||||
|
(unindexedFileItem) => _buildListUnindexedFileTile(
|
||||||
|
unindexedFileItem,
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaterfallUnindexedFileTile(
|
||||||
|
UnindexedFileItem unindexedFileItem,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
return _buildWaterfallFileTileBase(
|
||||||
|
unindexedFileItem.file,
|
||||||
|
() => '/files/${unindexedFileItem.file.id}',
|
||||||
|
ref,
|
||||||
|
context,
|
||||||
|
[
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Symbols.delete),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirmed = await showConfirmAlert(
|
||||||
|
'confirmDeleteFile'.tr(),
|
||||||
|
'deleteFile'.tr(),
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showLoadingModal(context);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
await client.delete('/drive/files/${unindexedFileItem.file.id}');
|
||||||
|
ref.invalidate(unindexedFileListNotifierProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('failedToDeleteFile'.tr());
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildListUnindexedFileTile(
|
||||||
|
UnindexedFileItem unindexedFileItem,
|
||||||
|
WidgetRef ref,
|
||||||
|
BuildContext context,
|
||||||
|
) {
|
||||||
|
final file = unindexedFileItem.file;
|
||||||
|
return ListTile(
|
||||||
|
leading: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 48,
|
||||||
|
width: 48,
|
||||||
|
child: getFileIcon(file, size: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title:
|
||||||
|
file.name.isEmpty
|
||||||
|
? Text('untitled').tr().italic()
|
||||||
|
: Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
subtitle: Text(formatFileSize(file.size)),
|
||||||
|
onTap: () {
|
||||||
|
context.push('/files/${file.id}', extra: file);
|
||||||
|
},
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Symbols.delete),
|
||||||
|
onPressed: () async {
|
||||||
|
final confirmed = await showConfirmAlert(
|
||||||
|
'confirmDeleteFile'.tr(),
|
||||||
|
'deleteFile'.tr(),
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showLoadingModal(context);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final client = ref.read(apiClientProvider);
|
||||||
|
await client.delete('/drive/files/${file.id}');
|
||||||
|
ref.invalidate(unindexedFileListNotifierProvider);
|
||||||
|
} catch (e) {
|
||||||
|
showSnackBar('failedToDeleteFile'.tr());
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) {
|
||||||
|
hideLoadingModal(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.fromLTRB(16, 0, 16, 0),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Symbols.inventory_2, size: 64, color: Colors.grey),
|
||||||
|
const Gap(16),
|
||||||
|
Text(
|
||||||
|
'No unindexed files',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Theme.of(ref.context).textTheme.bodyLarge?.color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Gap(8),
|
||||||
|
Text(
|
||||||
|
'All files have been assigned to paths.\n'
|
||||||
|
'Files without paths will appear here.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(
|
||||||
|
ref.context,
|
||||||
|
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,16 @@ import 'package:gap/gap.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/pods/userinfo.dart';
|
||||||
|
import 'package:island/screens/auth/create_account_modal.dart';
|
||||||
|
import 'package:island/screens/auth/login_modal.dart';
|
||||||
import 'package:island/screens/notification.dart';
|
import 'package:island/screens/notification.dart';
|
||||||
import 'package:island/services/event_bus.dart';
|
import 'package:island/services/event_bus.dart';
|
||||||
|
import 'package:island/services/responsive.dart';
|
||||||
import 'package:island/widgets/account/account_picker.dart';
|
import 'package:island/widgets/account/account_picker.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
import 'package:island/widgets/post/compose_sheet.dart';
|
import 'package:island/widgets/post/compose_sheet.dart';
|
||||||
|
import 'package:island/screens/chat/chat_form.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
|
||||||
enum FabMenuType { main, compose, chat, realm }
|
enum FabMenuType { main, compose, chat, realm }
|
||||||
@@ -50,16 +55,46 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
late final bool useRootNavigator;
|
late final bool useRootNavigator;
|
||||||
late final Widget menuContent;
|
late final Widget menuContent;
|
||||||
|
|
||||||
final commonEntires = <Widget>[
|
final unauthorizedEntires = <Widget>[
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
leading: const Icon(Symbols.bubble_chart),
|
leading: const Icon(Symbols.login),
|
||||||
title: Text('aiThoughtTitle').tr(),
|
title: Text('login').tr(),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.of(context).pop();
|
showModalBottomSheet(
|
||||||
context.pushNamed('thought');
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => LoginModal(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.person_add),
|
||||||
|
title: Text('createAccount').tr(),
|
||||||
|
onTap: () async {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
useRootNavigator: true,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (context) => CreateAccountModal(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
final authorizedEntires = <Widget>[
|
||||||
|
if (!isWideScreen(context))
|
||||||
|
ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
leading: const Icon(Symbols.bubble_chart),
|
||||||
|
title: Text('aiThoughtTitle').tr(),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
context.goNamed('thought');
|
||||||
|
},
|
||||||
|
),
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
final notificationCount = ref.watch(
|
final notificationCount = ref.watch(
|
||||||
@@ -87,6 +122,10 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
final userInfo = ref.watch(userInfoProvider);
|
||||||
|
final authorized = userInfo.value != null;
|
||||||
|
final commonEntires = authorized ? authorizedEntires : unauthorizedEntires;
|
||||||
|
|
||||||
switch (fabType) {
|
switch (fabType) {
|
||||||
case FabMenuType.compose:
|
case FabMenuType.compose:
|
||||||
icon = Symbols.create;
|
icon = Symbols.create;
|
||||||
@@ -95,25 +134,28 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
ListTile(
|
if (authorized)
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
...([
|
||||||
leading: const Icon(Symbols.post_add_rounded),
|
ListTile(
|
||||||
title: Text('postCompose').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
onTap: () async {
|
leading: const Icon(Symbols.post_add_rounded),
|
||||||
Navigator.of(context).pop();
|
title: Text('postCompose').tr(),
|
||||||
await PostComposeSheet.show(context);
|
onTap: () async {
|
||||||
},
|
Navigator.of(context).pop();
|
||||||
),
|
await PostComposeSheet.show(context);
|
||||||
ListTile(
|
},
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
),
|
||||||
leading: const Icon(Symbols.article),
|
ListTile(
|
||||||
title: Text('articleCompose').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
onTap: () async {
|
leading: const Icon(Symbols.article),
|
||||||
Navigator.of(context).pop();
|
title: Text('articleCompose').tr(),
|
||||||
GoRouter.of(context).pushNamed('articleCompose');
|
onTap: () async {
|
||||||
},
|
Navigator.of(context).pop();
|
||||||
),
|
GoRouter.of(context).pushNamed('articleCompose');
|
||||||
const Divider(),
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
]),
|
||||||
...commonEntires,
|
...commonEntires,
|
||||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
@@ -128,29 +170,35 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
ListTile(
|
if (authorized)
|
||||||
title: const Text('createChatRoom').tr(),
|
...([
|
||||||
leading: const Icon(Symbols.add),
|
ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
title: const Text('createChatRoom').tr(),
|
||||||
onTap: () {
|
leading: const Icon(Symbols.add),
|
||||||
Navigator.pop(context);
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
context.pushNamed('chatNew').then((value) {
|
onTap: () {
|
||||||
if (value != null) {
|
showModalBottomSheet(
|
||||||
eventBus.fire(const ChatRoomsRefreshEvent());
|
context: context,
|
||||||
}
|
useRootNavigator: true,
|
||||||
});
|
isScrollControlled: true,
|
||||||
},
|
builder: (context) => const EditChatScreen(),
|
||||||
),
|
).then((value) {
|
||||||
ListTile(
|
if (value != null) {
|
||||||
title: const Text('createDirectMessage').tr(),
|
eventBus.fire(const ChatRoomsRefreshEvent());
|
||||||
leading: const Icon(Symbols.person),
|
}
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
});
|
||||||
onTap: () {
|
},
|
||||||
Navigator.pop(context);
|
),
|
||||||
_createDirectMessage(context, ref);
|
ListTile(
|
||||||
},
|
title: const Text('createDirectMessage').tr(),
|
||||||
),
|
leading: const Icon(Symbols.person),
|
||||||
const Divider(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
|
onTap: () {
|
||||||
|
_createDirectMessage(context, ref);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
]),
|
||||||
...commonEntires,
|
...commonEntires,
|
||||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
@@ -164,21 +212,24 @@ class FabMenu extends HookConsumerWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
ListTile(
|
if (authorized)
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
...([
|
||||||
leading: const Icon(Symbols.group_add),
|
ListTile(
|
||||||
title: Text('createRealm').tr(),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
onTap: () {
|
leading: const Icon(Symbols.group_add),
|
||||||
Navigator.of(context).pop();
|
title: Text('createRealm').tr(),
|
||||||
context.pushNamed('realmNew').then((value) {
|
onTap: () {
|
||||||
if (value != null) {
|
Navigator.of(context).pop();
|
||||||
// Fire realm refresh event if needed
|
context.pushNamed('realmNew').then((value) {
|
||||||
// eventBus.fire(const RealmsRefreshEvent());
|
if (value != null) {
|
||||||
}
|
// Fire realm refresh event if needed
|
||||||
});
|
// eventBus.fire(const RealmsRefreshEvent());
|
||||||
},
|
}
|
||||||
),
|
});
|
||||||
const Divider(),
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
]),
|
||||||
...commonEntires,
|
...commonEntires,
|
||||||
Gap(MediaQuery.of(context).padding.bottom + 16),
|
Gap(MediaQuery.of(context).padding.bottom + 16),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -62,9 +62,19 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
bool? _yesNoSelected;
|
bool? _yesNoSelected;
|
||||||
int? _ratingSelected; // 1..5
|
int? _ratingSelected; // 1..5
|
||||||
|
|
||||||
|
/// Flag to track if user has edited the current question to prevent provider rebuilds from resetting state
|
||||||
|
bool _userHasEdited = false;
|
||||||
|
|
||||||
|
/// Listener for text controller to mark as edited when user types
|
||||||
|
late final VoidCallback _controllerListener;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_controllerListener = () {
|
||||||
|
_userHasEdited = true;
|
||||||
|
};
|
||||||
|
_textController.addListener(_controllerListener);
|
||||||
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
|
||||||
// Set initial collapse state based on the parameter
|
// Set initial collapse state based on the parameter
|
||||||
_isCollapsed = !widget.isInitiallyExpanded;
|
_isCollapsed = !widget.isInitiallyExpanded;
|
||||||
@@ -75,6 +85,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
_isModifying = false;
|
_isModifying = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Load initial answers into local state
|
||||||
|
if (_questions != null) {
|
||||||
|
_loadCurrentIntoLocalState();
|
||||||
|
_userHasEdited = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _initializeFromPollData(SnPollWithStats poll) {
|
void _initializeFromPollData(SnPollWithStats poll) {
|
||||||
@@ -101,6 +116,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_textController.removeListener(_controllerListener);
|
||||||
_textController.dispose();
|
_textController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -111,30 +127,35 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
final q = _current;
|
final q = _current;
|
||||||
final saved = _answers[q.id];
|
final saved = _answers[q.id];
|
||||||
|
|
||||||
_singleChoiceSelected = null;
|
if (!_userHasEdited) {
|
||||||
_multiChoiceSelected.clear();
|
_singleChoiceSelected = null;
|
||||||
_yesNoSelected = null;
|
_multiChoiceSelected.clear();
|
||||||
_ratingSelected = null;
|
_yesNoSelected = null;
|
||||||
_textController.text = '';
|
_ratingSelected = null;
|
||||||
|
|
||||||
switch (q.type) {
|
switch (q.type) {
|
||||||
case SnPollQuestionType.singleChoice:
|
case SnPollQuestionType.singleChoice:
|
||||||
if (saved is String) _singleChoiceSelected = saved;
|
if (saved is String) _singleChoiceSelected = saved;
|
||||||
break;
|
break;
|
||||||
case SnPollQuestionType.multipleChoice:
|
case SnPollQuestionType.multipleChoice:
|
||||||
if (saved is List) {
|
if (saved is List) {
|
||||||
_multiChoiceSelected.addAll(saved.whereType<String>());
|
_multiChoiceSelected.addAll(saved.whereType<String>());
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case SnPollQuestionType.yesNo:
|
case SnPollQuestionType.yesNo:
|
||||||
if (saved is bool) _yesNoSelected = saved;
|
if (saved is bool) _yesNoSelected = saved;
|
||||||
break;
|
break;
|
||||||
case SnPollQuestionType.rating:
|
case SnPollQuestionType.rating:
|
||||||
if (saved is int) _ratingSelected = saved;
|
if (saved is int) _ratingSelected = saved;
|
||||||
break;
|
break;
|
||||||
case SnPollQuestionType.freeText:
|
case SnPollQuestionType.freeText:
|
||||||
if (saved is String) _textController.text = saved;
|
if (saved is String) {
|
||||||
break;
|
_textController.removeListener(_controllerListener);
|
||||||
|
_textController.text = saved;
|
||||||
|
_textController.addListener(_controllerListener);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +235,9 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
data: {'answer': _answers},
|
data: {'answer': _answers},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Refresh poll data to show submitted answer
|
||||||
|
ref.invalidate(pollWithStatsProvider(widget.pollId));
|
||||||
|
|
||||||
// Only call onSubmit after server accepts
|
// Only call onSubmit after server accepts
|
||||||
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
|
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
|
||||||
|
|
||||||
@@ -236,6 +260,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
if (_index < _questions!.length - 1) {
|
if (_index < _questions!.length - 1) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index++;
|
_index++;
|
||||||
|
_userHasEdited = false;
|
||||||
_loadCurrentIntoLocalState();
|
_loadCurrentIntoLocalState();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -250,6 +275,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
if (_index > 0) {
|
if (_index > 0) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_index--;
|
_index--;
|
||||||
|
_userHasEdited = false;
|
||||||
_loadCurrentIntoLocalState();
|
_loadCurrentIntoLocalState();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -342,7 +368,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
RadioListTile<String>(
|
RadioListTile<String>(
|
||||||
value: opt.id,
|
value: opt.id,
|
||||||
groupValue: _singleChoiceSelected,
|
groupValue: _singleChoiceSelected,
|
||||||
onChanged: (val) => setState(() => _singleChoiceSelected = val),
|
onChanged:
|
||||||
|
(val) => setState(() {
|
||||||
|
_singleChoiceSelected = val;
|
||||||
|
_userHasEdited = true;
|
||||||
|
}),
|
||||||
title: Text(opt.label),
|
title: Text(opt.label),
|
||||||
subtitle: opt.description != null ? Text(opt.description!) : null,
|
subtitle: opt.description != null ? Text(opt.description!) : null,
|
||||||
),
|
),
|
||||||
@@ -364,6 +394,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
} else {
|
} else {
|
||||||
_multiChoiceSelected.remove(opt.id);
|
_multiChoiceSelected.remove(opt.id);
|
||||||
}
|
}
|
||||||
|
_userHasEdited = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
title: Text(opt.label),
|
title: Text(opt.label),
|
||||||
@@ -386,6 +417,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
onSelectionChanged: (sel) {
|
onSelectionChanged: (sel) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_yesNoSelected = sel.isEmpty ? null : sel.first;
|
_yesNoSelected = sel.isEmpty ? null : sel.first;
|
||||||
|
_userHasEdited = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
multiSelectionEnabled: false,
|
multiSelectionEnabled: false,
|
||||||
@@ -411,6 +443,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_ratingSelected = value;
|
_ratingSelected = value;
|
||||||
|
_userHasEdited = true;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -422,7 +455,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
return TextField(
|
return TextField(
|
||||||
controller: _textController,
|
controller: _textController,
|
||||||
maxLines: 6,
|
maxLines: 6,
|
||||||
decoration: const InputDecoration(border: OutlineInputBorder()),
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,6 +478,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isModifying = true;
|
_isModifying = true;
|
||||||
_index = 0; // Reset to first question for modification
|
_index = 0; // Reset to first question for modification
|
||||||
|
_userHasEdited = false;
|
||||||
_loadCurrentIntoLocalState();
|
_loadCurrentIntoLocalState();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -487,32 +525,6 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (poll.title != null || poll.description != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (poll.title?.isNotEmpty ?? false)
|
|
||||||
Text(
|
|
||||||
poll.title!,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
if (poll.description?.isNotEmpty ?? false)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 4),
|
|
||||||
child: Text(
|
|
||||||
poll.description!,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyMedium?.color?.withOpacity(0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final q in _questions!)
|
for (final q in _questions!)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16.0),
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ class ArticleComposeAttachments extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
ValueListenableBuilder<Map<int, double>>(
|
ValueListenableBuilder<Map<int, double?>>(
|
||||||
valueListenable: state.attachmentProgress,
|
valueListenable: state.attachmentProgress,
|
||||||
builder: (context, progressMap, _) {
|
builder: (context, progressMap, _) {
|
||||||
return Wrap(
|
return Wrap(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user