Compare commits

..

76 Commits

Author SHA1 Message Date
24791b3293 🎉 Launch 3.3.0+146 2025-11-17 01:33:34 +08:00
3ac263d483 🐛 Fix build on web 2025-11-17 01:26:34 +08:00
2445d8adf8 💄 Optimzations 2025-11-17 01:23:27 +08:00
d4f95bbbf4 Claim fund 2025-11-17 01:20:49 +08:00
943e4b7b5c 🐛 Fix edit post didn't inherent poll and fund 2025-11-16 23:58:36 +08:00
7edc02a1d3 ♻️ No longer two submit post function 2025-11-16 23:54:50 +08:00
3f9881e943 Fund creation and attach found to message 2025-11-16 23:43:28 +08:00
50c25e919c 🐛 Bug fixes in cloud file collection 2025-11-16 23:00:14 +08:00
99fb08dd55 Send message with poll 2025-11-16 22:43:18 +08:00
e43bc6b8a8 💄 Optimize poll 2025-11-16 22:29:24 +08:00
c247cdf81c ♻️ Refactored poll editor 2025-11-16 22:15:10 +08:00
3ffa730505 💄 Optimize chat input expand style 2025-11-16 21:49:41 +08:00
1cc34d3073 Chat input expansiable section basis 2025-11-16 21:42:10 +08:00
96a919cc4e 💫 Animated height padding in inputs 2025-11-16 20:20:24 +08:00
e7e3bfcadf 🐛 Fix oidc callback 2025-11-16 18:38:36 +08:00
a8617a5040 💄 Collspible link embeds 2025-11-16 18:23:24 +08:00
d94f8d004f 💄 Shows friends overview on mobile as well 2025-11-16 18:10:55 +08:00
d93b066979 📝 Rename the currencies 2025-11-16 17:59:10 +08:00
320664a547 💄 Friends overview optimization 2025-11-16 17:50:36 +08:00
98f4698d5b 🐛 Fixes of serval bugs 2025-11-16 17:40:37 +08:00
82397dd087 Friends overview basis 2025-11-16 17:26:31 +08:00
4ec10ceb47 ♻️ Rework of the oidc login flow (wip) 2025-11-16 17:07:34 +08:00
4b03b45a0d 💄 Create account, login modal varaint and more auth check to prevent user from entering certain widget in unauthorized state 2025-11-16 14:53:40 +08:00
7a72d32649 🐛 Fix publisher page account avatar no gesture detector, close #188 2025-11-16 12:06:48 +08:00
5152dd13ea 💄 Continue optimize the post reaction sheet 2025-11-16 12:04:13 +08:00
fd377aa7af 💄 Change the post reaction sheet sticker picker align and close 2025-11-16 11:56:49 +08:00
67044148f1 💄 Fix chat input overlaps with message sometimes 2025-11-16 11:48:51 +08:00
92bc43e4df 🐛 Fix chat creation cause stack has no page 2025-11-16 11:48:35 +08:00
a1a7b34c86 ♻️ Use bottom modal sheet for chat creation form 2025-11-16 11:46:31 +08:00
40c0e052cf 💄 Optimize thought input space to avoid input cover message 2025-11-16 11:22:47 +08:00
9a75228e38 Multi model support in thought 2025-11-16 02:45:02 +08:00
a9fd75cc45 Thinking billing check 2025-11-16 01:18:20 +08:00
a713b30d93 🐛 Fix bugs 2025-11-16 00:52:17 +08:00
e516f0a862 🐛 Bug fixes 2025-11-16 00:34:10 +08:00
429b966c4b 🐛 Fix wrong tool call progress status 2025-11-15 23:22:33 +08:00
f14da0d3a2 💫 Add tool call calling hint animation 2025-11-15 23:22:07 +08:00
d201182bd2 ♻️ Turn thought into a Tab 2025-11-15 23:08:31 +08:00
6f6422c15e 💄 Optimize thought function call style 2025-11-15 23:02:25 +08:00
9f6ae639ee 🐛 Fix publisher member management missing service id to use sphere API 2025-11-15 22:40:27 +08:00
35f4d7d885 ♻️ Updated the thought rendering 2025-11-15 22:16:29 +08:00
a9c8f49797 💄 Optimize thoughts 2025-11-15 21:15:41 +08:00
5e9341a19c ♻️ Refactored the thinking 2025-11-15 17:10:36 +08:00
645a6dca93 ♻️ Refactor the thought insight to support new API 2025-11-15 16:59:22 +08:00
ea8e7ead2d 💄 Add go to previous path action in path nav in file list 2025-11-15 16:25:24 +08:00
5f2f083d72 ♻️ Fixes and optimizations in file list 2025-11-15 16:20:05 +08:00
5cf40e27de 💄 Optimized the waterfall file list style 2025-11-15 16:05:42 +08:00
1ab7295918 💄 Optimize waterfall file list design 2025-11-15 15:54:13 +08:00
07f191171c Waterfall layout in files (w.i.p) 2025-11-15 15:42:09 +08:00
4a5dac248e ♻️ Dedicated file viewer widget 2025-11-15 15:08:49 +08:00
3b983a6444 ♻️ Refactored the file detail 2025-11-15 15:04:01 +08:00
4607b77355 ♻️ Better file icons 2025-11-15 13:36:00 +08:00
7957e4894a File list drag and drop 2025-11-15 13:22:05 +08:00
f94f80c375 👽 Update the indexed file api calls 2025-11-15 03:06:41 +08:00
74fa2215a6 Unindexed files 2025-11-15 02:59:20 +08:00
0d11435feb ⬆️ Upgrade dependecies 2025-11-15 01:44:10 +08:00
e22598b0a6 🔨 Preview of the watchOS app main screen 2025-11-14 21:41:04 +08:00
84cfe643f5 👽 Adopt the new folder system (w.i.p) 2025-11-14 01:04:15 +08:00
05ac04e9a2 Enchaned file detail screen 2025-11-13 01:44:55 +08:00
66f283d6e8 Renders file folders in drive 2025-11-13 01:31:58 +08:00
c779c7523c FIle index 2025-11-12 22:09:22 +08:00
ac7cb29afe ♻️ Improved the files screen 2025-11-11 00:49:07 +08:00
935aa77223 ♻️ Turn the file screen into a tab 2025-11-11 00:39:49 +08:00
24e5b3b824 Account tab icon shows pfp 2025-11-11 00:39:35 +08:00
0391893b32 🐛 Bug fixes in upload task tracking 2025-11-11 00:00:09 +08:00
b8d24876c8 🚚 Rename upload task to drive task 2025-11-10 01:56:22 +08:00
0493661f9a 🐛 Fixes and optimizations 2025-11-10 01:49:38 +08:00
b40afde00f 💫 Animated the upload overlay 2025-11-10 01:40:28 +08:00
78a4022531 💄 Optimize upload overlay styling 2025-11-10 01:27:06 +08:00
8a291c80b7 Upload tasks overlay 2025-11-10 01:11:43 +08:00
1395d65b76 ♻️ Refactored publisher creation into sheet 2025-11-09 21:18:34 +08:00
eb4942e0ed 💄 Optimize account card style when no background 2025-11-09 14:28:53 +08:00
f254cfa81e ♻️ Refactored the captcha 2025-11-09 14:21:33 +08:00
4927795260 ♻️ Refactored the upload files according to new backend tasks 2025-11-09 14:09:26 +08:00
e4019dadc8 💄 Optimize file upload prograss indicates 2025-11-09 01:59:24 +08:00
5e7d77e1a1 🐛 Fix share sheet error 2025-11-08 20:05:18 +08:00
bfcbed035c ♻️ Refactored file uploading 2025-11-08 20:04:54 +08:00
123 changed files with 13422 additions and 4731 deletions

View File

@@ -136,6 +136,7 @@
"reactionNegative": "Negative",
"reactionNeutral": "Neutral",
"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",
"stickerPlaceholder": "Sticker Placeholder",
"reactionAttitude": "Reaction Attitude",
@@ -1087,6 +1088,7 @@
"levelingStage10": "Immortal",
"levelingStage11": "Divine",
"levelingStage12": "Transcendent",
"uploadTasks": "Upload Tasks",
"uploadAttachment": "Upload Attachment",
"attachmentPreview": "Attachment Preview",
"selectPool": "Select Pool",
@@ -1301,7 +1303,9 @@
"thoughtInputHint": "Ask sn-chan anything...",
"thoughtNewConversation": "Start New Conversation",
"thoughtParseError": "Failed to parse AI response",
"thoughtFunctionCall": "Function Call",
"thoughtFunctionCall": "Use {}",
"thoughtFunctionCallBegin": "Calling tool {}",
"thoughtFunctionCallFinish": "{} responded",
"aiThought": "AI Thought",
"aiThoughtTitle": "Let sn-chan think",
"postReferenceUnavailable": "Referenced post is unavailable",
@@ -1320,5 +1324,17 @@
"popularity": "Popularity",
"descendingOrder": "Descending Order",
"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"
}

View File

@@ -251,10 +251,10 @@
"translatorBadgeName": "翻译者",
"translatorBadgeDescription": "协助将 Solar Network 翻译成不同语言",
"wallet": "钱包",
"walletCurrencyPoints": "新太阳点",
"walletCurrencyPoints": "源能点",
"walletCurrencyShortPoints": "NSP",
"walletCurrencyGolds": "太阳币",
"walletCurrencyShortGolds": "TSD",
"walletCurrencyGolds": "星辰碎片",
"walletCurrencyShortGolds": "SHD",
"retry": "重试",
"creatorHubUnselectedHint": "选择/创建一个发布者以开始使用。",
"relationships": "关系",
@@ -1090,5 +1090,6 @@
"thoughtNewConversation": "开始新对话",
"thoughtParseError": "解析 AI 响应失败",
"aiThought": "寻思",
"aiThoughtTitle": "让 SN 酱寻思寻思"
"aiThoughtTitle": "让 SN 酱寻思寻思",
"thoughtUnpaidHint": "寻思因为有未支付的订单而被禁用"
}

View File

@@ -1,3 +1,6 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- drift: true
- provider: true
- shared_preferences: true

View File

@@ -1,7 +1,5 @@
PODS:
- Alamofire (5.10.2)
- app_links (6.4.1):
- Flutter
- connectivity_plus (0.0.1):
- Flutter
- croppy (0.0.1):
@@ -52,18 +50,18 @@ PODS:
- Firebase/Messaging (12.4.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.4.0)
- firebase_analytics (12.0.3):
- firebase_analytics (12.0.4):
- firebase_core
- FirebaseAnalytics (= 12.4.0)
- Flutter
- firebase_core (4.2.0):
- firebase_core (4.2.1):
- Firebase/CoreOnly (= 12.4.0)
- Flutter
- firebase_crashlytics (5.0.3):
- firebase_crashlytics (5.0.4):
- Firebase/Crashlytics (= 12.4.0)
- firebase_core
- Flutter
- firebase_messaging (16.0.3):
- firebase_messaging (16.0.4):
- Firebase/Messaging (= 12.4.0)
- firebase_core
- Flutter
@@ -265,6 +263,8 @@ PODS:
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- protocol_handler_ios (0.0.1):
- Flutter
- receive_sharing_intent (1.8.1):
- Flutter
- record_ios (1.1.0):
@@ -323,7 +323,6 @@ PODS:
DEPENDENCIES:
- Alamofire
- app_links (from `.symlinks/plugins/app_links/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- croppy (from `.symlinks/plugins/croppy/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
@@ -358,6 +357,7 @@ DEPENDENCIES:
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- 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`)
- record_ios (from `.symlinks/plugins/record_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
@@ -404,8 +404,6 @@ SPEC REPOS:
- WebRTC-SDK
EXTERNAL SOURCES:
app_links:
:path: ".symlinks/plugins/app_links/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
croppy:
@@ -470,6 +468,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios:
:path: ".symlinks/plugins/protocol_handler_ios/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
record_ios:
@@ -497,7 +497,6 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
croppy: 979e8ddc254f4642bffe7d52dc7193354b27ba30
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
@@ -506,10 +505,10 @@ SPEC CHECKSUMS:
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e
firebase_analytics: 1d024068b1d4707d5ba7a42a12976ddf3316d835
firebase_core: 744984dbbed8b3036abf34f0b98d80f130a7e464
firebase_crashlytics: f3a9a4338ab99b67042f64e9e22e1bf349cb44ed
firebase_messaging: 82c70650c426a0a14873e1acdb9ec2b443c4e8b4
firebase_analytics: 67fbdd9f3c04e55048024f3da21cfc36f05e56cf
firebase_core: f1aafb21c14f497e5498f7ffc4dc63cbb52b2594
firebase_crashlytics: 83c7467d7534975a4d779af43bd226d0a4616464
firebase_messaging: c17a29984eafce4b2997fe078bb0a9e0b06f5dde
FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f
FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3
FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018
@@ -553,6 +552,7 @@ SPEC CHECKSUMS:
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
protocol_handler_ios: 59f23ee71f3ec602d67902ca7f669a80957888d5
receive_sharing_intent: 222384f00ffe7e952bbfabaa9e3967cb87e5fe00
record_ios: f75fa1d57f840012775c0e93a38a7f3ceea1a374
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c

View File

@@ -48,3 +48,11 @@ struct ContentView: View {
}
}
}
// --- Placeholder Implementations for Preview ---
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View File

@@ -59,6 +59,8 @@ void main() async {
try {
await EasyLocalization.ensureInitialized();
// Disable logs
EasyLocalization.logger.enableBuildModes = [];
if (kIsWeb || !Platform.isLinux) {
await Firebase.initializeApp(

View File

@@ -1,4 +1,5 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:island/models/activity.dart';
import 'package:island/models/auth.dart';
import 'package:island/models/file.dart';
import 'package:island/models/wallet.dart';
@@ -263,3 +264,15 @@ sealed class SnSocialCreditRecord with _$SnSocialCreditRecord {
factory SnSocialCreditRecord.fromJson(Map<String, dynamic> 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);
}

View File

@@ -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

View File

@@ -449,3 +449,22 @@ Map<String, dynamic> _$SnSocialCreditRecordToJson(
'updated_at': instance.updatedAt.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(),
};

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

View 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

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

View File

@@ -60,3 +60,19 @@ sealed class SnCloudFile with _$SnCloudFile {
factory SnCloudFile.fromJson(Map<String, dynamic> 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);
}

View File

@@ -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

View File

@@ -78,3 +78,28 @@ Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
'updated_at': instance.updatedAt.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(),
};

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

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

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

View File

@@ -46,6 +46,18 @@ sealed class SnPoll with _$SnPoll {
}) = _SnPoll;
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

View File

@@ -38,6 +38,31 @@ class ThinkingChunkTypeConverter
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
sealed class StreamThinkingRequest with _$StreamThinkingRequest {
const factory StreamThinkingRequest({
@@ -46,6 +71,7 @@ sealed class StreamThinkingRequest with _$StreamThinkingRequest {
@Default([]) List<String> accpetProposals,
List<String>? attachedPosts,
List<Map<String, dynamic>>? attachedMessages,
@JsonKey(name: 'service_id') String? serviceId,
}) = _StreamThinkingRequest;
factory StreamThinkingRequest.fromJson(Map<String, dynamic> json) =>
@@ -77,6 +103,43 @@ sealed class SnThinkingChunk with _$SnThinkingChunk {
_$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
sealed class SnThinkingSequence with _$SnThinkingSequence {
const factory SnThinkingSequence({
@@ -98,9 +161,8 @@ sealed class SnThinkingSequence with _$SnThinkingSequence {
sealed class SnThinkingThought with _$SnThinkingThought {
const factory SnThinkingThought({
required String id,
String? content,
@Default([]) List<SnThinkingMessagePart> parts,
@Default([]) List<SnCloudFile> files,
@Default([]) List<SnThinkingChunk> chunks,
@ThinkingThoughtRoleConverter() required ThinkingThoughtRole role,
int? tokenCount,
String? modelName,
@@ -114,3 +176,26 @@ sealed class SnThinkingThought with _$SnThinkingThought {
factory SnThinkingThought.fromJson(Map<String, dynamic> 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

View File

@@ -24,6 +24,7 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson(
(json['attached_messages'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList(),
serviceId: json['service_id'] as String?,
);
Map<String, dynamic> _$StreamThinkingRequestToJson(
@@ -34,6 +35,7 @@ Map<String, dynamic> _$StreamThinkingRequestToJson(
'accpet_proposals': instance.accpetProposals,
'attached_posts': instance.attachedPosts,
'attached_messages': instance.attachedMessages,
'service_id': instance.serviceId,
};
_SnThinkingChunk _$SnThinkingChunkFromJson(Map<String, dynamic> json) =>
@@ -50,6 +52,64 @@ Map<String, dynamic> _$SnThinkingChunkToJson(_SnThinkingChunk instance) =>
'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(
id: json['id'] as String,
@@ -80,17 +140,19 @@ Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
_SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
_SnThinkingThought(
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:
(json['files'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
chunks:
(json['chunks'] as List<dynamic>?)
?.map((e) => SnThinkingChunk.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
role: const ThinkingThoughtRoleConverter().fromJson(
(json['role'] as num).toInt(),
),
@@ -114,9 +176,8 @@ _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
<String, dynamic>{
'id': instance.id,
'content': instance.content,
'parts': instance.parts.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),
'token_count': instance.tokenCount,
'model_name': instance.modelName,
@@ -126,3 +187,34 @@ Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
'updated_at': instance.updatedAt.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(),
};

View File

@@ -176,6 +176,8 @@ sealed class SnWalletFund with _$SnWalletFund {
required String id,
required String currency,
required double totalAmount,
required double remainingAmount,
required int amountOfSplits,
required int splitType, // 0: even, 1: random
required int
status, // 0: created, 1: partially claimed, 2: fully claimed, 3: expired
@@ -184,6 +186,7 @@ sealed class SnWalletFund with _$SnWalletFund {
required SnAccount? creatorAccount,
required DateTime expiredAt,
required List<SnWalletFundRecipient> recipients,
required bool isOpen,
required DateTime createdAt,
required DateTime updatedAt,
required DateTime? deletedAt,

View File

@@ -2553,9 +2553,9 @@ $SnWalletSubscriptionCopyWith<$Res>? get subscription {
/// @nodoc
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
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
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -2568,16 +2568,16 @@ $SnWalletFundCopyWith<SnWalletFund> get copyWith => _$SnWalletFundCopyWithImpl<S
@override
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)
@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
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;
@useResult
$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
/// 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(
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,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,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,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 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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
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) {
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();
}
@@ -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) {
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`
///
@@ -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) {
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;
}
@@ -2767,12 +2770,14 @@ return $default(_that.id,_that.currency,_that.totalAmount,_that.splitType,_that.
@JsonSerializable()
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);
@override final String id;
@override final String currency;
@override final double totalAmount;
@override final double remainingAmount;
@override final int amountOfSplits;
@override final int splitType;
// 0: even, 1: random
@override final int status;
@@ -2788,6 +2793,7 @@ class _SnWalletFund implements SnWalletFund {
return EqualUnmodifiableListView(_recipients);
}
@override final bool isOpen;
@override final DateTime createdAt;
@override final DateTime updatedAt;
@override final DateTime? deletedAt;
@@ -2805,16 +2811,16 @@ Map<String, dynamic> toJson() {
@override
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)
@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
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;
@override @useResult
$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
/// 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(
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,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,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,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 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,deletedAt: freezed == deletedAt ? _self.deletedAt : deletedAt // ignore: cast_nullable_to_non_nullable
as DateTime?,

View File

@@ -336,6 +336,8 @@ _SnWalletFund _$SnWalletFundFromJson(
id: json['id'] as String,
currency: json['currency'] as String,
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(),
status: (json['status'] as num).toInt(),
message: json['message'] as String?,
@@ -349,6 +351,7 @@ _SnWalletFund _$SnWalletFundFromJson(
(json['recipients'] as List<dynamic>)
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
.toList(),
isOpen: json['is_open'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt:
@@ -362,6 +365,8 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
'id': instance.id,
'currency': instance.currency,
'total_amount': instance.totalAmount,
'remaining_amount': instance.remainingAmount,
'amount_of_splits': instance.amountOfSplits,
'split_type': instance.splitType,
'status': instance.status,
'message': instance.message,
@@ -369,6 +374,7 @@ Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
'creator_account': instance.creatorAccount?.toJson(),
'expired_at': instance.expiredAt.toIso8601String(),
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
'is_open': instance.isOpen,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt?.toIso8601String(),

View File

@@ -3,10 +3,13 @@ import "package:dio/dio.dart";
import "package:drift/drift.dart" show Variable;
import "package:easy_localization/easy_localization.dart";
import "package:flutter/material.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/database/drift_db.dart";
import "package:island/database/message.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/wallet.dart";
import "package:island/pods/database.dart";
import "package:island/pods/lifecycle.dart";
import "package:island/pods/network.dart";
@@ -28,7 +31,7 @@ class MessagesNotifier extends _$MessagesNotifier {
late final SnChatMember _identity;
final Map<String, LocalChatMessage> _pendingMessages = {};
final Map<String, Map<int, double>> _fileUploadProgress = {};
final Map<String, Map<int, double?>> _fileUploadProgress = {};
int? _totalCount;
String? _searchQuery;
bool? _withLinks;
@@ -433,12 +436,15 @@ class MessagesNotifier extends _$MessagesNotifier {
}
Future<void> sendMessage(
WidgetRef ref,
String content,
List<UniversalFile> attachments, {
SnPoll? poll,
SnWalletFund? fund,
SnChatMessage? editingTo,
SnChatMessage? forwardingTo,
SnChatMessage? replyingTo,
Function(String, Map<int, double>)? onProgress,
Function(String, Map<int, double?>)? onProgress,
}) async {
final nonce = const Uuid().v4();
talker.log('Sending message with nonce $nonce');
@@ -471,10 +477,10 @@ class MessagesNotifier extends _$MessagesNotifier {
for (var idx = 0; idx < attachments.length; idx++) {
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: attachments[idx],
client: ref.read(apiClientProvider),
onProgress: (progress, _) {
_fileUploadProgress[localMessage.id]?[idx] = progress;
_fileUploadProgress[localMessage.id]?[idx] = progress ?? 0.0;
onProgress?.call(
localMessage.id,
_fileUploadProgress[localMessage.id] ?? {},
@@ -496,6 +502,8 @@ class MessagesNotifier extends _$MessagesNotifier {
'attachments_id': cloudAttachments.map((e) => e.id).toList(),
'replied_message_id': replyingTo?.id,
'forwarded_message_id': forwardingTo?.id,
'poll_id': poll?.id,
'fund_id': fund?.id,
'meta': {},
'nonce': nonce,
},

View File

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

104
lib/pods/file_list.dart Normal file
View 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;
}

View File

@@ -45,13 +45,13 @@ final billingQuotaProvider =
// ignore: unused_element
typedef BillingQuotaRef = AutoDisposeFutureProviderRef<Map<String, dynamic>?>;
String _$cloudFileListNotifierHash() =>
r'22c45a8ea23147a3835ba870ad2f0bb833f853ea';
r'5f2f80357cb31ac6473df5ac2101f9a462004f81';
/// See also [CloudFileListNotifier].
@ProviderFor(CloudFileListNotifier)
final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
CloudFileListNotifier,
CursorPagingData<SnCloudFile>
CursorPagingData<FileListItem>
>.internal(
CloudFileListNotifier.new,
name: r'cloudFileListNotifierProvider',
@@ -64,6 +64,27 @@ final cloudFileListNotifierProvider = AutoDisposeAsyncNotifierProvider<
);
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: 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
View 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);
}
}

View File

@@ -12,7 +12,9 @@ import 'package:island/screens/developers/hub.dart';
import 'package:island/screens/developers/edit_project.dart';
import 'package:island/screens/developers/new_project.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_detail.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_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/me/account_settings.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_detail.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_detail.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/poll/poll_editor.dart';
import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/compose_article.dart';
import 'package:island/screens/posts/post_detail.dart';
@@ -126,11 +125,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return CallScreen(roomId: id);
},
),
GoRoute(
name: 'thought',
path: '/thought',
builder: (context, state) => const ThoughtScreen(),
),
GoRoute(
name: 'logs',
path: '/logs',
@@ -269,11 +263,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/chat',
builder: (context, state) => const ChatListScreen(),
),
GoRoute(
name: 'chatNew',
path: '/chat/new',
builder: (context, state) => const NewChatScreen(),
),
GoRoute(
name: 'chatRoom',
path: '/chat/:id',
@@ -282,14 +271,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatRoomScreen(id: id);
},
),
GoRoute(
name: 'chatEdit',
path: '/chat/:id/edit',
builder: (context, state) {
final id = state.pathParameters['id']!;
return EditChatScreen(id: id);
},
),
GoRoute(
name: 'chatDetail',
path: '/chat/:id/detail',
@@ -396,11 +377,6 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/account/wallet',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
name: 'files',
path: '/account/files',
builder: (context, state) => const FileListScreen(),
),
GoRoute(
name: 'relationships',
path: '/account/relationships',
@@ -445,6 +421,38 @@ final routerProvider = Provider<GoRouter>((ref) {
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
GoRoute(
name: 'creatorHub',
@@ -477,28 +485,7 @@ final routerProvider = Provider<GoRouter>((ref) {
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(
name: 'creatorStickers',
path: ':name/stickers',
@@ -507,19 +494,6 @@ final routerProvider = Provider<GoRouter>((ref) {
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);
},
),
],
),

View File

@@ -129,7 +129,7 @@ class AccountScreen extends HookConsumerWidget {
pathParameters: {'name': user.value!.name},
);
},
),
).padding(bottom: 12),
Expanded(
child: Column(
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(
minTileHeight: 48,
leading: const Icon(Symbols.wallet),
@@ -385,16 +396,6 @@ class AccountScreen extends HookConsumerWidget {
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(
minTileHeight: 48,
leading: const Icon(Symbols.people),

View File

@@ -84,9 +84,7 @@ class AccountSettingsScreen extends HookConsumerWidget {
'accountPasswordChange'.tr(),
);
if (!confirm || !context.mounted) return;
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
try {
if (context.mounted) showLoadingModal(context);

View File

@@ -76,7 +76,7 @@ class UpdateProfileScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,

View File

@@ -2,9 +2,17 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 {
static Future<String?> show(BuildContext context) {
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
builder: (context) => const CaptchaScreen(),
);
}
const CaptchaScreen({super.key});
@override
@@ -13,9 +21,9 @@ class CaptchaScreen extends ConsumerWidget {
if (!captchaUrl.hasValue) return Center(child: CircularProgressIndicator());
return AppScaffold(
appBar: AppBar(title: Text("Anti-Robot")),
body: InAppWebView(
return SheetScaffold(
titleText: "Anti-Robot",
child: InAppWebView(
initialUrlRequest: URLRequest(
url: WebUri('${captchaUrl.value}?redirect_uri=solian://captcha'),
),

View File

@@ -4,11 +4,19 @@ import 'dart:ui_web' as ui;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/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:flutter/material.dart';
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});
@override
@@ -61,9 +69,9 @@ class _CaptchaScreenState extends ConsumerState<CaptchaScreen> {
@override
Widget build(BuildContext context) {
return AppScaffold(
appBar: AppBar(title: Text("Anti-Robot")),
body:
return SheetScaffold(
titleText: "Anti-Robot",
child:
_isInitialized
? HtmlElementView(viewType: 'captcha-iframe')
: Center(child: CircularProgressIndicator()),

View File

@@ -1,317 +1,22 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:email_validator/email_validator.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: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: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 {
const CreateAccountScreen({super.key});
@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();
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(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('createAccount').tr(),
),
body:
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()),
),
],
),
),
body: CreateAccountContent(),
);
}
}

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

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

View File

@@ -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: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/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: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 = {
0: ('authFactorPassword', 'authFactorPasswordDescription', Symbols.password),
@@ -44,745 +23,13 @@ class LoginScreen extends HookConsumerWidget {
@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 AppScaffold(
isNoBackground: false,
appBar: AppBar(
leading: const PageBackButton(),
title: Text('login').tr(),
),
body: 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 _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),
),
],
body: LoginContent(),
);
}
}

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

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

View File

@@ -16,10 +16,10 @@ import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:island/widgets/content/sheet.dart';
class NewChatScreen extends StatelessWidget {
const NewChatScreen({super.key});
@@ -99,7 +99,7 @@ class EditChatScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
@@ -151,12 +151,10 @@ class EditChatScreen extends HookConsumerWidget {
}
}
return AppScaffold(
appBar: AppBar(
title: Text(id == null ? 'createChatRoom' : 'editChatRoom').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
return SheetScaffold(
titleText: (id == null ? 'createChatRoom' : 'editChatRoom').tr(),
onClose: () => context.pop(),
child: SingleChildScrollView(
child: Column(
children: [
AspectRatio(
@@ -204,16 +202,24 @@ class EditChatScreen extends HookConsumerWidget {
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(labelText: 'Name'),
decoration: InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
),
const SizedBox(height: 16),
TextFormField(
controller: descriptionController,
decoration: const InputDecoration(
decoration: InputDecoration(
labelText: 'Description',
alignLabelWithHint: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
minLines: 3,
maxLines: null,
@@ -223,7 +229,12 @@ class EditChatScreen extends HookConsumerWidget {
const SizedBox(height: 16),
DropdownButtonFormField<SnRealm>(
value: currentRealm.value,
decoration: InputDecoration(labelText: 'realm'.tr()),
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: [
DropdownMenuItem<SnRealm>(
value: null,

View File

@@ -2,6 +2,7 @@ import "dart:async";
import "dart:math" as math;
import "package:easy_localization/easy_localization.dart";
import "package:file_picker/file_picker.dart";
import "package:image_picker/image_picker.dart";
import "package:flutter/material.dart";
import "package:go_router/go_router.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/models/chat.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_subscribe.dart";
import "package:island/pods/chat/messages_notifier.dart";
@@ -141,14 +144,38 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageController = useTextEditingController();
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
final bottomGradientNotifier = useState(ValueNotifier<double>(0.0));
final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null);
final selectedPoll = useState<SnPoll?>(null);
final selectedFund = useState<SnWalletFund?>(null);
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
final attachmentProgress = useState<Map<String, Map<int, double?>>>({});
// Selection mode state
final isSelectionMode = useState<bool>(false);
@@ -181,16 +208,13 @@ class ChatRoomScreen extends HookConsumerWidget {
}, [scrollController]);
Future<void> pickPhotoMedia() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
final ImagePicker picker = ImagePicker();
final List<XFile> results = await picker.pickMultiImage();
if (results.isEmpty) return;
attachments.value = [
...attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
}
@@ -265,10 +289,15 @@ class ChatRoomScreen extends HookConsumerWidget {
void sendMessage() {
if (messageController.text.trim().isNotEmpty ||
attachments.value.isNotEmpty) {
attachments.value.isNotEmpty ||
selectedPoll.value != null ||
selectedFund.value != null) {
messagesNotifier.sendMessage(
ref,
messageController.text.trim(),
attachments.value,
poll: selectedPoll.value,
fund: selectedFund.value,
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
@@ -283,6 +312,8 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
attachments.value = [];
}
}
@@ -563,7 +594,7 @@ class ChatRoomScreen extends HookConsumerWidget {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: attachment,
poolId: config.poolId,
mode:
@@ -573,7 +604,7 @@ class ChatRoomScreen extends HookConsumerWidget {
onProgress: (progress, _) {
attachmentProgress.value = {
...attachmentProgress.value,
'chat-upload': {index: progress},
'chat-upload': {index: progress ?? 0.0},
};
},
).future;
@@ -593,183 +624,428 @@ class ChatRoomScreen extends HookConsumerWidget {
}
}
Widget chatMessageListWidget(
List<LocalChatMessage> messageList,
) => SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom +
80, // Leave space for chat input
),
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;
Widget chatMessageListWidget(List<LocalChatMessage> messageList) =>
previousInputHeight != null && previousInputHeight != inputHeight.value
? TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: previousInputHeight,
end: inputHeight.value,
),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
builder:
(context, height, child) => SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
top: 16,
bottom:
MediaQuery.of(context).padding.bottom + 8 + height,
),
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}');
// 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) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(
context,
).colorScheme.primaryContainer.withOpacity(0.3)
: null,
child: Stack(
children: [
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,
final messageWidget = chatIdentity.when(
skipError: true,
data:
(identity) => GestureDetector(
onLongPress: () {
if (!isSelectionMode.value) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.3)
: null,
child: Stack(
children: [
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,
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,
isSelected: selectedMessages.value.contains(message.id),
onToggleSelection: toggleMessageSelection,
onEnterSelectionMode: () {
},
),
)
: SuperListView.builder(
listController: listController,
padding: EdgeInsets.only(
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) {
toggleSelectionMode();
toggleMessageSelection(message.id);
}
},
),
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,
),
onTap: () {
if (isSelectionMode.value) {
toggleMessageSelection(message.id);
}
},
child: Container(
color:
selectedMessages.value.contains(message.id)
? Theme.of(context)
.colorScheme
.primaryContainer
.withOpacity(0.3)
: null,
child: Stack(
children: [
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,
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),
),
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,
);
},
child: messageWidget,
);
},
);
return AppScaffold(
appBar: AppBar(
@@ -965,6 +1241,7 @@ class ChatRoomScreen extends HookConsumerWidget {
child: chatRoom.when(
data:
(room) => Column(
key: inputKey,
mainAxisSize: MainAxisSize.min,
children: [
ChatInput(
@@ -979,10 +1256,16 @@ class ChatRoomScreen extends HookConsumerWidget {
messageEditingTo.value = null;
messageReplyingTo.value = null;
messageForwardingTo.value = null;
selectedPoll.value = null;
selectedFund.value = null;
},
messageEditingTo: messageEditingTo.value,
messageReplyingTo: messageReplyingTo.value,
messageForwardingTo: messageForwardingTo.value,
selectedPoll: selectedPoll.value,
onPollSelected: (poll) => selectedPoll.value = poll,
selectedFund: selectedFund.value,
onFundSelected: (fund) => selectedFund.value = fund,
onPickFile: (bool isPhoto) {
if (isPhoto) {
pickPhotoMedia();

View File

@@ -16,6 +16,7 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -447,10 +448,17 @@ class _ChatRoomActionMenu extends HookConsumerWidget {
if ((chatIdentity.value?.role ?? 0) >= 50)
PopupMenuItem(
onTap: () {
context.pushReplacementNamed(
'chatEdit',
pathParameters: {'id': id},
);
showModalBottomSheet(
context: context,
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(
children: [

View File

@@ -261,7 +261,11 @@ class _PublisherUnselectedWidget extends HookConsumerWidget {
subtitle: Text('createPublisherHint').tr(),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
context.pushNamed('creatorNew').then((value) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => const NewPublisherScreen(),
).then((value) {
if (value != null) {
ref.invalidate(publishersManagedProvider);
}
@@ -285,19 +289,18 @@ class CreatorHubScreen extends HookConsumerWidget {
);
void updatePublisher() {
context
.pushNamed(
'creatorEdit',
pathParameters: {'name': currentPublisher.value!.name},
)
.then((value) async {
if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future);
currentPublisher.value =
data
.where((e) => e.id == currentPublisher.value!.id)
.firstOrNull;
});
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder:
(context) =>
EditPublisherScreen(name: currentPublisher.value!.name),
).then((value) async {
if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future);
currentPublisher.value =
data.where((e) => e.id == currentPublisher.value!.id).firstOrNull;
});
}
void deletePublisher() {
@@ -828,7 +831,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
try {
final apiClient = ref.watch(apiClientProvider);
await apiClient.post(
'/publishers/$publisherUname/invites',
'/sphere/publishers/$publisherUname/invites',
data: {'related_user_id': result.id, 'role': 0},
);
// Refresh both providers
@@ -959,7 +962,7 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
apiClientProvider,
);
await apiClient.delete(
'/publishers/$publisherUname/members/${member.accountId}',
'/sphere/publishers/$publisherUname/members/${member.accountId}',
);
// Refresh both providers
memberNotifier.reset();
@@ -1084,7 +1087,7 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
final apiClient = ref.read(apiClientProvider);
await apiClient.patch(
'/publishers/$publisherUname/members/${member.accountId}/role',
'/sphere/publishers/$publisherUname/members/${member.accountId}/role',
data: newRole,
);

View File

@@ -1,10 +1,10 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.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/poll/poll_feedback.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -73,10 +73,14 @@ class CreatorPollListScreen extends HookConsumerWidget {
final String pubName;
Future<void> _createPoll(BuildContext context) async {
final result = await GoRouter.of(
context,
).pushNamed('creatorPollNew', pathParameters: {'name': pubName});
if (result is SnPollWithStats && context.mounted) {
final result = await showModalBottomSheet<SnPollWithStats>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder: (context) => PollEditorScreen(initialPublisher: pubName),
);
if (result != null && context.mounted) {
Navigator.of(context).maybePop(result);
}
}
@@ -176,11 +180,20 @@ class _CreatorPollItem extends HookConsumerWidget {
Text('edit').tr(),
],
),
onTap: () {
GoRouter.of(context).pushNamed(
'creatorPollEdit',
pathParameters: {'name': pubName, 'id': pollWithStats.id},
onTap: () async {
final result = await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pubName,
initialPollId: pollWithStats.id,
),
);
if (result != null && context.mounted) {
ref.invalidate(pollListNotifierProvider(pubName));
}
},
),
PopupMenuItem(

View File

@@ -16,8 +16,8 @@ import 'package:island/screens/realm/realms.dart';
import 'package:island/services/file.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -95,11 +95,11 @@ class EditPublisherScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
client: ref.read(apiClientProvider),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');
@@ -177,13 +177,11 @@ class EditPublisherScreen extends HookConsumerWidget {
}
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(name == null ? 'createPublisher' : 'editPublisher').tr(),
leading: const PageBackButton(),
),
body: SingleChildScrollView(
final titleText = (name == null ? 'createPublisher' : 'editPublisher').tr();
return SheetScaffold(
titleText: titleText,
child: SingleChildScrollView(
padding: EdgeInsets.only(bottom: 16),
child: Column(
children: [

View File

@@ -141,7 +141,7 @@ class EditAppScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,

View File

@@ -127,11 +127,11 @@ class EditBotScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,
),
client: ref.read(apiClientProvider),
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');

View File

@@ -11,15 +11,16 @@ import 'package:island/models/realm.dart';
import 'package:island/models/webfeed.dart';
import 'package:island/pods/event_calendar.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/auth/login_modal.dart';
import 'package:island/screens/notification.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/models/post.dart';
import 'package:island/widgets/check_in.dart';
import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/post/post_featured.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:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -341,7 +342,7 @@ class ExploreScreen extends HookConsumerWidget {
margin: EdgeInsets.zero,
),
PostFeaturedList(),
const PostComposeCard(),
FriendsOverviewWidget(),
],
),
),
@@ -350,21 +351,39 @@ class ExploreScreen extends HookConsumerWidget {
else
Flexible(
flex: 2,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(context).textTheme.titleLarge,
).bold(),
const Gap(2),
Text(
'Login to explore more!',
style: Theme.of(context).textTheme.bodyLarge,
),
],
).padding(horizontal: 36, vertical: 16),
child:
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Symbols.emoji_people_rounded, size: 40),
const Gap(8),
Text(
'Welcome to\nthe Solar Network',
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
).bold(),
const Gap(2),
Text(
'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);
@@ -523,6 +542,11 @@ class ExploreScreen extends HookConsumerWidget {
child: PostFeaturedList(),
),
),
SliverToBoxAdapter(
child: FriendsOverviewWidget(
padding: const EdgeInsets.only(bottom: 8),
),
),
if (notificationCount.value != null &&
notificationCount.value! > 0)
SliverToBoxAdapter(

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

View File

@@ -1,122 +1,65 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:cross_file/cross_file.dart';
import 'package:file_picker/file_picker.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/network.dart';
import 'package:island/pods/file_pool.dart';
import 'package:island/utils/format.dart';
import 'package:island/pods/file_list.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/file_info_sheet.dart';
import 'package:island/widgets/content/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:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_paging_utils/riverpod_paging_utils.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 {
const FileListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Filter state
final selectedPool = useState<String?>(null);
final includeRecycled = useState(false);
// Path navigation state
final currentPath = useState<String>('/');
final mode = useState<FileListMode>(FileListMode.normal);
final usageAsync = ref.watch(billingUsageProvider);
final quotaAsync = ref.watch(billingQuotaProvider);
// Update notifier filters when state changes
useEffect(() {
final notifier = ref.read(cloudFileListNotifierProvider.notifier);
notifier.setFilters(selectedPool.value, includeRecycled.value);
return null;
}, [selectedPool.value, includeRecycled.value]);
final viewMode = useState(FileListViewMode.list);
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(
data:
(usage) => quotaAsync.when(
data:
(quota) => _buildQuotaUI(
usage,
quota,
ref,
selectedPool,
includeRecycled,
(quota) => FileListView(
usage: usage,
quota: quota,
currentPath: currentPath,
onPickAndUpload:
() => _pickAndUploadFile(ref, currentPath.value),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),
@@ -127,430 +70,138 @@ class FileListScreen extends HookConsumerWidget {
);
}
Widget _buildQuotaUI(
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);
Future<void> _pickAndUploadFile(WidgetRef ref, String currentPath) async {
try {
final client = ref.read(apiClientProvider);
await client.delete('/drive/files/recycled');
ref.invalidate(cloudFileListNotifierProvider);
showSnackBar('recycledFilesDeleted'.tr());
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
withData: false,
);
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) {
showSnackBar('failedToDeleteRecycledFiles'.tr());
} finally {
if (ref.context.mounted) hideLoadingModal(ref.context);
showSnackBar('Error picking file: $e');
}
}
Widget _buildStatCard(String label, String value, {double? progress}) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(label, style: const TextStyle(fontSize: 14)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Future<void> _showCreateDirectoryDialog(
BuildContext context,
ValueNotifier<String> currentPath,
) async {
final controller = TextEditingController(text: currentPath.value);
String? newPath;
void handleChangeDirectory(BuildContext context) {
newPath = controller.text.trim();
if (newPath!.isNotEmpty) {
// 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: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
const Gap(8),
TextField(
controller: controller,
decoration: const InputDecoration(
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),
),
);
}
}

View File

@@ -8,7 +8,7 @@ import 'package:island/pods/network.dart';
import 'package:island/talker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/models/poll.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:uuid/uuid.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -393,7 +393,7 @@ class PollEditorScreen extends ConsumerWidget {
showSnackBar(isUpdate ? 'pollUpdated'.tr() : 'pollCreated'.tr());
if (!context.mounted) return;
Navigator.of(context).maybePop(res.data);
Navigator.of(context).maybePop(SnPoll.fromJson(res.data));
} catch (e) {
showErrorAlert(e);
}
@@ -415,23 +415,46 @@ class PollEditorScreen extends ConsumerWidget {
});
}
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr()),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
const Gap(8),
],
),
body: Column(
return SheetScaffold(
titleText: model.id == null ? 'pollCreate'.tr() : 'pollEdit'.tr(),
actions: [
if (kDebugMode)
IconButton(
tooltip: 'pollPreviewJsonDebug'.tr(),
onPressed: () {
_showDebugPreview(context, model);
},
icon: const Icon(Icons.visibility_outlined),
),
],
heightFactor: 0.9,
onClose: () async {
final confirmed = await showDialog<bool>(
context: context,
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: [
Expanded(
child: ConstrainedBox(

View File

@@ -306,7 +306,7 @@ class ArticleComposeScreen extends HookConsumerWidget {
],
),
children: [
ValueListenableBuilder<Map<int, double>>(
ValueListenableBuilder<Map<int, double?>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
return Wrap(

View File

@@ -237,7 +237,9 @@ class PostSearchScreen extends HookConsumerWidget {
controller: pubNameController,
decoration: InputDecoration(
labelText: 'pubName'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),
@@ -247,7 +249,9 @@ class PostSearchScreen extends HookConsumerWidget {
controller: realmController,
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),

View File

@@ -73,10 +73,33 @@ class _PublisherBasisWidget extends StatelessWidget {
Positioned(
bottom: -24,
left: 16,
child: ProfilePictureWidget(
file: data.picture,
radius: 32,
borderRadius: data.type == 0 ? null : 12,
child: GestureDetector(
child: Badge(
isLabelVisible: data.type == 0,
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},
);
}
},
),
),
],

View File

@@ -92,7 +92,7 @@ class EditRealmScreen extends HookConsumerWidget {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: UniversalFile(
data: result,
type: UniversalFileType.image,

View File

@@ -5,8 +5,10 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/screens/notification.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/fab_menu.dart';
import 'package:material_symbols_icons/symbols.dart';
@@ -21,6 +23,8 @@ const kTabRoutes = [
'/chat',
'/realms',
'/account',
'/files',
'/thought',
'/creators',
'/developers',
];
@@ -66,19 +70,40 @@ class TabsScreen extends HookConsumerWidget {
icon: Badge.count(
count: notificationUnreadCount.value ?? 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)
NavigationDestination(
label: 'creatorHub'.tr(),
icon: const Icon(Symbols.design_services_rounded),
),
if (wideScreen)
NavigationDestination(
label: 'developerHub'.tr(),
icon: const Icon(Symbols.data_object_rounded),
),
...([
NavigationDestination(
label: 'files'.tr(),
icon: const Icon(Symbols.folder_rounded),
),
NavigationDestination(
label: 'aiThought'.tr(),
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() {

View File

@@ -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:flutter/material.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:island/models/thought.dart";
import "package:island/pods/network.dart";
import "package:island/pods/userinfo.dart";
import "package:island/widgets/alert.dart";
import "package:island/widgets/app_scaffold.dart";
import "package:island/widgets/response.dart";
import "package:island/widgets/thought/thought_sequence_list.dart";
import "package:island/widgets/thought/thought_shared.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';
@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
Future<List<SnThinkingThought>> thoughtSequence(
Ref ref,
@@ -35,6 +36,13 @@ Future<List<SnThinkingThought>> thoughtSequence(
.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 {
const ThoughtScreen({super.key});
@@ -46,203 +54,32 @@ class ThoughtScreen extends HookConsumerWidget {
? ref.watch(thoughtSequenceProvider(selectedSequenceId.value!))
: const AsyncValue<List<SnThinkingThought>>.data([]);
final localThoughts = useState<List<SnThinkingThought>>([]);
final currentTopic = useState<String?>('aiThought'.tr());
final messageController = useTextEditingController();
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));
// 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();
// Extract sequence ID from loaded thoughts for the chat interface
final sequenceIdFromThoughts = thoughts.maybeWhen(
data: (thoughts) {
if (thoughts.isNotEmpty && thoughts.first.sequenceId.isNotEmpty) {
return thoughts.first.sequenceId;
}
});
return null;
}, [thoughts]);
return null;
},
orElse: () => null,
);
// 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]);
// Get initial thoughts and topic from provider
final initialThoughts = thoughts.valueOrNull;
final initialTopic =
(initialThoughts?.isNotEmpty ?? false) &&
initialThoughts!.first.sequence?.topic != null
? initialThoughts.first.sequence!.topic
: 'aiThought'.tr();
// 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: 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);
}
}
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: Text(currentTopic.value ?? 'aiThought'.tr()),
title: Text(initialTopic ?? 'aiThought'.tr()),
leading: const PageBackButton(),
actions: [
IconButton(
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),
],
),
body: Stack(
children: [
// Thoughts list
Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
body: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
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: [
Expanded(
child: thoughts.when(
data:
(thoughtList) => SuperListView.builder(
listController: listController,
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,
),
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
actions: [
TextButton(
onPressed: () {
retry();
},
child: Text('retry'.tr()),
),
],
),
Expanded(child: thoughtsBody),
],
),
),
),
// 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),
],
),
),
),
);
},
orElse:
() => thoughts.when(
data:
(thoughtList) => ThoughtChatInterface(
initialThoughts: thoughtList,
initialTopic: initialTopic,
),
loading: () => const Center(child: CircularProgressIndicator()),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() =>
selectedSequenceId.value != null
? ref.invalidate(
thoughtSequenceProvider(
selectedSequenceId.value!,
),
)
: null,
),
),
),
// 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,
),
),
),
),
],
),
);
}

View File

@@ -6,6 +6,25 @@ part of 'think.dart';
// 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';
/// Copied from Dart SDK
@@ -152,5 +171,25 @@ class _ThoughtSequenceProviderElement
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: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@@ -1,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:flutter/material.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:island/models/thought.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/content/sheet.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 {
final List<Map<String, dynamic>> attachedMessages;
@@ -42,275 +38,68 @@ class ThoughtSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sequenceId = useState<String?>(null);
final localThoughts = useState<List<SnThinkingThought>>([]);
final currentTopic = useState<String?>('aiThought'.tr());
final chatState = useThoughtChat(
ref,
attachedMessages: attachedMessages,
attachedPosts: attachedPosts,
);
final messageController = useTextEditingController();
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);
}
}
final statusAsync = ref.watch(thoughtAvailableStausProvider);
return SheetScaffold(
titleText: currentTopic.value ?? 'aiThought'.tr(),
child: Stack(
children: [
// Thoughts list
Center(
child: Container(
constraints: BoxConstraints(maxWidth: 640),
child: Column(
titleText: chatState.currentTopic.value ?? 'aiThought'.tr(),
child: statusAsync.maybeWhen(
data: (status) {
final retry = useMemoized(
() => () async {
showLoadingModal(context);
try {
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: [
Expanded(
child: SuperListView.builder(
listController: listController,
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,
);
},
MaterialBanner(
leading: const Icon(Symbols.error),
content: const Text(
'You have unpaid orders. Please settle your payment to continue using the service.',
style: TextStyle(fontWeight: FontWeight.bold),
),
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,
),
),
),
),
],
),
);
}

View File

@@ -55,6 +55,7 @@ class CreateFundSheet extends StatefulWidget {
class _CreateFundSheetState extends State<CreateFundSheet> {
final amountController = TextEditingController();
final splitsController = TextEditingController(text: '1');
final messageController = TextEditingController();
String selectedCurrency = 'golds';
int selectedSplitType = 0; // 0: even, 1: random
@@ -64,6 +65,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
void dispose() {
amountController.dispose();
messageController.dispose();
splitsController.dispose();
super.dispose();
}
@@ -103,17 +105,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
labelText: 'enterAmount'.tr(),
hintText: '0.00',
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -136,17 +130,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
DropdownButtonFormField<String>(
value: selectedCurrency,
decoration: InputDecoration(
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -173,49 +159,84 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
},
),
// Split Type Section (only show when there are 2+ recipients)
if (selectedRecipients.length >= 2) ...[
const Gap(16),
Text(
'splitType'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
const Gap(16),
// Amount of Splits Section
Text(
'amountOfSplits'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
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),
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);
}
},
),
),
],
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
if (value.isEmpty && selectedRecipients.isNotEmpty) {
splitsController.text =
selectedRecipients.length.toString();
}
},
),
const Gap(16),
Text(
'splitType'.tr(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
],
),
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),
@@ -370,17 +391,9 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
labelText: 'personalMessage'.tr(),
hintText: 'addPersonalMessageForRecipients'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -520,14 +533,15 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
Future<void> _createFund() async {
final amount = double.tryParse(amountController.text);
final splits = int.tryParse(splitsController.text);
if (amount == null || amount <= 0) {
showErrorAlert('invalidAmount'.tr());
return;
}
if (selectedRecipients.isEmpty) {
showErrorAlert('noRecipientsSelected'.tr());
if (splits == null || splits <= 0) {
showErrorAlert('invalidNumberOfSplits'.tr());
return;
}
@@ -535,6 +549,7 @@ class _CreateFundSheetState extends State<CreateFundSheet> {
'currency': selectedCurrency,
'total_amount': amount,
'split_type': selectedSplitType,
'amount_of_splits': splits,
'recipient_account_ids': selectedRecipients.map((r) => r.id).toList(),
'message':
messageController.text.trim().isEmpty
@@ -610,17 +625,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
labelText: 'enterAmount'.tr(),
hintText: '0.00',
prefixIcon: Icon(kCurrencyIconData[selectedCurrency]),
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -643,17 +650,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
DropdownButtonFormField<String>(
value: selectedCurrency,
decoration: InputDecoration(
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -817,17 +816,9 @@ class _CreateTransferSheetState extends State<CreateTransferSheet> {
labelText: 'transferRemark'.tr(),
hintText: 'addRemarkForTransfer'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withOpacity(0.2),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
@@ -1863,6 +1854,6 @@ class WalletScreen extends HookConsumerWidget {
}
const Map<String, IconData> kCurrencyIconData = {
'points': Symbols.toll,
'golds': Symbols.attach_money,
'points': Symbols.bolt,
'golds': Symbols.diamond,
};

File diff suppressed because it is too large Load Diff

View File

@@ -16,3 +16,10 @@ class PostCreatedEvent {
class ChatRoomsRefreshEvent {
const ChatRoomsRefreshEvent();
}
/// Event fired when OIDC auth callback is received
class OidcAuthCallbackEvent {
final String challengeId;
const OidcAuthCallbackEvent(this.challengeId);
}

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'package:convert/convert.dart';
import 'package:cross_file/cross_file.dart';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
@@ -6,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/upload_tasks.dart';
import 'package:mime/mime.dart';
import 'package:native_exif/native_exif.dart';
import 'package:path/path.dart' show extension;
@@ -21,9 +23,51 @@ class FileUploader {
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.
Future<Map<String, dynamic>> createUploadTask({
required Uint8List bytes,
required dynamic fileData,
required String fileName,
required String contentType,
String? poolId,
@@ -31,9 +75,19 @@ class FileUploader {
String? encryptPassword,
String? expiredAt,
int? chunkSize,
String? path,
}) async {
final hash = _calculateFileHash(bytes);
final fileSize = bytes.length;
String hash;
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(
'/drive/files/upload/create',
@@ -47,6 +101,7 @@ class FileUploader {
'encrypt_password': encryptPassword,
'expired_at': expiredAt,
'chunk_size': chunkSize,
'path': path,
},
);
@@ -58,6 +113,7 @@ class FileUploader {
required String taskId,
required int chunkIndex,
required Uint8List chunkData,
ProgressCallback? onSendProgress,
}) async {
final formData = FormData.fromMap({
'chunk': MultipartFile.fromBytes(
@@ -69,19 +125,26 @@ class FileUploader {
await _client.post(
'/drive/files/upload/chunk/$taskId/$chunkIndex',
data: formData,
onSendProgress: onSendProgress,
);
}
/// Completes the upload and returns the CloudFile object.
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);
}
/// Uploads a file in chunks using the multi-part API.
Future<SnCloudFile> uploadFile({
required Uint8List bytes,
required dynamic fileData,
required String fileName,
required String contentType,
String? poolId,
@@ -89,10 +152,13 @@ class FileUploader {
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(
bytes: bytes,
fileData: fileData,
fileName: fileName,
contentType: contentType,
poolId: poolId,
@@ -100,6 +166,7 @@ class FileUploader {
encryptPassword: encryptPassword,
expiredAt: expiredAt,
chunkSize: customChunkSize,
path: path,
);
if (createResponse['file_exists'] == true) {
@@ -110,36 +177,74 @@ class FileUploader {
final taskId = createResponse['task_id'] as String;
final chunkSize = createResponse['chunk_size'] 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
final chunks = <Uint8List>[];
for (int i = 0; i < bytes.length; i += chunkSize) {
final end = i + chunkSize > bytes.length ? bytes.length : i + chunkSize;
chunks.add(Uint8List.fromList(bytes.sublist(i, end)));
}
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 _readNextChunk(subscription, chunkSize);
await uploadChunk(
taskId: taskId,
chunkIndex: i,
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)));
}
// Ensure we have the correct number of chunks
if (chunks.length != chunksCount) {
throw Exception(
'Chunk count mismatch: expected $chunksCount, got ${chunks.length}',
);
}
// Upload each chunk
for (int i = 0; i < chunks.length; i++) {
await uploadChunk(taskId: taskId, chunkIndex: i, chunkData: chunks[i]);
// 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);
},
);
bytesUploaded += chunks[i].length;
}
} else {
throw ArgumentError('Invalid fileData type');
}
// Step 3: Complete upload
onProgress?.call(null, Duration.zero);
return await completeUpload(taskId);
}
static Completer<SnCloudFile?> createCloudFile({
required UniversalFile fileData,
required Dio client,
required WidgetRef ref,
String? poolId,
String? path,
FileUploadMode? mode,
Function(double progress, Duration estimate)? onProgress,
Function(double? progress, Duration estimate)? onProgress,
}) {
final completer = Completer<SnCloudFile?>();
@@ -175,8 +280,9 @@ class FileUploader {
.then(
(_) => _processUpload(
fileData,
client,
ref,
poolId,
path,
onProgress,
completer,
),
@@ -185,8 +291,9 @@ class FileUploader {
debugPrint('Error removing GPS EXIF data: $e');
return _processUpload(
fileData,
client,
ref,
poolId,
path,
onProgress,
completer,
);
@@ -196,16 +303,17 @@ class FileUploader {
}
}
_processUpload(fileData, client, poolId, onProgress, completer);
_processUpload(fileData, ref, poolId, path, onProgress, completer);
return completer;
}
// Helper method to process the upload
// Helper method to process the upload with enhanced uploader
static Completer<SnCloudFile?> _processUpload(
UniversalFile fileData,
Dio client,
WidgetRef ref,
String? poolId,
Function(double progress, Duration estimate)? onProgress,
String? path,
Function(double? progress, Duration estimate)? onProgress,
Completer<SnCloudFile?> completer,
) {
String actualMimetype = getMimeType(fileData);
@@ -216,23 +324,16 @@ class FileUploader {
final data = fileData.data;
if (data is XFile) {
// Read bytes from XFile
data
.readAsBytes()
.then((readBytes) {
_performUpload(
bytes: readBytes,
fileName: fileData.displayName ?? data.name,
contentType: actualMimetype,
client: client,
poolId: poolId,
onProgress: onProgress,
completer: completer,
);
})
.catchError((e) {
completer.completeError(e);
});
_performUpload(
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) {
bytes = data is List<int> ? Uint8List.fromList(data) : data;
@@ -252,10 +353,11 @@ class FileUploader {
if (bytes != null) {
_performUpload(
bytes: bytes,
fileData: bytes,
fileName: actualFilename,
contentType: actualMimetype,
client: client,
path: path,
ref: ref,
poolId: poolId,
onProgress: onProgress,
completer: completer,
@@ -265,30 +367,34 @@ class FileUploader {
return completer;
}
// Helper method to perform the actual upload
// Helper method to perform the actual upload with enhanced uploader
static void _performUpload({
required Uint8List bytes,
required dynamic fileData,
required String fileName,
required String contentType,
required Dio client,
required WidgetRef ref,
String? poolId,
Function(double progress, Duration estimate)? onProgress,
String? path,
Function(double? progress, Duration estimate)? onProgress,
required Completer<SnCloudFile?> completer,
}) {
final uploader = FileUploader(client);
// Use the enhanced uploader with task tracking
final uploader = ref.read(enhancedFileUploaderProvider);
// Call progress start
onProgress?.call(0.0, Duration.zero);
onProgress?.call(null, Duration.zero);
uploader
.uploadFile(
bytes: bytes,
fileData: fileData,
fileName: fileName,
contentType: contentType,
poolId: poolId,
path: path,
onProgress: onProgress,
)
.then((result) {
// Call progress end
onProgress?.call(1.0, Duration.zero);
onProgress?.call(null, Duration.zero);
completer.complete(result);
})
.catchError((e) {

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

View File

@@ -5,5 +5,11 @@ String formatFileSize(int bytes) {
if (bytes < 1024 * 1024 * 1024) {
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';
}

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

View 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

View File

@@ -240,7 +240,11 @@ class _PurchaseGiftSheetState extends State<PurchaseGiftSheet> {
labelText: 'personalMessage'.tr(),
hintText: 'addPersonalMessageForRecipient'.tr(),
alignLabelWithHint: true,
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(
@@ -925,7 +929,9 @@ class StellarProgramTab extends HookConsumerWidget {
decoration: InputDecoration(
isDense: true,
hintText: 'enterGiftCode'.tr(),
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.2),

View File

@@ -13,6 +13,7 @@ import 'package:island/route.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/pods/websocket.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:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -198,6 +199,7 @@ class WindowScaffold extends HookConsumerWidget {
],
),
_WebSocketIndicator(),
const UploadOverlay(),
],
),
),
@@ -213,7 +215,11 @@ class WindowScaffold extends HookConsumerWidget {
actions: <Type, Action<Intent>>{PopIntent: PopAction(ref)},
child: Stack(
fit: StackFit.expand,
children: [Positioned.fill(child: child), _WebSocketIndicator()],
children: [
Positioned.fill(child: child),
_WebSocketIndicator(),
const UploadOverlay(),
],
),
),
);

View File

@@ -8,6 +8,7 @@ import 'package:island/pods/activity/activity_rpc.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/route.dart';
import 'package:island/screens/tray_manager.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/services/notify.dart';
import 'package:island/services/sharing_intent.dart';
import 'package:island/services/update_service.dart';
@@ -115,8 +116,21 @@ class _AppWrapperState extends ConsumerState<AppWrapper>
}
void _handleDeepLink(Uri uri, WidgetRef ref) {
final router = ref.read(routerProvider);
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);
if (uri.queryParameters.isNotEmpty) {
path =
Uri.parse(

View File

@@ -11,7 +11,9 @@ import "package:island/models/account.dart";
import "package:island/models/autocomplete_response.dart";
import "package:island/models/chat.dart";
import "package:island/models/file.dart";
import "package:island/models/poll.dart";
import "package:island/models/publisher.dart";
import "package:island/models/wallet.dart";
import "package:island/models/realm.dart";
import "package:island/models/sticker.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:island/widgets/stickers/sticker_picker.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 {
final TextEditingController messageController;
@@ -44,7 +209,11 @@ class ChatInput extends HookConsumerWidget {
final Function(int) onDeleteAttachment;
final Function(int, int) onMoveAttachment;
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({
super.key,
@@ -65,12 +234,17 @@ class ChatInput extends HookConsumerWidget {
required this.onMoveAttachment,
required this.onAttachmentsChanged,
required this.attachmentProgress,
this.selectedPoll,
required this.onPollSelected,
this.selectedFund,
required this.onFundSelected,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final inputFocusNode = useFocusNode();
final chatSubscribe = ref.watch(chatSubscribeNotifierProvider(chatRoom.id));
final isExpanded = useState(false);
void send() {
inputFocusNode.requestFocus();
@@ -281,6 +455,195 @@ class ChatInput extends HookConsumerWidget {
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(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutCubic,
@@ -426,43 +789,28 @@ class ChatInput extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'stickers'.tr(),
icon: const Icon(Symbols.add_reaction),
tooltip:
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: () {
final size = MediaQuery.of(context).size;
showStickerPickerPopover(
context,
Offset(
20,
size.height -
480 -
MediaQuery.of(context).padding.bottom,
),
onPick: (placeholder) {
// Insert placeholder at current cursor position
final text = messageController.text;
final selection = messageController.selection;
final start =
selection.start >= 0
? selection.start
: text.length;
final end =
selection.end >= 0
? selection.end
: text.length;
final newText = text.replaceRange(
start,
end,
placeholder,
);
messageController.value = TextEditingValue(
text: newText,
selection: TextSelection.collapsed(
offset: start + placeholder.length,
),
);
},
);
isExpanded.value = !isExpanded.value;
},
),
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')),
),
],
),
),

View File

@@ -144,7 +144,11 @@ class ChatLinkAttachment extends HookConsumerWidget {
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage.value,
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside:
(_) =>

View File

@@ -40,7 +40,7 @@ class MessageItem extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Function(String action)? onAction;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final bool isSelectionMode;
@@ -689,7 +689,7 @@ class MessageHoverActionMenu extends StatelessWidget {
class MessageItemDisplayBubble extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final String? translatedText;
@@ -821,7 +821,7 @@ class MessageItemDisplayBubble extends HookConsumerWidget {
class MessageItemDisplayIRC extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final String? translatedText;
@@ -949,7 +949,7 @@ class MessageItemDisplayIRC extends HookConsumerWidget {
class MessageItemDisplayDiscord extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Map<int, double>? progress;
final Map<int, double?>? progress;
final bool showAvatar;
final Function(String messageId) onJump;
final String? translatedText;
@@ -1238,7 +1238,7 @@ class MessageQuoteWidget extends HookConsumerWidget {
}
class FileUploadProgressWidget extends StatelessWidget {
final Map<int, double>? progress;
final Map<int, double?>? progress;
final Color textColor;
final bool hasContent;
@@ -1266,7 +1266,9 @@ class FileUploadProgressWidget extends StatelessWidget {
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
(entry.value * 100).toStringAsFixed(1),
entry.value != null
? (entry.value! * 100).toStringAsFixed(1)
: '0.0',
],
),
style: TextStyle(

View File

@@ -104,9 +104,7 @@ class CheckInWidget extends HookConsumerWidget {
} catch (err) {
if (err is DioException) {
if (err.response?.statusCode == 423 && context.mounted) {
final captchaTk = await Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => CaptchaScreen()));
final captchaTk = await CaptchaScreen.show(context);
if (captchaTk == null) return;
return await checkIn(captchatTk: captchaTk);
}

View File

@@ -401,7 +401,7 @@ class AttachmentPreview extends HookConsumerWidget {
children: [
if (progress != null)
Text(
'${progress!.toStringAsFixed(2)}%',
'${(progress! * 100).toStringAsFixed(2)}%',
style: TextStyle(color: Colors.white),
)
else
@@ -411,10 +411,7 @@ class AttachmentPreview extends HookConsumerWidget {
),
Gap(6),
Center(
child: LinearProgressIndicator(
value:
progress != null ? progress! / 100.0 : null,
),
child: LinearProgressIndicator(value: progress),
),
],
),

View File

@@ -215,7 +215,6 @@ class CloudFileList extends HookConsumerWidget {
}
if (files.length == 1) {
final isImage = files.first.mimeType?.startsWith('image') ?? false;
final isAudio = files.first.mimeType?.startsWith('audio') ?? false;
final widgetItem = ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: _CloudFileListEntry(
@@ -243,11 +242,7 @@ class CloudFileList extends HookConsumerWidget {
minWidth: minWidth ?? 0,
maxWidth: files.length == 1 ? maxWidth : double.infinity,
),
height: isAudio ? 120 : null,
child:
isAudio
? widgetItem
: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
child: IntrinsicWidth(child: IntrinsicHeight(child: widgetItem)),
);
}
@@ -413,6 +408,8 @@ class _CloudFileListEntry extends HookConsumerWidget {
final lockedByMature = file.sensitiveMarks.isNotEmpty && !showMature.value;
final meta = file.fileMeta is Map ? file.fileMeta as Map : const {};
final ratio = meta['ratio'] as num?;
final fit = BoxFit.cover;
Widget bg = const SizedBox.shrink();
@@ -484,7 +481,7 @@ class _CloudFileListEntry extends HookConsumerWidget {
onTap?.call();
}
},
child: content,
child: AspectRatio(aspectRatio: ratio?.toDouble() ?? 1, child: content),
);
}
}

View File

@@ -6,7 +6,6 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:island/models/file.dart';
import 'package:island/pods/network.dart';
import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/attachment_preview.dart';
@@ -61,7 +60,7 @@ class CloudFilePicker extends HookConsumerWidget {
final cloudFile =
await FileUploader.createCloudFile(
fileData: file,
client: ref.read(apiClientProvider),
ref: ref,
onProgress: (progress, _) {
uploadProgress.value = progress;
},
@@ -112,23 +111,28 @@ class CloudFilePicker extends HookConsumerWidget {
void pickImage() async {
showLoadingModal(context);
final result = await FilePicker.platform.pickFiles(
allowMultiple: allowMultiple,
type: FileType.image,
);
if (result == null || result.files.isEmpty) {
final ImagePicker picker = ImagePicker();
List<XFile> results;
if (allowMultiple) {
results = await picker.pickMultiImage();
} else {
final XFile? result = await picker.pickImage(
source: ImageSource.gallery,
);
results = result != null ? [result] : [];
}
if (results.isEmpty) {
if (context.mounted) hideLoadingModal(context);
return;
}
final newFiles =
result.files.map((e) {
final xfile =
e.bytes != null
? XFile.fromData(e.bytes!, name: e.name)
: XFile(e.path!);
return UniversalFile(data: xfile, type: UniversalFileType.image);
}).toList();
results
.map(
(xfile) =>
UniversalFile(data: xfile, type: UniversalFileType.image),
)
.toList();
if (!allowMultiple) {
files.value = newFiles;

View File

@@ -1,5 +1,4 @@
import 'dart:io';
import 'dart:math' as math;
import 'package:cached_network_image/cached_network_image.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/utils/format.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/audio.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' show extension;
import 'package:path_provider/path_provider.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/content/file_info_sheet.dart';
import 'file_viewer_contents.dart';
import 'image.dart';
import 'video.dart';
@@ -68,8 +66,6 @@ class CloudFileWidget extends HookConsumerWidget {
);
if (item.mimeType == 'application/pdf') {
final pdfViewer = useMemoized(() => SfPdfViewer.network(uri), [uri]);
Future<void> downloadFile() async {
try {
showSnackBar('Downloading file...');
@@ -109,7 +105,7 @@ class CloudFileWidget extends HookConsumerWidget {
),
child: Stack(
children: [
pdfViewer,
PdfFileContent(uri: uri),
Positioned(
top: 8,
left: 8,
@@ -205,14 +201,6 @@ class CloudFileWidget extends HookConsumerWidget {
}
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 {
try {
showSnackBar('Downloading file...');
@@ -252,29 +240,9 @@ class CloudFileWidget extends HookConsumerWidget {
),
child: Stack(
children: [
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: 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'));
},
Padding(
padding: const EdgeInsets.fromLTRB(20, 68, 20, 20),
child: TextFileContent(uri: uri),
),
Positioned(
top: 8,
@@ -371,21 +339,13 @@ class CloudFileWidget extends HookConsumerWidget {
}
var content = switch (item.mimeType?.split('/').firstOrNull) {
'image' =>
ratio == 1.0
? IntrinsicHeight(
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
)
: AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'image' => AspectRatio(
aspectRatio: ratio,
child:
(useInternalGate && dataSaving && !unlocked.value)
? dataPlaceHolder(Symbols.image)
: cloudImage(),
),
'video' => AspectRatio(
aspectRatio: ratio,
child:
@@ -393,14 +353,7 @@ class CloudFileWidget extends HookConsumerWidget {
? dataPlaceHolder(Symbols.play_arrow)
: cloudVideo(),
),
'audio' => Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: math.min(360, MediaQuery.of(context).size.width * 0.8),
),
child: UniversalAudio(uri: uri, filename: item.name),
),
),
'audio' => AudioFileContent(item: item, uri: uri),
_ => Builder(
builder: (context) {
Future<void> downloadFile() async {

View File

@@ -1,12 +1,10 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:island/models/embed.dart';
import 'package:island/services/responsive.dart';
import 'package:island/utils/mapping.dart';
import 'package:island/widgets/content/embed/link.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 {
final List<dynamic> embeds;
@@ -26,46 +24,108 @@ class EmbedListWidget extends StatelessWidget {
@override
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(
children:
embeds
.map((embedData) => convertMapKeysToSnakeCase(embedData))
.map(
(embedData) => switch (embedData['type']) {
'link' => EmbedLinkWidget(
link: SnScrappedLink.fromJson(embedData),
maxWidth:
maxWidth ??
math.min(
MediaQuery.of(context).size.width,
kWideScreenWidth,
),
margin: EdgeInsets.only(
top: 4,
bottom: 4,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
children: [
if (linkEmbeds.isNotEmpty)
Container(
margin: EdgeInsets.only(
top: 8,
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
),
decoration: BoxDecoration(
border: Border.all(color: Theme.of(context).dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Theme(
data: Theme.of(
context,
).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
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(
horizontal: renderingPadding.horizontal,
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']}'),
},
)
.toList(),
_ => Text('Unable show embed: ${embedData['type']}'),
},
),
],
);
}
}

View File

@@ -13,8 +13,8 @@ import 'package:url_launcher/url_launcher_string.dart';
class FileInfoSheet extends StatelessWidget {
final SnCloudFile item;
const FileInfoSheet({super.key, required this.item});
final VoidCallback? onClose;
const FileInfoSheet({super.key, required this.item, this.onClose});
@override
Widget build(BuildContext context) {
@@ -22,6 +22,7 @@ class FileInfoSheet extends StatelessWidget {
final exifData = item.fileMeta?['exif'] as Map<String, dynamic>? ?? {};
return SheetScaffold(
onClose: onClose,
titleText: 'fileInfoTitle'.tr(),
child: SingleChildScrollView(
child: Column(

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

View File

@@ -8,6 +8,7 @@ class SheetScaffold extends StatelessWidget {
final Widget child;
final double heightFactor;
final double? height;
final VoidCallback? onClose;
const SheetScaffold({
super.key,
this.title,
@@ -16,6 +17,7 @@ class SheetScaffold extends StatelessWidget {
this.actions = const [],
this.heightFactor = 0.8,
this.height,
this.onClose,
});
@override
@@ -50,7 +52,11 @@ class SheetScaffold extends StatelessWidget {
...actions,
IconButton(
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)),
),
],

View File

@@ -3,12 +3,12 @@ import 'package:flutter/material.dart';
class UniversalVideo extends StatelessWidget {
final String uri;
final double aspectRatio;
final double? aspectRatio;
final bool autoplay;
const UniversalVideo({
super.key,
required this.uri,
required this.aspectRatio,
this.aspectRatio,
this.autoplay = false,
});

View File

@@ -28,7 +28,9 @@ Future<void> _showSetTokenDialog(BuildContext context, WidgetRef ref) async {
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter access token',
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
autofocus: true,
),
@@ -96,7 +98,7 @@ class DebugSheet extends HookConsumerWidget {
'Unable to check for updates',
);
}
}
},
),
const Divider(height: 8),
ListTile(

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

View File

@@ -4,11 +4,16 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:go_router/go_router.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/services/event_bus.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_picker.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_sheet.dart';
import 'package:island/screens/chat/chat_form.dart';
import 'package:material_symbols_icons/symbols.dart';
enum FabMenuType { main, compose, chat, realm }
@@ -50,16 +55,46 @@ class FabMenu extends HookConsumerWidget {
late final bool useRootNavigator;
late final Widget menuContent;
final commonEntires = <Widget>[
final unauthorizedEntires = <Widget>[
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.bubble_chart),
title: Text('aiThoughtTitle').tr(),
leading: const Icon(Symbols.login),
title: Text('login').tr(),
onTap: () async {
Navigator.of(context).pop();
context.pushNamed('thought');
showModalBottomSheet(
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(
builder: (context, ref, _) {
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) {
case FabMenuType.compose:
icon = Symbols.create;
@@ -95,25 +134,28 @@ class FabMenu extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.post_add_rounded),
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeSheet.show(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.article),
title: Text('articleCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
GoRouter.of(context).pushNamed('articleCompose');
},
),
const Divider(),
if (authorized)
...([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.post_add_rounded),
title: Text('postCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
await PostComposeSheet.show(context);
},
),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.article),
title: Text('articleCompose').tr(),
onTap: () async {
Navigator.of(context).pop();
GoRouter.of(context).pushNamed('articleCompose');
},
),
const Divider(),
]),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],
@@ -128,29 +170,35 @@ class FabMenu extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Gap(24),
ListTile(
title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
Navigator.pop(context);
context.pushNamed('chatNew').then((value) {
if (value != null) {
eventBus.fire(const ChatRoomsRefreshEvent());
}
});
},
),
ListTile(
title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
Navigator.pop(context);
_createDirectMessage(context, ref);
},
),
const Divider(),
if (authorized)
...([
ListTile(
title: const Text('createChatRoom').tr(),
leading: const Icon(Symbols.add),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const EditChatScreen(),
).then((value) {
if (value != null) {
eventBus.fire(const ChatRoomsRefreshEvent());
}
});
},
),
ListTile(
title: const Text('createDirectMessage').tr(),
leading: const Icon(Symbols.person),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onTap: () {
_createDirectMessage(context, ref);
},
),
const Divider(),
]),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],
@@ -164,21 +212,24 @@ class FabMenu extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
const Gap(24),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add),
title: Text('createRealm').tr(),
onTap: () {
Navigator.of(context).pop();
context.pushNamed('realmNew').then((value) {
if (value != null) {
// Fire realm refresh event if needed
// eventBus.fire(const RealmsRefreshEvent());
}
});
},
),
const Divider(),
if (authorized)
...([
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.group_add),
title: Text('createRealm').tr(),
onTap: () {
Navigator.of(context).pop();
context.pushNamed('realmNew').then((value) {
if (value != null) {
// Fire realm refresh event if needed
// eventBus.fire(const RealmsRefreshEvent());
}
});
},
),
const Divider(),
]),
...commonEntires,
Gap(MediaQuery.of(context).padding.bottom + 16),
],

View File

@@ -62,9 +62,19 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
bool? _yesNoSelected;
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
void initState() {
super.initState();
_controllerListener = () {
_userHasEdited = true;
};
_textController.addListener(_controllerListener);
_answers = Map<String, dynamic>.from(widget.initialAnswers ?? {});
// Set initial collapse state based on the parameter
_isCollapsed = !widget.isInitiallyExpanded;
@@ -75,6 +85,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
_isModifying = false;
}
}
// Load initial answers into local state
if (_questions != null) {
_loadCurrentIntoLocalState();
_userHasEdited = false;
}
}
void _initializeFromPollData(SnPollWithStats poll) {
@@ -101,6 +116,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
@override
void dispose() {
_textController.removeListener(_controllerListener);
_textController.dispose();
super.dispose();
}
@@ -111,30 +127,35 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
final q = _current;
final saved = _answers[q.id];
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
_textController.text = '';
if (!_userHasEdited) {
_singleChoiceSelected = null;
_multiChoiceSelected.clear();
_yesNoSelected = null;
_ratingSelected = null;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) _textController.text = saved;
break;
switch (q.type) {
case SnPollQuestionType.singleChoice:
if (saved is String) _singleChoiceSelected = saved;
break;
case SnPollQuestionType.multipleChoice:
if (saved is List) {
_multiChoiceSelected.addAll(saved.whereType<String>());
}
break;
case SnPollQuestionType.yesNo:
if (saved is bool) _yesNoSelected = saved;
break;
case SnPollQuestionType.rating:
if (saved is int) _ratingSelected = saved;
break;
case SnPollQuestionType.freeText:
if (saved is String) {
_textController.removeListener(_controllerListener);
_textController.text = saved;
_textController.addListener(_controllerListener);
}
break;
}
}
}
@@ -214,6 +235,9 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
data: {'answer': _answers},
);
// Refresh poll data to show submitted answer
ref.invalidate(pollWithStatsProvider(widget.pollId));
// Only call onSubmit after server accepts
widget.onSubmit(Map<String, dynamic>.unmodifiable(_answers));
@@ -236,6 +260,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
if (_index < _questions!.length - 1) {
setState(() {
_index++;
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
} else {
@@ -250,6 +275,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
if (_index > 0) {
setState(() {
_index--;
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
} else {
@@ -342,7 +368,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
RadioListTile<String>(
value: opt.id,
groupValue: _singleChoiceSelected,
onChanged: (val) => setState(() => _singleChoiceSelected = val),
onChanged:
(val) => setState(() {
_singleChoiceSelected = val;
_userHasEdited = true;
}),
title: Text(opt.label),
subtitle: opt.description != null ? Text(opt.description!) : null,
),
@@ -364,6 +394,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
} else {
_multiChoiceSelected.remove(opt.id);
}
_userHasEdited = true;
});
},
title: Text(opt.label),
@@ -386,6 +417,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
onSelectionChanged: (sel) {
setState(() {
_yesNoSelected = sel.isEmpty ? null : sel.first;
_userHasEdited = true;
});
},
multiSelectionEnabled: false,
@@ -411,6 +443,7 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
onPressed: () {
setState(() {
_ratingSelected = value;
_userHasEdited = true;
});
},
);
@@ -422,7 +455,11 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
return TextField(
controller: _textController,
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(() {
_isModifying = true;
_index = 0; // Reset to first question for modification
_userHasEdited = false;
_loadCurrentIntoLocalState();
});
},
@@ -487,32 +525,6 @@ class _PollSubmitState extends ConsumerState<PollSubmit> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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!)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),

View File

@@ -131,7 +131,7 @@ class ArticleComposeAttachments extends ConsumerWidget {
],
),
children: [
ValueListenableBuilder<Map<int, double>>(
ValueListenableBuilder<Map<int, double?>>(
valueListenable: state.attachmentProgress,
builder: (context, progressMap, _) {
return Wrap(

View File

@@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart';
import 'package:island/models/post.dart';
@@ -17,10 +16,8 @@ import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_attachments.dart';
import 'package:island/widgets/post/compose_form_fields.dart';
import 'package:island/widgets/post/compose_info_banner.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:island/widgets/post/compose_toolbar.dart';
import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/publishers_modal.dart';
@@ -144,16 +141,11 @@ class PostComposeCard extends HookConsumerWidget {
// Helper methods
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: composeState),
);
ComposeLogic.showSettingsSheet(context, composeState);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
composeState,
context,
@@ -330,7 +322,13 @@ class PostComposeCard extends HookConsumerWidget {
if (isContained) {
Navigator.of(context).pop();
}
context.pushNamed('creatorNew').then((value) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) => const NewPublisherScreen(),
).then((value) {
if (value != null) {
composeState.currentPublisher.value =
value as SnPublisher;
@@ -368,9 +366,14 @@ class PostComposeCard extends HookConsumerWidget {
if (isContained) {
Navigator.of(context).pop();
}
context.pushNamed('creatorNew').then((
value,
) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder:
(context) =>
const NewPublisherScreen(),
).then((value) {
if (value != null) {
composeState.currentPublisher.value =
value as SnPublisher;

View File

@@ -9,10 +9,8 @@ import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:material_symbols_icons/symbols.dart';
/// A dialog that wraps PostComposeCard for easy use in dialogs.
@@ -104,16 +102,11 @@ class PostComposeDialog extends HookConsumerWidget {
// Helper methods for actions
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
ComposeLogic.showSettingsSheet(context, state);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
state,
context,

View File

@@ -0,0 +1,388 @@
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/wallet.dart';
import 'package:island/pods/network.dart';
import 'package:island/screens/wallet.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/payment/payment_overlay.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart';
/// Bottom sheet for selecting or creating a fund. Returns SnWalletFund via Navigator.pop.
class ComposeFundSheet extends HookConsumerWidget {
const ComposeFundSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPushing = useState(false);
final errorText = useState<String?>(null);
return SheetScaffold(
heightFactor: 0.6,
titleText: 'fund'.tr(),
child: DefaultTabController(
length: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TabBar(
tabs: [
Tab(text: 'fundsRecent'.tr()),
Tab(text: 'fundCreateNew'.tr()),
],
),
Expanded(
child: TabBarView(
children: [
// Link/Select existing fund list
ref
.watch(walletFundsProvider())
.when(
data:
(funds) =>
funds.isEmpty
? Center(
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Symbols.money_bag,
size: 48,
color:
Theme.of(
context,
).colorScheme.outline,
),
const Gap(16),
Text(
'noFundsCreated'.tr(),
style:
Theme.of(
context,
).textTheme.titleMedium,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: funds.length,
itemBuilder: (context, index) {
final fund = funds[index];
return Card(
margin: const EdgeInsets.only(
bottom: 8,
),
child: InkWell(
onTap:
() => Navigator.of(
context,
).pop(fund),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Symbols.money_bag,
color:
Theme.of(context)
.colorScheme
.primary,
fill: 1,
),
const Gap(8),
Expanded(
child: Text(
'${fund.totalAmount.toStringAsFixed(2)} ${fund.currency}',
style: TextStyle(
fontSize: 18,
fontWeight:
FontWeight.bold,
color:
Theme.of(
context,
)
.colorScheme
.primary,
),
),
),
Container(
padding:
const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color:
_getFundStatusColor(
context,
fund.status,
).withOpacity(
0.1,
),
borderRadius:
BorderRadius.circular(
12,
),
),
child: Text(
_getFundStatusText(
fund.status,
),
style: TextStyle(
color:
_getFundStatusColor(
context,
fund.status,
),
fontSize: 12,
fontWeight:
FontWeight.w600,
),
),
),
],
),
if (fund.message != null &&
fund
.message!
.isNotEmpty) ...[
const Gap(8),
Text(
fund.message!,
style:
Theme.of(context)
.textTheme
.bodyMedium,
),
],
const Gap(8),
Text(
'${'recipients'.tr()}: ${fund.recipients.where((r) => r.isReceived).length}/${fund.recipients.length}',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(
color:
Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
],
),
),
),
);
},
),
loading:
() => const Center(
child: CircularProgressIndicator(),
),
error:
(error, stack) =>
Center(child: Text('Error: $error')),
),
// Create new fund and return it
SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fundCreateNewHint',
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
if (errorText.value != null)
Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
top: 4,
),
child: Text(
errorText.value!,
style: TextStyle(color: Colors.red[700]),
),
),
const Gap(16),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
icon:
isPushing.value
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Symbols.add_circle),
label: Text('create'.tr()),
onPressed:
isPushing.value
? null
: () async {
errorText.value = null;
isPushing.value = true;
// Show modal bottom sheet with fund creation form and await result
final result = await showModalBottomSheet<
Map<String, dynamic>
>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder:
(context) =>
const CreateFundSheet(),
);
if (result == null) {
isPushing.value = false;
return;
}
try {
if (!context.mounted) return;
final client = ref.read(
apiClientProvider,
);
showLoadingModal(context);
final resp = await client.post(
'/pass/wallets/funds',
data: result,
options: Options(
headers: {'X-Noop': true},
),
);
final fund = SnWalletFund.fromJson(
resp.data,
);
if (fund.status == 0) {
// Return the fund that was just created (but not yet paid)
if (context.mounted) {
hideLoadingModal(context);
}
Navigator.of(context).pop(fund);
return;
}
final orderResp = await client.post(
'/pass/wallets/funds/${fund.id}/order',
);
final order = SnWalletOrder.fromJson(
orderResp.data,
);
if (context.mounted) {
hideLoadingModal(context);
}
// Show payment overlay to complete the payment
if (!context.mounted) return;
final paidOrder =
await PaymentOverlay.show(
context: context,
order: order,
enableBiometric: true,
);
if (paidOrder != null &&
context.mounted) {
showLoadingModal(context);
// Wait for server to handle order
await Future.delayed(
const Duration(seconds: 1),
);
ref.invalidate(walletFundsProvider);
// Return the created fund
final updatedResp = await client.get(
'/pass/wallets/funds/${fund.id}',
);
final updatedFund =
SnWalletFund.fromJson(
updatedResp.data,
);
if (context.mounted) {
hideLoadingModal(context);
}
Navigator.of(
context,
).pop(updatedFund);
} else {
isPushing.value = false;
}
} catch (err) {
if (context.mounted) {
hideLoadingModal(context);
}
errorText.value = err.toString();
isPushing.value = false;
}
},
),
),
],
).padding(horizontal: 24, vertical: 24),
),
],
),
),
],
),
),
);
}
String _getFundStatusText(int status) {
switch (status) {
case 0:
return 'fundStatusCreated'.tr();
case 1:
return 'fundStatusPartial'.tr();
case 2:
return 'fundStatusCompleted'.tr();
case 3:
return 'fundStatusExpired'.tr();
default:
return 'fundStatusUnknown'.tr();
}
}
Color _getFundStatusColor(BuildContext context, int status) {
switch (status) {
case 0:
return Colors.blue;
case 1:
return Colors.orange;
case 2:
return Colors.green;
case 3:
return Colors.red;
default:
return Theme.of(context).colorScheme.primary;
}
}
}

View File

@@ -143,7 +143,11 @@ class ComposeLinkAttachment extends HookConsumerWidget {
helperText: 'fileIdHint'.tr(),
helperMaxLines: 3,
errorText: errorMessage.value,
border: OutlineInputBorder(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
),
onTapOutside:
(_) =>

View File

@@ -2,11 +2,12 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/poll.dart';
import 'package:island/models/publisher.dart';
import 'package:island/screens/creators/poll/poll_list.dart';
import 'package:island/screens/poll/poll_editor.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:riverpod_paging_utils/riverpod_paging_utils.dart';
@@ -15,14 +16,13 @@ import 'package:island/widgets/post/publishers_modal.dart';
/// Bottom sheet for selecting or creating a poll. Returns SnPoll via Navigator.pop.
class ComposePollSheet extends HookConsumerWidget {
/// Optional publisher name to filter polls and prefill creation.
final String? pubName;
final SnPublisher? pub;
const ComposePollSheet({super.key, this.pubName});
const ComposePollSheet({super.key, this.pub});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedPublisher = useState<String?>(pubName);
final selectedPublisher = useState<SnPublisher?>(pub);
final isPushing = useState(false);
final errorText = useState<String?>(null);
@@ -46,10 +46,11 @@ class ComposePollSheet extends HookConsumerWidget {
children: [
// Link/Select existing poll list
PagingHelperView(
provider: pollListNotifierProvider(pubName),
futureRefreshable: pollListNotifierProvider(pubName).future,
provider: pollListNotifierProvider(pub?.name),
futureRefreshable:
pollListNotifierProvider(pub?.name).future,
notifierRefreshable:
pollListNotifierProvider(pubName).notifier,
pollListNotifierProvider(pub?.name).notifier,
contentBuilder:
(data, widgetCount, endItemView) => ListView.builder(
padding: EdgeInsets.zero,
@@ -66,7 +67,9 @@ class ComposePollSheet extends HookConsumerWidget {
title: Text(poll.title ?? 'untitled'.tr()),
subtitle: _buildPollSubtitle(poll),
onTap: () {
Navigator.of(context).pop(poll);
Navigator.of(
context,
).pop(SnPoll.fromPollWithStats(poll));
},
);
},
@@ -81,38 +84,48 @@ class ComposePollSheet extends HookConsumerWidget {
Text(
'pollCreateNewHint',
).tr().fontSize(13).opacity(0.85).padding(bottom: 8),
ListTile(
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: '@${selectedPublisher.value}',
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: 'selected'.tr(),
),
leading: const Icon(Symbols.account_circle),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder: (context) => const PublisherModal(),
);
if (picked != null) {
try {
final name = picked.name;
if (name.isNotEmpty) {
selectedPublisher.value = name;
Card(
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
title: Text(
selectedPublisher.value == null
? 'publisher'.tr()
: selectedPublisher.value!.nick,
),
subtitle: Text(
selectedPublisher.value == null
? 'publisherHint'.tr()
: '@${selectedPublisher.value?.name}',
),
leading:
selectedPublisher.value == null
? const Icon(Symbols.account_circle)
: ProfilePictureWidget(
file: selectedPublisher.value?.picture,
),
trailing: const Icon(Symbols.chevron_right),
onTap: () async {
final picked =
await showModalBottomSheet<SnPublisher>(
context: context,
isScrollControlled: true,
builder:
(context) => const PublisherModal(),
);
if (picked != null) {
try {
selectedPublisher.value = picked;
errorText.value = null;
} catch (_) {
// ignore
}
} catch (_) {
// ignore
}
}
},
},
),
),
if (errorText.value != null)
Padding(
@@ -146,8 +159,7 @@ class ComposePollSheet extends HookConsumerWidget {
isPushing.value
? null
: () async {
final pub = selectedPublisher.value ?? '';
if (pub.isEmpty) {
if (pub == null) {
errorText.value =
'publisherCannotBeEmpty'.tr();
return;
@@ -155,12 +167,18 @@ class ComposePollSheet extends HookConsumerWidget {
errorText.value = null;
isPushing.value = true;
// Push to creatorPollNew route and await result
final result = await GoRouter.of(
context,
).push<SnPoll>(
'/creators/$pub/polls/new',
);
// Show modal bottom sheet with poll editor and await result
final result =
await showModalBottomSheet<SnPoll>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
builder:
(context) => PollEditorScreen(
initialPublisher: pub?.name,
),
);
if (result == null) {
isPushing.value = false;

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:island/services/event_bus.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:mime/mime.dart';
import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -21,6 +22,7 @@ import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/post/compose_link_attachments.dart';
import 'package:island/widgets/post/compose_poll.dart';
import 'package:island/widgets/post/compose_fund.dart';
import 'package:island/widgets/post/compose_recorder.dart';
import 'package:island/pods/file_pool.dart';
import 'package:pasteboard/pasteboard.dart';
@@ -33,7 +35,7 @@ class ComposeState {
final TextEditingController slugController;
final ValueNotifier<int> visibility;
final ValueNotifier<List<UniversalFile>> attachments;
final ValueNotifier<Map<int, double>> attachmentProgress;
final ValueNotifier<Map<int, double?>> attachmentProgress;
final ValueNotifier<SnPublisher?> currentPublisher;
final ValueNotifier<bool> submitting;
final ValueNotifier<List<SnPostCategory>> categories;
@@ -44,6 +46,8 @@ class ComposeState {
int postType;
// Linked poll id for this compose session (nullable)
final ValueNotifier<String?> pollId;
// Linked fund id for this compose session (nullable)
final ValueNotifier<String?> fundId;
Timer? _autoSaveTimer;
ComposeState({
@@ -63,7 +67,9 @@ class ComposeState {
required this.draftId,
this.postType = 0,
String? pollId,
}) : pollId = ValueNotifier<String?>(pollId);
String? fundId,
}) : pollId = ValueNotifier<String?>(pollId),
fundId = ValueNotifier<String?>(fundId);
void startAutoSave(WidgetRef ref) {
_autoSaveTimer?.cancel();
@@ -98,6 +104,22 @@ class ComposeLogic {
// Initialize categories from original post
final categories = originalPost?.categories ?? <SnPostCategory>[];
// Extract poll and fund IDs from embeds
String? pollId;
String? fundId;
if (originalPost?.meta?['embeds'] is List) {
final embeds =
(originalPost!.meta!['embeds'] as List).cast<Map<String, dynamic>>();
try {
final pollEmbed = embeds.firstWhere((e) => e['type'] == 'poll');
pollId = pollEmbed['id'];
} catch (_) {}
try {
final fundEmbed = embeds.firstWhere((e) => e['type'] == 'fund');
fundId = fundEmbed['id'];
} catch (_) {}
}
return ComposeState(
attachments: ValueNotifier<List<UniversalFile>>(
originalPost?.attachments
@@ -123,7 +145,7 @@ class ComposeLogic {
slugController: TextEditingController(text: originalPost?.slug),
visibility: ValueNotifier<int>(originalPost?.visibility ?? 0),
submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}),
attachmentProgress: ValueNotifier<Map<int, double?>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(originalPost?.publisher),
tags: ValueNotifier<List<String>>(tags),
categories: ValueNotifier<List<SnPostCategory>>(categories),
@@ -131,8 +153,8 @@ class ComposeLogic {
embedView: ValueNotifier<SnPostEmbedView?>(originalPost?.embedView),
draftId: id,
postType: postType,
// initialize without poll by default
pollId: null,
pollId: pollId,
fundId: fundId,
);
}
@@ -149,7 +171,7 @@ class ComposeLogic {
slugController: TextEditingController(text: draft.slug),
visibility: ValueNotifier<int>(draft.visibility),
submitting: ValueNotifier<bool>(false),
attachmentProgress: ValueNotifier<Map<int, double>>({}),
attachmentProgress: ValueNotifier<Map<int, double?>>({}),
currentPublisher: ValueNotifier<SnPublisher?>(null),
tags: ValueNotifier<List<String>>(tags),
categories: ValueNotifier<List<SnPostCategory>>(draft.categories),
@@ -158,6 +180,8 @@ class ComposeLogic {
draftId: draft.id,
postType: postType,
pollId: null,
// initialize without fund by default
fundId: null,
);
}
@@ -180,7 +204,7 @@ class ComposeLogic {
try {
final cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: attachment,
).future;
if (cloudFile != null) {
@@ -402,16 +426,13 @@ class ComposeLogic {
}
static Future<void> pickPhotoMedia(WidgetRef ref, ComposeState state) async {
final result = await FilePicker.platform.pickFiles(
type: FileType.image,
allowMultiple: true,
allowCompression: false,
);
if (result == null || result.count == 0) return;
final ImagePicker picker = ImagePicker();
final List<XFile> results = await picker.pickMultiImage();
if (results.isEmpty) return;
state.attachments.value = [
...state.attachments.value,
...result.files.map(
(e) => UniversalFile(data: e.xFile, type: UniversalFileType.image),
...results.map(
(xfile) => UniversalFile(data: xfile, type: UniversalFileType.image),
),
];
}
@@ -503,7 +524,7 @@ class ComposeLogic {
try {
state.attachmentProgress.value = {
...state.attachmentProgress.value,
index: 0,
index: 0.0,
};
SnCloudFile? cloudFile;
@@ -513,7 +534,7 @@ class ComposeLogic {
cloudFile =
await FileUploader.createCloudFile(
client: ref.read(apiClientProvider),
ref: ref,
fileData: attachment,
poolId: poolId ?? selectedPoolId,
mode:
@@ -523,7 +544,7 @@ class ComposeLogic {
onProgress: (progress, _) {
state.attachmentProgress.value = {
...state.attachmentProgress.value,
index: progress,
index: progress ?? 0.0,
};
},
).future;
@@ -614,22 +635,47 @@ class ComposeLogic {
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ComposePollSheet(),
builder: (context) => ComposePollSheet(pub: state.currentPublisher.value),
);
if (poll == null) return;
state.pollId.value = poll.id;
}
static Future<void> performAction(
static Future<void> pickFund(
WidgetRef ref,
ComposeState state,
BuildContext context,
) async {
if (state.fundId.value != null) {
state.fundId.value = null;
return;
}
final fund = await showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
builder: (context) => const ComposeFundSheet(),
);
if (fund == null) return;
state.fundId.value = fund.id;
}
/// Unified submit method that returns the created/updated post.
static Future<SnPost> performSubmit(
WidgetRef ref,
ComposeState state,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
required Function() onSuccess,
}) async {
if (state.submitting.value) return;
if (state.submitting.value) {
throw Exception('Already submitting');
}
// Don't submit empty posts (no content and no attachments)
final hasContent =
@@ -639,25 +685,26 @@ class ComposeLogic {
final hasAttachments = state.attachments.value.isNotEmpty;
if (!hasContent && !hasAttachments) {
if (context.mounted) {
showSnackBar('postContentEmpty'.tr());
}
return; // Don't submit empty posts
showErrorAlert('postContentEmpty'.tr());
throw Exception('Post content is empty'); // Don't submit empty posts
}
try {
state.submitting.value = true;
// pload any local attachments first
// Upload any local attachments first
await Future.wait(
state.attachments.value
.asMap()
.entries
.where((entry) => entry.value.isOnDevice)
.map((entry) => uploadAttachment(ref, state, entry.key)),
.map(
(entry) => ComposeLogic.uploadAttachment(ref, state, entry.key),
),
);
// Prepare API request
final client = ref.watch(apiClientProvider);
final client = ref.read(apiClientProvider);
final isNewPost = originalPost == null;
final endpoint =
'/sphere${isNewPost ? '/posts' : '/posts/${originalPost.id}'}';
@@ -682,43 +729,85 @@ class ComposeLogic {
'categories': state.categories.value.map((e) => e.slug).toList(),
if (state.realm.value != null) 'realm_id': state.realm.value?.id,
if (state.pollId.value != null) 'poll_id': state.pollId.value,
if (state.fundId.value != null) 'fund_id': state.fundId.value,
if (state.embedView.value != null)
'embed_view': state.embedView.value!.toJson(),
};
// Send request
await client.request(
final response = await client.request(
endpoint,
queryParameters: {'pub': state.currentPublisher.value?.name},
data: payload,
options: Options(method: isNewPost ? 'POST' : 'PATCH'),
);
// Delete draft after successful submission
if (state.postType == 1) {
// Delete article draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
} else {
// Delete regular post draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
}
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
// Parse the response into a SnPost
final post = SnPost.fromJson(response.data);
// Call the success callback
onSuccess();
eventBus.fire(PostCreatedEvent());
return post;
} catch (err) {
showErrorAlert(err);
// Show error message if context is mounted
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error: $err')));
}
rethrow;
} finally {
state.submitting.value = false;
}
}
static Future<void> performAction(
WidgetRef ref,
ComposeState state,
BuildContext context, {
SnPost? originalPost,
SnPost? repliedPost,
SnPost? forwardedPost,
}) async {
await ComposeLogic.performSubmit(
ref,
state,
context,
originalPost: originalPost,
repliedPost: repliedPost,
forwardedPost: forwardedPost,
onSuccess: () async {
// Delete draft after successful submission
if (state.postType == 1) {
// Delete article draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
} else {
// Delete regular post draft
await ref
.read(composeStorageNotifierProvider.notifier)
.deleteDraft(state.draftId);
}
if (context.mounted) {
Navigator.of(context).maybePop(true);
}
},
);
}
/// Shows the settings sheet modal.
static void showSettingsSheet(BuildContext context, ComposeState state) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
}
static Future<void> handlePaste(ComposeState state) async {
final clipboard = await Pasteboard.image;
if (clipboard == null) return;
@@ -781,5 +870,6 @@ class ComposeLogic {
state.realm.dispose();
state.embedView.dispose();
state.pollId.dispose();
state.fundId.dispose();
}
}

View File

@@ -8,10 +8,8 @@ import 'package:island/screens/posts/compose.dart';
import 'package:island/services/compose_storage_db.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_settings_sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:island/widgets/post/compose_state_utils.dart';
import 'package:island/widgets/post/compose_submit_utils.dart';
import 'package:material_symbols_icons/symbols.dart';
/// A dialog that wraps PostComposeCard for easy use in dialogs.
@@ -106,16 +104,11 @@ class PostComposeSheet extends HookConsumerWidget {
// Helper methods for actions
void showSettingsSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
builder: (context) => ComposeSettingsSheet(state: state),
);
ComposeLogic.showSettingsSheet(context, state);
}
Future<void> performSubmit() async {
await ComposeSubmitUtils.performSubmit(
await ComposeLogic.performSubmit(
ref,
state,
context,

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