Compare commits

..

25 Commits

Author SHA1 Message Date
d7746d14e4 🚀 Launch 3.5.0+151 2025-12-06 21:52:30 +08:00
648d5225f6 🐛 Ensure mobile site management request permission 2025-12-06 21:48:16 +08:00
9d4d0f2e48 🐛 Fix inconsistence alert 2025-12-06 21:44:43 +08:00
fe386163f4 💄 Optimize designs in developer hub 2025-12-06 21:39:50 +08:00
ac2cee10e5 💄 Hub now shows loading stautus of publishers / developers 2025-12-06 21:28:19 +08:00
9c370647dd 🐛 Fix some bugs in creator hub 2025-12-06 21:26:00 +08:00
7516e197fe 💄 Fix post replies skeleton inconststent 2025-12-06 21:15:32 +08:00
71c372ab6c Prefer auto dispose riverpods 2025-12-06 21:13:25 +08:00
25f23f7f93 🐛 Fix serval bugs during the changes 2025-12-06 21:05:29 +08:00
51853698b9 🐛 Fix serval bugs 2025-12-06 20:53:24 +08:00
39ed5393ab 💄 Dedicated notification skeleton 2025-12-06 20:49:54 +08:00
782b3f1b08 🐛 Fix article edit shows the post edit sheet 2025-12-06 20:45:47 +08:00
3ef2f13dd3 💄 Redesign the post tags and categories page 2025-12-06 20:40:28 +08:00
36b0f55a47 🐛 Fix inconsistent of margin in post silver list 2025-12-06 20:24:54 +08:00
bc7a6e865e 🐛 Fix some issues 2025-12-06 20:20:54 +08:00
2ff60fc4ff 💫 List loading state switch animation 2025-12-06 19:54:34 +08:00
ea93aa144e 🐛 Fix some bugs in post search UI 2025-12-06 19:47:36 +08:00
e4cd0c99df 💄 Optimize skeleton effect 2025-12-06 19:01:40 +08:00
dff84dde58 Post list now supports initial filter to prevent some mismatch 2025-12-06 18:47:50 +08:00
16c7b7e764 ♻️ Refactored post loading 2025-12-06 18:20:47 +08:00
240509ceff 🚚 Update files layout of pods 2025-12-06 17:31:12 +08:00
91da9768c1 💄 Adjust the style of the post reply preview 2025-12-06 15:26:23 +08:00
60b8e2bcad 💄 Optimize post reply preview 2025-12-06 13:53:22 +08:00
504e4d55ad 💄 Post list skeleton 2025-12-06 13:31:17 +08:00
38a15bb62a Better loading animation in paginationed list 2025-12-06 13:13:30 +08:00
104 changed files with 7376 additions and 6960 deletions

View File

@@ -12,6 +12,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" /> <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="29" /> android:maxSdkVersion="29" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" /> <uses-permission android:name="com.google.android.c2dm.permission.RECEIVE" />
@@ -159,4 +161,4 @@
<data android:mimeType="text/plain" /> <data android:mimeType="text/plain" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -618,6 +618,7 @@
"tagsHint": "Enter tags, separated by commas", "tagsHint": "Enter tags, separated by commas",
"categories": "Categories", "categories": "Categories",
"categoriesHint": "Enter categories, separated by commas", "categoriesHint": "Enter categories, separated by commas",
"categoriesAndTags": "Categories & Tags",
"chatNotJoined": "You have not joined this chat yet.", "chatNotJoined": "You have not joined this chat yet.",
"chatUnableJoin": "You can't join this chat due to it's access control settings.", "chatUnableJoin": "You can't join this chat due to it's access control settings.",
"chatJoin": "Join the Chat", "chatJoin": "Join the Chat",

View File

@@ -1489,5 +1489,6 @@
"accountActivationAlert": "请记住激活您的账户", "accountActivationAlert": "请记住激活您的账户",
"accountActivationAlertHint": "未激活的账户可能会导致各种权限问题,请点击我们发送到您邮箱收件箱的链接来激活您的账户。", "accountActivationAlertHint": "未激活的账户可能会导致各种权限问题,请点击我们发送到您邮箱收件箱的链接来激活您的账户。",
"accountActivationResendHint": "没收到?请尝试点击下方按钮重新发送。如果您在账户未激活期间需要更新邮箱,请随时联系我们的客服。", "accountActivationResendHint": "没收到?请尝试点击下方按钮重新发送。如果您在账户未激活期间需要更新邮箱,请随时联系我们的客服。",
"accountActivationResend": "重新发送" "accountActivationResend": "重新发送",
"noFurtherData": "已经到底了"
} }

View File

@@ -257,6 +257,8 @@ PODS:
- path_provider_foundation (0.0.1): - path_provider_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- pointer_interceptor_ios (0.0.1): - pointer_interceptor_ios (0.0.1):
- Flutter - Flutter
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
@@ -351,6 +353,7 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- pasteboard (from `.symlinks/plugins/pasteboard/ios`) - pasteboard (from `.symlinks/plugins/pasteboard/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`) - pointer_interceptor_ios (from `.symlinks/plugins/pointer_interceptor_ios/ios`)
- protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`) - protocol_handler_ios (from `.symlinks/plugins/protocol_handler_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
@@ -458,6 +461,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/pasteboard/ios" :path: ".symlinks/plugins/pasteboard/ios"
path_provider_foundation: path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin" :path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
pointer_interceptor_ios: pointer_interceptor_ios:
:path: ".symlinks/plugins/pointer_interceptor_ios/ios" :path: ".symlinks/plugins/pointer_interceptor_ios/ios"
protocol_handler_ios: protocol_handler_ios:
@@ -539,6 +544,7 @@ SPEC CHECKSUMS:
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c pasteboard: 49088aeb6119d51f976a421db60d8e1ab079b63c
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0 pointer_interceptor_ios: da06a662d5bfd329602b45b2ab41bc0fb5fdb0f0
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851

File diff suppressed because it is too large Load Diff

View File

@@ -12,18 +12,16 @@ _SnAbuseReport _$SnAbuseReportFromJson(Map<String, dynamic> json) =>
resourceIdentifier: json['resource_identifier'] as String, resourceIdentifier: json['resource_identifier'] as String,
type: (json['type'] as num).toInt(), type: (json['type'] as num).toInt(),
reason: json['reason'] as String, reason: json['reason'] as String,
resolvedAt: resolvedAt: json['resolved_at'] == null
json['resolved_at'] == null ? null
? null : DateTime.parse(json['resolved_at'] as String),
: DateTime.parse(json['resolved_at'] as String),
resolution: json['resolution'] as String?, resolution: json['resolution'] as String?,
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) => Map<String, dynamic> _$SnAbuseReportToJson(_SnAbuseReport instance) =>

View File

@@ -15,12 +15,11 @@ _SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount(
isSuperuser: json['is_superuser'] as bool, isSuperuser: json['is_superuser'] as bool,
automatedId: json['automated_id'] as String?, automatedId: json['automated_id'] as String?,
profile: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>), profile: SnAccountProfile.fromJson(json['profile'] as Map<String, dynamic>),
perkSubscription: perkSubscription: json['perk_subscription'] == null
json['perk_subscription'] == null ? null
? null : SnWalletSubscriptionRef.fromJson(
: SnWalletSubscriptionRef.fromJson( json['perk_subscription'] as Map<String, dynamic>,
json['perk_subscription'] as Map<String, dynamic>, ),
),
badges: badges:
(json['badges'] as List<dynamic>?) (json['badges'] as List<dynamic>?)
?.map((e) => SnAccountBadge.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnAccountBadge.fromJson(e as Map<String, dynamic>))
@@ -31,16 +30,14 @@ _SnAccount _$SnAccountFromJson(Map<String, dynamic> json) => _SnAccount(
?.map((e) => SnContactMethod.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnContactMethod.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
activatedAt: activatedAt: json['activated_at'] == null
json['activated_at'] == null ? null
? null : DateTime.parse(json['activated_at'] as String),
: DateTime.parse(json['activated_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) => Map<String, dynamic> _$SnAccountToJson(_SnAccount instance) =>
@@ -73,8 +70,9 @@ _UsernameColor _$UsernameColorFromJson(Map<String, dynamic> json) =>
type: json['type'] as String? ?? 'plain', type: json['type'] as String? ?? 'plain',
value: json['value'] as String?, value: json['value'] as String?,
direction: json['direction'] as String?, direction: json['direction'] as String?,
colors: colors: (json['colors'] as List<dynamic>?)
(json['colors'] as List<dynamic>?)?.map((e) => e as String).toList(), ?.map((e) => e as String)
.toList(),
); );
Map<String, dynamic> _$UsernameColorToJson(_UsernameColor instance) => Map<String, dynamic> _$UsernameColorToJson(_UsernameColor instance) =>
@@ -85,69 +83,55 @@ Map<String, dynamic> _$UsernameColorToJson(_UsernameColor instance) =>
'colors': instance.colors, 'colors': instance.colors,
}; };
_SnAccountProfile _$SnAccountProfileFromJson(Map<String, dynamic> json) => _SnAccountProfile _$SnAccountProfileFromJson(
_SnAccountProfile( Map<String, dynamic> json,
id: json['id'] as String, ) => _SnAccountProfile(
firstName: json['first_name'] as String? ?? '', id: json['id'] as String,
middleName: json['middle_name'] as String? ?? '', firstName: json['first_name'] as String? ?? '',
lastName: json['last_name'] as String? ?? '', middleName: json['middle_name'] as String? ?? '',
bio: json['bio'] as String? ?? '', lastName: json['last_name'] as String? ?? '',
gender: json['gender'] as String? ?? '', bio: json['bio'] as String? ?? '',
pronouns: json['pronouns'] as String? ?? '', gender: json['gender'] as String? ?? '',
location: json['location'] as String? ?? '', pronouns: json['pronouns'] as String? ?? '',
timeZone: json['time_zone'] as String? ?? '', location: json['location'] as String? ?? '',
birthday: timeZone: json['time_zone'] as String? ?? '',
json['birthday'] == null birthday: json['birthday'] == null
? null ? null
: DateTime.parse(json['birthday'] as String), : DateTime.parse(json['birthday'] as String),
links: links: json['links'] == null
json['links'] == null ? const []
? const [] : const ProfileLinkConverter().fromJson(json['links']),
: const ProfileLinkConverter().fromJson(json['links']), lastSeenAt: json['last_seen_at'] == null
lastSeenAt: ? null
json['last_seen_at'] == null : DateTime.parse(json['last_seen_at'] as String),
? null activeBadge: json['active_badge'] == null
: DateTime.parse(json['last_seen_at'] as String), ? null
activeBadge: : SnAccountBadge.fromJson(json['active_badge'] as Map<String, dynamic>),
json['active_badge'] == null experience: (json['experience'] as num).toInt(),
? null level: (json['level'] as num).toInt(),
: SnAccountBadge.fromJson( socialCredits: (json['social_credits'] as num?)?.toDouble() ?? 100,
json['active_badge'] as Map<String, dynamic>, socialCreditsLevel: (json['social_credits_level'] as num?)?.toInt() ?? 0,
), levelingProgress: (json['leveling_progress'] as num).toDouble(),
experience: (json['experience'] as num).toInt(), picture: json['picture'] == null
level: (json['level'] as num).toInt(), ? null
socialCredits: (json['social_credits'] as num?)?.toDouble() ?? 100, : SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
socialCreditsLevel: (json['social_credits_level'] as num?)?.toInt() ?? 0, background: json['background'] == null
levelingProgress: (json['leveling_progress'] as num).toDouble(), ? null
picture: : SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
json['picture'] == null verification: json['verification'] == null
? null ? null
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>), : SnVerificationMark.fromJson(
background: json['verification'] as Map<String, dynamic>,
json['background'] == null ),
? null usernameColor: json['username_color'] == null
: SnCloudFile.fromJson( ? null
json['background'] as Map<String, dynamic>, : UsernameColor.fromJson(json['username_color'] as Map<String, dynamic>),
), createdAt: DateTime.parse(json['created_at'] as String),
verification: updatedAt: DateTime.parse(json['updated_at'] as String),
json['verification'] == null deletedAt: json['deleted_at'] == null
? null ? null
: SnVerificationMark.fromJson( : DateTime.parse(json['deleted_at'] as String),
json['verification'] as Map<String, dynamic>, );
),
usernameColor:
json['username_color'] == null
? null
: UsernameColor.fromJson(
json['username_color'] 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> _$SnAccountProfileToJson(_SnAccountProfile instance) => Map<String, dynamic> _$SnAccountProfileToJson(_SnAccountProfile instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -188,17 +172,15 @@ _SnAccountStatus _$SnAccountStatusFromJson(Map<String, dynamic> json) =>
isCustomized: json['is_customized'] as bool, isCustomized: json['is_customized'] as bool,
label: json['label'] as String? ?? "", label: json['label'] as String? ?? "",
meta: json['meta'] as Map<String, dynamic>?, meta: json['meta'] as Map<String, dynamic>?,
clearedAt: clearedAt: json['cleared_at'] == null
json['cleared_at'] == null ? null
? null : DateTime.parse(json['cleared_at'] as String),
: DateTime.parse(json['cleared_at'] as String),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) => Map<String, dynamic> _$SnAccountStatusToJson(_SnAccountStatus instance) =>
@@ -225,21 +207,18 @@ _SnAccountBadge _$SnAccountBadgeFromJson(Map<String, dynamic> json) =>
label: json['label'] as String?, label: json['label'] as String?,
caption: json['caption'] as String?, caption: json['caption'] as String?,
meta: json['meta'] as Map<String, dynamic>, meta: json['meta'] as Map<String, dynamic>,
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
activatedAt: activatedAt: json['activated_at'] == null
json['activated_at'] == null ? null
? null : DateTime.parse(json['activated_at'] as String),
: DateTime.parse(json['activated_at'] as String), deletedAt: json['deleted_at'] == null
deletedAt: ? null
json['deleted_at'] == null : DateTime.parse(json['deleted_at'] as String),
? null
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) => Map<String, dynamic> _$SnAccountBadgeToJson(_SnAccountBadge instance) =>
@@ -261,20 +240,18 @@ _SnContactMethod _$SnContactMethodFromJson(Map<String, dynamic> json) =>
_SnContactMethod( _SnContactMethod(
id: json['id'] as String, id: json['id'] as String,
type: (json['type'] as num).toInt(), type: (json['type'] as num).toInt(),
verifiedAt: verifiedAt: json['verified_at'] == null
json['verified_at'] == null ? null
? null : DateTime.parse(json['verified_at'] as String),
: DateTime.parse(json['verified_at'] as String),
isPrimary: json['is_primary'] as bool, isPrimary: json['is_primary'] as bool,
isPublic: json['is_public'] as bool, isPublic: json['is_public'] as bool,
content: json['content'] as String, content: json['content'] as String,
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnContactMethodToJson(_SnContactMethod instance) => Map<String, dynamic> _$SnContactMethodToJson(_SnContactMethod instance) =>
@@ -295,10 +272,9 @@ _SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) =>
_SnNotification( _SnNotification(
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String, id: json['id'] as String,
topic: json['topic'] as String, topic: json['topic'] as String,
title: json['title'] as String, title: json['title'] as String,
@@ -306,10 +282,9 @@ _SnNotification _$SnNotificationFromJson(Map<String, dynamic> json) =>
content: json['content'] as String, content: json['content'] as String,
meta: json['meta'] as Map<String, dynamic>? ?? const {}, meta: json['meta'] as Map<String, dynamic>? ?? const {},
priority: (json['priority'] as num).toInt(), priority: (json['priority'] as num).toInt(),
viewedAt: viewedAt: json['viewed_at'] == null
json['viewed_at'] == null ? null
? null : DateTime.parse(json['viewed_at'] as String),
: DateTime.parse(json['viewed_at'] as String),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
); );
@@ -376,10 +351,9 @@ _SnAuthDeviceWithSessione _$SnAuthDeviceWithSessioneFromJson(
deviceLabel: json['device_label'] as String?, deviceLabel: json['device_label'] as String?,
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
platform: (json['platform'] as num).toInt(), platform: (json['platform'] as num).toInt(),
sessions: sessions: (json['sessions'] as List<dynamic>)
(json['sessions'] as List<dynamic>) .map((e) => SnAuthSession.fromJson(e as Map<String, dynamic>))
.map((e) => SnAuthSession.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
isCurrent: json['is_current'] as bool? ?? false, isCurrent: json['is_current'] as bool? ?? false,
); );
@@ -405,10 +379,9 @@ _SnExperienceRecord _$SnExperienceRecordFromJson(Map<String, dynamic> json) =>
bonusMultiplier: (json['bonus_multiplier'] as num?)?.toDouble() ?? 1.0, bonusMultiplier: (json['bonus_multiplier'] as num?)?.toDouble() ?? 1.0,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnExperienceRecordToJson(_SnExperienceRecord instance) => Map<String, dynamic> _$SnExperienceRecordToJson(_SnExperienceRecord instance) =>
@@ -430,16 +403,14 @@ _SnSocialCreditRecord _$SnSocialCreditRecordFromJson(
delta: (json['delta'] as num).toDouble(), delta: (json['delta'] as num).toDouble(),
reasonType: json['reason_type'] as String, reasonType: json['reason_type'] as String,
reason: json['reason'] as String, reason: json['reason'] as String,
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnSocialCreditRecordToJson( Map<String, dynamic> _$SnSocialCreditRecordToJson(
@@ -460,10 +431,9 @@ _SnFriendOverviewItem _$SnFriendOverviewItemFromJson(
) => _SnFriendOverviewItem( ) => _SnFriendOverviewItem(
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>), account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>), status: SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
activities: activities: (json['activities'] as List<dynamic>)
(json['activities'] as List<dynamic>) .map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>))
.map((e) => SnPresenceActivity.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
); );
Map<String, dynamic> _$SnFriendOverviewItemToJson( Map<String, dynamic> _$SnFriendOverviewItemToJson(

View File

@@ -12,10 +12,9 @@ _SnNotableDay _$SnNotableDayFromJson(Map<String, dynamic> json) =>
localName: json['local_name'] as String, localName: json['local_name'] as String,
globalName: json['global_name'] as String, globalName: json['global_name'] as String,
countryCode: json['country_code'] as String, countryCode: json['country_code'] as String,
holidays: holidays: (json['holidays'] as List<dynamic>)
(json['holidays'] as List<dynamic>) .map((e) => (e as num).toInt())
.map((e) => (e as num).toInt()) .toList(),
.toList(),
); );
Map<String, dynamic> _$SnNotableDayToJson(_SnNotableDay instance) => Map<String, dynamic> _$SnNotableDayToJson(_SnNotableDay instance) =>
@@ -35,10 +34,9 @@ _SnTimelineEvent _$SnTimelineEventFromJson(Map<String, dynamic> json) =>
data: json['data'], data: json['data'],
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnTimelineEventToJson(_SnTimelineEvent instance) => Map<String, dynamic> _$SnTimelineEventToJson(_SnTimelineEvent instance) =>
@@ -56,21 +54,18 @@ _SnCheckInResult _$SnCheckInResultFromJson(Map<String, dynamic> json) =>
_SnCheckInResult( _SnCheckInResult(
id: json['id'] as String, id: json['id'] as String,
level: (json['level'] as num).toInt(), level: (json['level'] as num).toInt(),
tips: tips: (json['tips'] as List<dynamic>)
(json['tips'] as List<dynamic>) .map((e) => SnFortuneTip.fromJson(e as Map<String, dynamic>))
.map((e) => SnFortuneTip.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnCheckInResultToJson(_SnCheckInResult instance) => Map<String, dynamic> _$SnCheckInResultToJson(_SnCheckInResult instance) =>
@@ -103,16 +98,14 @@ _SnEventCalendarEntry _$SnEventCalendarEntryFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => _SnEventCalendarEntry( ) => _SnEventCalendarEntry(
date: DateTime.parse(json['date'] as String), date: DateTime.parse(json['date'] as String),
checkInResult: checkInResult: json['check_in_result'] == null
json['check_in_result'] == null ? null
? null : SnCheckInResult.fromJson(
: SnCheckInResult.fromJson( json['check_in_result'] as Map<String, dynamic>,
json['check_in_result'] as Map<String, dynamic>, ),
), statuses: (json['statuses'] as List<dynamic>)
statuses: .map((e) => SnAccountStatus.fromJson(e as Map<String, dynamic>))
(json['statuses'] as List<dynamic>) .toList(),
.map((e) => SnAccountStatus.fromJson(e as Map<String, dynamic>))
.toList(),
); );
Map<String, dynamic> _$SnEventCalendarEntryToJson( Map<String, dynamic> _$SnEventCalendarEntryToJson(
@@ -141,10 +134,9 @@ _SnPresenceActivity _$SnPresenceActivityFromJson(Map<String, dynamic> json) =>
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPresenceActivityToJson(_SnPresenceActivity instance) => Map<String, dynamic> _$SnPresenceActivityToJson(_SnPresenceActivity instance) =>

View File

@@ -34,35 +34,29 @@ Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) =>
_SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
_SnAuthChallenge( _SnAuthChallenge(
id: json['id'] as String, id: json['id'] as String,
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
stepRemain: (json['step_remain'] as num).toInt(), stepRemain: (json['step_remain'] as num).toInt(),
stepTotal: (json['step_total'] as num).toInt(), stepTotal: (json['step_total'] as num).toInt(),
failedAttempts: (json['failed_attempts'] as num).toInt(), failedAttempts: (json['failed_attempts'] as num).toInt(),
blacklistFactors: blacklistFactors: (json['blacklist_factors'] as List<dynamic>)
(json['blacklist_factors'] as List<dynamic>) .map((e) => e as String)
.map((e) => e as String) .toList(),
.toList(),
audiences: json['audiences'] as List<dynamic>, audiences: json['audiences'] as List<dynamic>,
scopes: json['scopes'] as List<dynamic>, scopes: json['scopes'] as List<dynamic>,
ipAddress: json['ip_address'] as String, ipAddress: json['ip_address'] as String,
userAgent: json['user_agent'] as String, userAgent: json['user_agent'] as String,
nonce: json['nonce'] as String?, nonce: json['nonce'] as String?,
location: location: json['location'] == null
json['location'] == null ? null
? null : GeoIpLocation.fromJson(json['location'] as Map<String, dynamic>),
: GeoIpLocation.fromJson(
json['location'] as Map<String, dynamic>,
),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) => Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) =>
@@ -90,28 +84,23 @@ _SnAuthSession _$SnAuthSessionFromJson(Map<String, dynamic> json) =>
id: json['id'] as String, id: json['id'] as String,
label: json['label'] as String?, label: json['label'] as String?,
lastGrantedAt: DateTime.parse(json['last_granted_at'] as String), lastGrantedAt: DateTime.parse(json['last_granted_at'] as String),
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
audiences: json['audiences'] as List<dynamic>, audiences: json['audiences'] as List<dynamic>,
scopes: json['scopes'] as List<dynamic>, scopes: json['scopes'] as List<dynamic>,
ipAddress: json['ip_address'] as String?, ipAddress: json['ip_address'] as String?,
userAgent: json['user_agent'] as String?, userAgent: json['user_agent'] as String?,
location: location: json['location'] == null
json['location'] == null ? null
? null : GeoIpLocation.fromJson(json['location'] as Map<String, dynamic>),
: GeoIpLocation.fromJson(
json['location'] as Map<String, dynamic>,
),
type: (json['type'] as num).toInt(), type: (json['type'] as num).toInt(),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) => Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) =>
@@ -138,18 +127,15 @@ _SnAuthFactor _$SnAuthFactorFromJson(Map<String, dynamic> json) =>
type: (json['type'] as num).toInt(), type: (json['type'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String), expiredAt: json['expired_at'] == null
expiredAt: ? null
json['expired_at'] == null : DateTime.parse(json['expired_at'] as String),
? null enabledAt: json['enabled_at'] == null
: DateTime.parse(json['expired_at'] as String), ? null
enabledAt: : DateTime.parse(json['enabled_at'] as String),
json['enabled_at'] == null
? null
: DateTime.parse(json['enabled_at'] as String),
trustworthy: (json['trustworthy'] as num).toInt(), trustworthy: (json['trustworthy'] as num).toInt(),
createdResponse: json['created_response'] as Map<String, dynamic>?, createdResponse: json['created_response'] as Map<String, dynamic>?,
); );
@@ -177,10 +163,9 @@ _SnAccountConnection _$SnAccountConnectionFromJson(Map<String, dynamic> json) =>
lastUsedAt: DateTime.parse(json['last_used_at'] as String), lastUsedAt: DateTime.parse(json['last_used_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnAccountConnectionToJson( Map<String, dynamic> _$SnAccountConnectionToJson(

View File

@@ -10,10 +10,9 @@ AutoCompletionAccountResponse _$AutoCompletionAccountResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AutoCompletionAccountResponse( ) => AutoCompletionAccountResponse(
type: json['type'] as String, type: json['type'] as String,
items: items: (json['items'] as List<dynamic>)
(json['items'] as List<dynamic>) .map((e) => AutoCompletionItem.fromJson(e as Map<String, dynamic>))
.map((e) => AutoCompletionItem.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
$type: json['runtimeType'] as String?, $type: json['runtimeType'] as String?,
); );
@@ -29,10 +28,9 @@ AutoCompletionStickerResponse _$AutoCompletionStickerResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => AutoCompletionStickerResponse( ) => AutoCompletionStickerResponse(
type: json['type'] as String, type: json['type'] as String,
items: items: (json['items'] as List<dynamic>)
(json['items'] as List<dynamic>) .map((e) => AutoCompletionItem.fromJson(e as Map<String, dynamic>))
.map((e) => AutoCompletionItem.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
$type: json['runtimeType'] as String?, $type: json['runtimeType'] as String?,
); );

View File

@@ -14,10 +14,9 @@ _Bot _$BotFromJson(Map<String, dynamic> json) => _Bot(
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>), account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
developer: developer: json['developer'] == null
json['developer'] == null ? null
? null : SnDeveloper.fromJson(json['developer'] as Map<String, dynamic>),
: SnDeveloper.fromJson(json['developer'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$BotToJson(_Bot instance) => <String, dynamic>{ Map<String, dynamic> _$BotToJson(_Bot instance) => <String, dynamic>{
@@ -74,10 +73,9 @@ _BotSecret _$BotSecretFromJson(Map<String, dynamic> json) => _BotSecret(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
secret: json['secret'] as String? ?? '', secret: json['secret'] as String? ?? '',
description: json['description'] as String?, description: json['description'] as String?,
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
botId: json['bot_id'] as String? ?? '', botId: json['bot_id'] as String? ?? '',
); );

View File

@@ -13,30 +13,25 @@ _SnChatRoom _$SnChatRoomFromJson(Map<String, dynamic> json) => _SnChatRoom(
type: (json['type'] as num).toInt(), type: (json['type'] as num).toInt(),
isPublic: json['is_public'] as bool? ?? false, isPublic: json['is_public'] as bool? ?? false,
isCommunity: json['is_community'] as bool? ?? false, isCommunity: json['is_community'] as bool? ?? false,
picture: picture: json['picture'] == null
json['picture'] == null ? null
? null : SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>), background: json['background'] == null
background: ? null
json['background'] == null : SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
? null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
realmId: json['realm_id'] as String?, realmId: json['realm_id'] as String?,
accountId: json['account_id'] as String?, accountId: json['account_id'] as String?,
realm: realm: json['realm'] == null
json['realm'] == null ? null
? null : SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String), members: (json['members'] as List<dynamic>?)
members: ?.map((e) => SnChatMember.fromJson(e as Map<String, dynamic>))
(json['members'] as List<dynamic>?) .toList(),
?.map((e) => SnChatMember.fromJson(e as Map<String, dynamic>))
.toList(),
); );
Map<String, dynamic> _$SnChatRoomToJson(_SnChatRoom instance) => Map<String, dynamic> _$SnChatRoomToJson(_SnChatRoom instance) =>
@@ -62,10 +57,9 @@ _SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) =>
_SnChatMessage( _SnChatMessage(
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String, id: json['id'] as String,
type: json['type'] as String? ?? 'text', type: json['type'] as String? ?? 'text',
content: json['content'] as String?, content: json['content'] as String?,
@@ -76,10 +70,9 @@ _SnChatMessage _$SnChatMessageFromJson(Map<String, dynamic> json) =>
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [], const [],
editedAt: editedAt: json['edited_at'] == null
json['edited_at'] == null ? null
? null : DateTime.parse(json['edited_at'] as String),
: DateTime.parse(json['edited_at'] as String),
attachments: attachments:
(json['attachments'] as List<dynamic>?) (json['attachments'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
@@ -122,10 +115,9 @@ _SnChatReaction _$SnChatReactionFromJson(Map<String, dynamic> json) =>
_SnChatReaction( _SnChatReaction(
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String, id: json['id'] as String,
messageId: json['message_id'] as String, messageId: json['message_id'] as String,
senderId: json['sender_id'] as String, senderId: json['sender_id'] as String,
@@ -151,42 +143,33 @@ _SnChatMember _$SnChatMemberFromJson(Map<String, dynamic> json) =>
_SnChatMember( _SnChatMember(
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
id: json['id'] as String, id: json['id'] as String,
chatRoomId: json['chat_room_id'] as String, chatRoomId: json['chat_room_id'] as String,
chatRoom: chatRoom: json['chat_room'] == null
json['chat_room'] == null ? null
? null : SnChatRoom.fromJson(json['chat_room'] as Map<String, dynamic>),
: SnChatRoom.fromJson(json['chat_room'] as Map<String, dynamic>),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>), account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
nick: json['nick'] as String?, nick: json['nick'] as String?,
notify: (json['notify'] as num).toInt(), notify: (json['notify'] as num).toInt(),
joinedAt: joinedAt: json['joined_at'] == null
json['joined_at'] == null ? null
? null : DateTime.parse(json['joined_at'] as String),
: DateTime.parse(json['joined_at'] as String), breakUntil: json['break_until'] == null
breakUntil: ? null
json['break_until'] == null : DateTime.parse(json['break_until'] as String),
? null timeoutUntil: json['timeout_until'] == null
: DateTime.parse(json['break_until'] as String), ? null
timeoutUntil: : DateTime.parse(json['timeout_until'] as String),
json['timeout_until'] == null status: json['status'] == null
? null ? null
: DateTime.parse(json['timeout_until'] as String), : SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
status: lastTyped: json['last_typed'] == null
json['status'] == null ? null
? null : DateTime.parse(json['last_typed'] as String),
: SnAccountStatus.fromJson(
json['status'] as Map<String, dynamic>,
),
lastTyped:
json['last_typed'] == null
? null
: DateTime.parse(json['last_typed'] as String),
); );
Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) => Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
@@ -211,12 +194,11 @@ Map<String, dynamic> _$SnChatMemberToJson(_SnChatMember instance) =>
_SnChatSummary _$SnChatSummaryFromJson(Map<String, dynamic> json) => _SnChatSummary _$SnChatSummaryFromJson(Map<String, dynamic> json) =>
_SnChatSummary( _SnChatSummary(
unreadCount: (json['unread_count'] as num).toInt(), unreadCount: (json['unread_count'] as num).toInt(),
lastMessage: lastMessage: json['last_message'] == null
json['last_message'] == null ? null
? null : SnChatMessage.fromJson(
: SnChatMessage.fromJson( json['last_message'] as Map<String, dynamic>,
json['last_message'] as Map<String, dynamic>, ),
),
); );
Map<String, dynamic> _$SnChatSummaryToJson(_SnChatSummary instance) => Map<String, dynamic> _$SnChatSummaryToJson(_SnChatSummary instance) =>
@@ -251,10 +233,9 @@ _ChatRealtimeJoinResponse _$ChatRealtimeJoinResponseFromJson(
callId: json['call_id'] as String, callId: json['call_id'] as String,
roomName: json['room_name'] as String, roomName: json['room_name'] as String,
isAdmin: json['is_admin'] as bool, isAdmin: json['is_admin'] as bool,
participants: participants: (json['participants'] as List<dynamic>)
(json['participants'] as List<dynamic>) .map((e) => CallParticipant.fromJson(e as Map<String, dynamic>))
.map((e) => CallParticipant.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
); );
Map<String, dynamic> _$ChatRealtimeJoinResponseToJson( Map<String, dynamic> _$ChatRealtimeJoinResponseToJson(
@@ -288,14 +269,12 @@ _SnRealtimeCall _$SnRealtimeCallFromJson(Map<String, dynamic> json) =>
id: json['id'] as String, id: json['id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String), endedAt: json['ended_at'] == null
endedAt: ? null
json['ended_at'] == null : DateTime.parse(json['ended_at'] as String),
? null
: DateTime.parse(json['ended_at'] as String),
senderId: json['sender_id'] as String, senderId: json['sender_id'] as String,
sender: SnChatMember.fromJson(json['sender'] as Map<String, dynamic>), sender: SnChatMember.fromJson(json['sender'] as Map<String, dynamic>),
roomId: json['room_id'] as String, roomId: json['room_id'] as String,

View File

@@ -12,30 +12,25 @@ _CustomApp _$CustomAppFromJson(Map<String, dynamic> json) => _CustomApp(
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
description: json['description'] as String?, description: json['description'] as String?,
status: (json['status'] as num?)?.toInt() ?? 0, status: (json['status'] as num?)?.toInt() ?? 0,
picture: picture: json['picture'] == null
json['picture'] == null ? null
? null : SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>), background: json['background'] == null
background: ? null
json['background'] == null : SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
? null verification: json['verification'] == null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>), ? null
verification: : SnVerificationMark.fromJson(
json['verification'] == null json['verification'] as Map<String, dynamic>,
? null ),
: SnVerificationMark.fromJson( oauthConfig: json['oauth_config'] == null
json['verification'] as Map<String, dynamic>, ? null
), : CustomAppOauthConfig.fromJson(
oauthConfig: json['oauth_config'] as Map<String, dynamic>,
json['oauth_config'] == null ),
? null links: json['links'] == null
: CustomAppOauthConfig.fromJson( ? null
json['oauth_config'] as Map<String, dynamic>, : CustomAppLinks.fromJson(json['links'] as Map<String, dynamic>),
),
links:
json['links'] == null
? null
: CustomAppLinks.fromJson(json['links'] as Map<String, dynamic>),
secrets: secrets:
(json['secrets'] as List<dynamic>?) (json['secrets'] as List<dynamic>?)
?.map((e) => CustomAppSecret.fromJson(e as Map<String, dynamic>)) ?.map((e) => CustomAppSecret.fromJson(e as Map<String, dynamic>))
@@ -83,10 +78,9 @@ _CustomAppOauthConfig _$CustomAppOauthConfigFromJson(
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [], const [],
postLogoutRedirectUris: postLogoutRedirectUris: (json['post_logout_redirect_uris'] as List<dynamic>?)
(json['post_logout_redirect_uris'] as List<dynamic>?) ?.map((e) => e as String)
?.map((e) => e as String) .toList(),
.toList(),
allowedScopes: allowedScopes:
(json['allowed_scopes'] as List<dynamic>?) (json['allowed_scopes'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
@@ -118,10 +112,9 @@ _CustomAppSecret _$CustomAppSecretFromJson(Map<String, dynamic> json) =>
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
secret: json['secret'] as String? ?? '', secret: json['secret'] as String? ?? '',
description: json['description'] as String?, description: json['description'] as String?,
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
isOidc: json['is_oidc'] as bool? ?? false, isOidc: json['is_oidc'] as bool? ?? false,
appId: json['app_id'] as String? ?? '', appId: json['app_id'] as String? ?? '',
); );

View File

@@ -9,10 +9,9 @@ part of 'developer.dart';
_SnDeveloper _$SnDeveloperFromJson(Map<String, dynamic> json) => _SnDeveloper( _SnDeveloper _$SnDeveloperFromJson(Map<String, dynamic> json) => _SnDeveloper(
id: json['id'] as String, id: json['id'] as String,
publisherId: json['publisher_id'] as String, publisherId: json['publisher_id'] as String,
publisher: publisher: json['publisher'] == null
json['publisher'] == null ? null
? null : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$SnDeveloperToJson(_SnDeveloper instance) => Map<String, dynamic> _$SnDeveloperToJson(_SnDeveloper instance) =>

View File

@@ -22,10 +22,9 @@ _DriveTask _$DriveTaskFromJson(Map<String, dynamic> json) => _DriveTask(
transmissionProgress: (json['transmission_progress'] as num?)?.toDouble(), transmissionProgress: (json['transmission_progress'] as num?)?.toDouble(),
errorMessage: json['error_message'] as String?, errorMessage: json['error_message'] as String?,
statusMessage: json['status_message'] as String?, statusMessage: json['status_message'] as String?,
result: result: json['result'] == null
json['result'] == null ? null
? null : SnCloudFile.fromJson(json['result'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['result'] as Map<String, dynamic>),
poolId: json['pool_id'] as String?, poolId: json['pool_id'] as String?,
bundleId: json['bundle_id'] as String?, bundleId: json['bundle_id'] as String?,
encryptPassword: json['encrypt_password'] as String?, encryptPassword: json['encrypt_password'] as String?,

View File

@@ -17,10 +17,9 @@ _SnScrappedLink _$SnScrappedLinkFromJson(Map<String, dynamic> json) =>
siteName: json['site_name'] as String?, siteName: json['site_name'] as String?,
contentType: json['content_type'] as String?, contentType: json['content_type'] as String?,
author: json['author'] as String?, author: json['author'] as String?,
publishedDate: publishedDate: json['published_date'] == null
json['published_date'] == null ? null
? null : DateTime.parse(json['published_date'] as String),
: DateTime.parse(json['published_date'] as String),
); );
Map<String, dynamic> _$SnScrappedLinkToJson(_SnScrappedLink instance) => Map<String, dynamic> _$SnScrappedLinkToJson(_SnScrappedLink instance) =>

View File

@@ -35,10 +35,9 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
description: json['description'] as String?, description: json['description'] as String?,
fileMeta: json['file_meta'] as Map<String, dynamic>?, fileMeta: json['file_meta'] as Map<String, dynamic>?,
userMeta: json['user_meta'] as Map<String, dynamic>?, userMeta: json['user_meta'] as Map<String, dynamic>?,
pool: pool: json['pool'] == null
json['pool'] == null ? null
? null : SnFilePool.fromJson(json['pool'] as Map<String, dynamic>),
: SnFilePool.fromJson(json['pool'] as Map<String, dynamic>),
sensitiveMarks: sensitiveMarks:
(json['sensitive_marks'] as List<dynamic>?) (json['sensitive_marks'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
@@ -47,17 +46,15 @@ _SnCloudFile _$SnCloudFileFromJson(Map<String, dynamic> json) => _SnCloudFile(
mimeType: json['mime_type'] as String?, mimeType: json['mime_type'] as String?,
hash: json['hash'] as String?, hash: json['hash'] as String?,
size: (json['size'] as num).toInt(), size: (json['size'] as num).toInt(),
uploadedAt: uploadedAt: json['uploaded_at'] == null
json['uploaded_at'] == null ? null
? null : DateTime.parse(json['uploaded_at'] as String),
: DateTime.parse(json['uploaded_at'] as String),
uploadedTo: json['uploaded_to'] as String?, uploadedTo: json['uploaded_to'] as String?,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) => Map<String, dynamic> _$SnCloudFileToJson(_SnCloudFile instance) =>
@@ -87,10 +84,9 @@ _SnCloudFileIndex _$SnCloudFileIndexFromJson(Map<String, dynamic> json) =>
file: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>), file: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnCloudFileIndexToJson(_SnCloudFileIndex instance) => Map<String, dynamic> _$SnCloudFileIndexToJson(_SnCloudFileIndex instance) =>

View File

@@ -16,18 +16,15 @@ _SnFilePool _$SnFilePoolFromJson(Map<String, dynamic> json) => _SnFilePool(
isHidden: json['is_hidden'] as bool?, isHidden: json['is_hidden'] as bool?,
accountId: json['account_id'] as String?, accountId: json['account_id'] as String?,
resourceIdentifier: json['resource_identifier'] as String?, resourceIdentifier: json['resource_identifier'] as String?,
createdAt: createdAt: json['created_at'] == null
json['created_at'] == null ? null
? null : DateTime.parse(json['created_at'] as String),
: DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null
updatedAt: ? null
json['updated_at'] == null : DateTime.parse(json['updated_at'] as String),
? null deletedAt: json['deleted_at'] == null
: DateTime.parse(json['updated_at'] as String), ? null
deletedAt: : DateTime.parse(json['deleted_at'] as String),
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnFilePoolToJson(_SnFilePool instance) => Map<String, dynamic> _$SnFilePoolToJson(_SnFilePool instance) =>

View File

@@ -10,10 +10,9 @@ _SnHeatmap _$SnHeatmapFromJson(Map<String, dynamic> json) => _SnHeatmap(
unit: json['unit'] as String, unit: json['unit'] as String,
periodStart: DateTime.parse(json['period_start'] as String), periodStart: DateTime.parse(json['period_start'] as String),
periodEnd: DateTime.parse(json['period_end'] as String), periodEnd: DateTime.parse(json['period_end'] as String),
items: items: (json['items'] as List<dynamic>)
(json['items'] as List<dynamic>) .map((e) => SnHeatmapItem.fromJson(e as Map<String, dynamic>))
.map((e) => SnHeatmapItem.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
); );
Map<String, dynamic> _$SnHeatmapToJson(_SnHeatmap instance) => Map<String, dynamic> _$SnHeatmapToJson(_SnHeatmap instance) =>

View File

@@ -8,31 +8,25 @@ part of 'poll.dart';
_SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) => _SnPollWithStats _$SnPollWithStatsFromJson(Map<String, dynamic> json) =>
_SnPollWithStats( _SnPollWithStats(
userAnswer: userAnswer: json['user_answer'] == null
json['user_answer'] == null ? null
? null : SnPollAnswer.fromJson(json['user_answer'] as Map<String, dynamic>),
: SnPollAnswer.fromJson(
json['user_answer'] as Map<String, dynamic>,
),
stats: json['stats'] as Map<String, dynamic>? ?? const {}, stats: json['stats'] as Map<String, dynamic>? ?? const {},
id: json['id'] as String, id: json['id'] as String,
questions: questions: (json['questions'] as List<dynamic>)
(json['questions'] as List<dynamic>) .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
title: json['title'] as String?, title: json['title'] as String?,
description: json['description'] as String?, description: json['description'] as String?,
endedAt: endedAt: json['ended_at'] == null
json['ended_at'] == null ? null
? null : DateTime.parse(json['ended_at'] as String),
: DateTime.parse(json['ended_at'] as String),
publisherId: json['publisher_id'] as String, publisherId: json['publisher_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) => Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
@@ -52,27 +46,23 @@ Map<String, dynamic> _$SnPollWithStatsToJson(_SnPollWithStats instance) =>
_SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll( _SnPoll _$SnPollFromJson(Map<String, dynamic> json) => _SnPoll(
id: json['id'] as String, id: json['id'] as String,
questions: questions: (json['questions'] as List<dynamic>)
(json['questions'] as List<dynamic>) .map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>))
.map((e) => SnPollQuestion.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
title: json['title'] as String?, title: json['title'] as String?,
description: json['description'] as String?, description: json['description'] as String?,
endedAt: endedAt: json['ended_at'] == null
json['ended_at'] == null ? null
? null : DateTime.parse(json['ended_at'] as String),
: DateTime.parse(json['ended_at'] as String),
publisherId: json['publisher_id'] as String, publisherId: json['publisher_id'] as String,
publisher: publisher: json['publisher'] == null
json['publisher'] == null ? null
? null : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{ Map<String, dynamic> _$SnPollToJson(_SnPoll instance) => <String, dynamic>{
@@ -92,10 +82,9 @@ _SnPollQuestion _$SnPollQuestionFromJson(Map<String, dynamic> json) =>
_SnPollQuestion( _SnPollQuestion(
id: json['id'] as String, id: json['id'] as String,
type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']), type: $enumDecode(_$SnPollQuestionTypeEnumMap, json['type']),
options: options: (json['options'] as List<dynamic>?)
(json['options'] as List<dynamic>?) ?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>))
?.map((e) => SnPollOption.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
title: json['title'] as String, title: json['title'] as String,
description: json['description'] as String?, description: json['description'] as String?,
order: (json['order'] as num).toInt(), order: (json['order'] as num).toInt(),
@@ -145,14 +134,12 @@ _SnPollAnswer _$SnPollAnswerFromJson(Map<String, dynamic> json) =>
pollId: json['poll_id'] as String, pollId: json['poll_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String), account: json['account'] == null
account: ? null
json['account'] == null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$SnPollAnswerToJson(_SnPollAnswer instance) => Map<String, dynamic> _$SnPollAnswerToJson(_SnPollAnswer instance) =>

View File

@@ -11,25 +11,20 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
title: json['title'] as String?, title: json['title'] as String?,
description: json['description'] as String?, description: json['description'] as String?,
language: json['language'] as String?, language: json['language'] as String?,
editedAt: editedAt: json['edited_at'] == null
json['edited_at'] == null ? null
? null : DateTime.parse(json['edited_at'] as String),
: DateTime.parse(json['edited_at'] as String), publishedAt: json['published_at'] == null
publishedAt: ? null
json['published_at'] == null : DateTime.parse(json['published_at'] as String),
? null
: DateTime.parse(json['published_at'] as String),
visibility: (json['visibility'] as num?)?.toInt() ?? 0, visibility: (json['visibility'] as num?)?.toInt() ?? 0,
content: json['content'] as String?, content: json['content'] as String?,
slug: json['slug'] as String?, slug: json['slug'] as String?,
type: (json['type'] as num?)?.toInt() ?? 0, type: (json['type'] as num?)?.toInt() ?? 0,
meta: json['meta'] as Map<String, dynamic>?, meta: json['meta'] as Map<String, dynamic>?,
embedView: embedView: json['embed_view'] == null
json['embed_view'] == null ? null
? null : SnPostEmbedView.fromJson(json['embed_view'] as Map<String, dynamic>),
: SnPostEmbedView.fromJson(
json['embed_view'] as Map<String, dynamic>,
),
viewsUnique: (json['views_unique'] as num?)?.toInt() ?? 0, viewsUnique: (json['views_unique'] as num?)?.toInt() ?? 0,
viewsTotal: (json['views_total'] as num?)?.toInt() ?? 0, viewsTotal: (json['views_total'] as num?)?.toInt() ?? 0,
upvotes: (json['upvotes'] as num?)?.toInt() ?? 0, upvotes: (json['upvotes'] as num?)?.toInt() ?? 0,
@@ -38,25 +33,21 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
awardedScore: (json['awarded_score'] as num?)?.toInt() ?? 0, awardedScore: (json['awarded_score'] as num?)?.toInt() ?? 0,
pinMode: (json['pin_mode'] as num?)?.toInt(), pinMode: (json['pin_mode'] as num?)?.toInt(),
threadedPostId: json['threaded_post_id'] as String?, threadedPostId: json['threaded_post_id'] as String?,
threadedPost: threadedPost: json['threaded_post'] == null
json['threaded_post'] == null ? null
? null : SnPost.fromJson(json['threaded_post'] as Map<String, dynamic>),
: SnPost.fromJson(json['threaded_post'] as Map<String, dynamic>),
repliedPostId: json['replied_post_id'] as String?, repliedPostId: json['replied_post_id'] as String?,
repliedPost: repliedPost: json['replied_post'] == null
json['replied_post'] == null ? null
? null : SnPost.fromJson(json['replied_post'] as Map<String, dynamic>),
: SnPost.fromJson(json['replied_post'] as Map<String, dynamic>),
forwardedPostId: json['forwarded_post_id'] as String?, forwardedPostId: json['forwarded_post_id'] as String?,
forwardedPost: forwardedPost: json['forwarded_post'] == null
json['forwarded_post'] == null ? null
? null : SnPost.fromJson(json['forwarded_post'] as Map<String, dynamic>),
: SnPost.fromJson(json['forwarded_post'] as Map<String, dynamic>),
realmId: json['realm_id'] as String?, realmId: json['realm_id'] as String?,
realm: realm: json['realm'] == null
json['realm'] == null ? null
? null : SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
attachments: attachments:
(json['attachments'] as List<dynamic>?) (json['attachments'] as List<dynamic>?)
?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
@@ -90,18 +81,15 @@ _SnPost _$SnPostFromJson(Map<String, dynamic> json) => _SnPost(
?.map((e) => SnPostFeaturedRecord.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPostFeaturedRecord.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
createdAt: createdAt: json['created_at'] == null
json['created_at'] == null ? null
? null : DateTime.parse(json['created_at'] as String),
: DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null
updatedAt: ? null
json['updated_at'] == null : DateTime.parse(json['updated_at'] as String),
? null deletedAt: json['deleted_at'] == null
: DateTime.parse(json['updated_at'] as String), ? null
deletedAt: : DateTime.parse(json['deleted_at'] as String),
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
repliedGone: json['replied_gone'] as bool? ?? false, repliedGone: json['replied_gone'] as bool? ?? false,
forwardedGone: json['forwarded_gone'] as bool? ?? false, forwardedGone: json['forwarded_gone'] as bool? ?? false,
isTruncated: json['is_truncated'] as bool? ?? false, isTruncated: json['is_truncated'] as bool? ?? false,
@@ -214,18 +202,15 @@ _SnPostAward _$SnPostAwardFromJson(Map<String, dynamic> json) => _SnPostAward(
message: json['message'] as String?, message: json['message'] as String?,
postId: json['post_id'] as String, postId: json['post_id'] as String,
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: createdAt: json['created_at'] == null
json['created_at'] == null ? null
? null : DateTime.parse(json['created_at'] as String),
: DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null
updatedAt: ? null
json['updated_at'] == null : DateTime.parse(json['updated_at'] as String),
? null deletedAt: json['deleted_at'] == null
: DateTime.parse(json['updated_at'] as String), ? null
deletedAt: : DateTime.parse(json['deleted_at'] as String),
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPostAwardToJson(_SnPostAward instance) => Map<String, dynamic> _$SnPostAwardToJson(_SnPostAward instance) =>
@@ -250,14 +235,12 @@ _SnPostReaction _$SnPostReactionFromJson(Map<String, dynamic> json) =>
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>), deletedAt: json['deleted_at'] == null
deletedAt: ? null
json['deleted_at'] == null : DateTime.parse(json['deleted_at'] as String),
? null
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPostReactionToJson(_SnPostReaction instance) => Map<String, dynamic> _$SnPostReactionToJson(_SnPostReaction instance) =>
@@ -278,17 +261,15 @@ _SnPostFeaturedRecord _$SnPostFeaturedRecordFromJson(
) => _SnPostFeaturedRecord( ) => _SnPostFeaturedRecord(
id: json['id'] as String, id: json['id'] as String,
postId: json['post_id'] as String, postId: json['post_id'] as String,
featuredAt: featuredAt: json['featured_at'] == null
json['featured_at'] == null ? null
? null : DateTime.parse(json['featured_at'] as String),
: DateTime.parse(json['featured_at'] as String),
socialCredits: (json['social_credits'] as num).toInt(), socialCredits: (json['social_credits'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPostFeaturedRecordToJson( Map<String, dynamic> _$SnPostFeaturedRecordToJson(

View File

@@ -17,10 +17,9 @@ _SnPublicationSite _$SnPublicationSiteFromJson(Map<String, dynamic> json) =>
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
pages: pages: (json['pages'] as List<dynamic>)
(json['pages'] as List<dynamic>) .map((e) => SnPublicationPage.fromJson(e as Map<String, dynamic>))
.map((e) => SnPublicationPage.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
); );
Map<String, dynamic> _$SnPublicationSiteToJson(_SnPublicationSite instance) => Map<String, dynamic> _$SnPublicationSiteToJson(_SnPublicationSite instance) =>

View File

@@ -12,38 +12,31 @@ _SnPublisher _$SnPublisherFromJson(Map<String, dynamic> json) => _SnPublisher(
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
nick: json['nick'] as String? ?? '', nick: json['nick'] as String? ?? '',
bio: json['bio'] as String? ?? '', bio: json['bio'] as String? ?? '',
picture: picture: json['picture'] == null
json['picture'] == null ? null
? null : SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>), background: json['background'] == null
background: ? null
json['background'] == null : SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
? null account: json['account'] == null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>), ? null
account: : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
json['account'] == null
? null
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
accountId: json['account_id'] as String?, accountId: json['account_id'] as String?,
createdAt: createdAt: json['created_at'] == null
json['created_at'] == null ? null
? null : DateTime.parse(json['created_at'] as String),
: DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null
updatedAt: ? null
json['updated_at'] == null : DateTime.parse(json['updated_at'] as String),
? null deletedAt: json['deleted_at'] == null
: DateTime.parse(json['updated_at'] as String), ? null
deletedAt: : DateTime.parse(json['deleted_at'] as String),
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
realmId: json['realm_id'] as String?, realmId: json['realm_id'] as String?,
verification: verification: json['verification'] == null
json['verification'] == null ? null
? null : SnVerificationMark.fromJson(
: SnVerificationMark.fromJson( json['verification'] as Map<String, dynamic>,
json['verification'] as Map<String, dynamic>, ),
),
); );
Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) => Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) =>
@@ -67,26 +60,22 @@ Map<String, dynamic> _$SnPublisherToJson(_SnPublisher instance) =>
_SnPublisherMember _$SnPublisherMemberFromJson(Map<String, dynamic> json) => _SnPublisherMember _$SnPublisherMemberFromJson(Map<String, dynamic> json) =>
_SnPublisherMember( _SnPublisherMember(
publisherId: json['publisher_id'] as String, publisherId: json['publisher_id'] as String,
publisher: publisher: json['publisher'] == null
json['publisher'] == null ? null
? null : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
role: (json['role'] as num).toInt(), role: (json['role'] as num).toInt(),
joinedAt: joinedAt: json['joined_at'] == null
json['joined_at'] == null ? null
? null : DateTime.parse(json['joined_at'] as String),
: DateTime.parse(json['joined_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnPublisherMemberToJson(_SnPublisherMember instance) => Map<String, dynamic> _$SnPublisherMemberToJson(_SnPublisherMember instance) =>

View File

@@ -12,27 +12,23 @@ _SnRealm _$SnRealmFromJson(Map<String, dynamic> json) => _SnRealm(
name: json['name'] as String? ?? '', name: json['name'] as String? ?? '',
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
verifiedAs: json['verified_as'] as String?, verifiedAs: json['verified_as'] as String?,
verifiedAt: verifiedAt: json['verified_at'] == null
json['verified_at'] == null ? null
? null : DateTime.parse(json['verified_at'] as String),
: DateTime.parse(json['verified_at'] as String),
isCommunity: json['is_community'] as bool, isCommunity: json['is_community'] as bool,
isPublic: json['is_public'] as bool, isPublic: json['is_public'] as bool,
picture: picture: json['picture'] == null
json['picture'] == null ? null
? null : SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['picture'] as Map<String, dynamic>), background: json['background'] == null
background: ? null
json['background'] == null : SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
? null
: SnCloudFile.fromJson(json['background'] as Map<String, dynamic>),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnRealmToJson(_SnRealm instance) => <String, dynamic>{ Map<String, dynamic> _$SnRealmToJson(_SnRealm instance) => <String, dynamic>{
@@ -55,32 +51,25 @@ Map<String, dynamic> _$SnRealmToJson(_SnRealm instance) => <String, dynamic>{
_SnRealmMember _$SnRealmMemberFromJson(Map<String, dynamic> json) => _SnRealmMember _$SnRealmMemberFromJson(Map<String, dynamic> json) =>
_SnRealmMember( _SnRealmMember(
realmId: json['realm_id'] as String, realmId: json['realm_id'] as String,
realm: realm: json['realm'] == null
json['realm'] == null ? null
? null : SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
: SnRealm.fromJson(json['realm'] as Map<String, dynamic>),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
role: (json['role'] as num).toInt(), role: (json['role'] as num).toInt(),
joinedAt: joinedAt: json['joined_at'] == null
json['joined_at'] == null ? null
? null : DateTime.parse(json['joined_at'] as String),
: DateTime.parse(json['joined_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String), status: json['status'] == null
status: ? null
json['status'] == null : SnAccountStatus.fromJson(json['status'] as Map<String, dynamic>),
? null
: SnAccountStatus.fromJson(
json['status'] as Map<String, dynamic>,
),
); );
Map<String, dynamic> _$SnRealmMemberToJson(_SnRealmMember instance) => Map<String, dynamic> _$SnRealmMemberToJson(_SnRealmMember instance) =>

View File

@@ -9,22 +9,19 @@ part of 'reference.dart';
_Reference _$ReferenceFromJson(Map<String, dynamic> json) => _Reference( _Reference _$ReferenceFromJson(Map<String, dynamic> json) => _Reference(
id: json['id'] as String, id: json['id'] as String,
fileId: json['file_id'] as String, fileId: json['file_id'] as String,
file: file: json['file'] == null
json['file'] == null ? null
? null : SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['file'] as Map<String, dynamic>),
usage: json['usage'] as String, usage: json['usage'] as String,
resourceId: json['resource_id'] as String, resourceId: json['resource_id'] as String,
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$ReferenceToJson(_Reference instance) => Map<String, dynamic> _$ReferenceToJson(_Reference instance) =>

View File

@@ -8,26 +8,22 @@ part of 'relationship.dart';
_SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) => _SnRelationship _$SnRelationshipFromJson(Map<String, dynamic> json) =>
_SnRelationship( _SnRelationship(
createdAt: createdAt: json['created_at'] == null
json['created_at'] == null ? null
? null : DateTime.parse(json['created_at'] as String),
: DateTime.parse(json['created_at'] as String), updatedAt: json['updated_at'] == null
updatedAt: ? null
json['updated_at'] == null : DateTime.parse(json['updated_at'] as String),
? null deletedAt: json['deleted_at'] == null
: DateTime.parse(json['updated_at'] as String), ? null
deletedAt: : DateTime.parse(json['deleted_at'] as String),
json['deleted_at'] == null
? null
: DateTime.parse(json['deleted_at'] as String),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: SnAccount.fromJson(json['account'] as Map<String, dynamic>), account: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
relatedId: json['related_id'] as String, relatedId: json['related_id'] as String,
related: SnAccount.fromJson(json['related'] as Map<String, dynamic>), related: SnAccount.fromJson(json['related'] as Map<String, dynamic>),
expiredAt: expiredAt: json['expired_at'] == null
json['expired_at'] == null ? null
? null : DateTime.parse(json['expired_at'] as String),
: DateTime.parse(json['expired_at'] as String),
status: (json['status'] as num).toInt(), status: (json['status'] as num).toInt(),
); );

View File

@@ -11,16 +11,14 @@ _SnSticker _$SnStickerFromJson(Map<String, dynamic> json) => _SnSticker(
slug: json['slug'] as String, slug: json['slug'] as String,
image: SnCloudFile.fromJson(json['image'] as Map<String, dynamic>), image: SnCloudFile.fromJson(json['image'] as Map<String, dynamic>),
packId: json['pack_id'] as String, packId: json['pack_id'] as String,
pack: pack: json['pack'] == null
json['pack'] == null ? null
? null : SnStickerPack.fromJson(json['pack'] as Map<String, dynamic>),
: SnStickerPack.fromJson(json['pack'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnStickerToJson(_SnSticker instance) => Map<String, dynamic> _$SnStickerToJson(_SnSticker instance) =>
@@ -42,20 +40,17 @@ _SnStickerPack _$SnStickerPackFromJson(Map<String, dynamic> json) =>
description: json['description'] as String, description: json['description'] as String,
prefix: json['prefix'] as String, prefix: json['prefix'] as String,
publisherId: json['publisher_id'] as String, publisherId: json['publisher_id'] as String,
icon: icon: json['icon'] == null
json['icon'] == null ? null
? null : SnCloudFile.fromJson(json['icon'] as Map<String, dynamic>),
: SnCloudFile.fromJson(json['icon'] as Map<String, dynamic>), publisher: json['publisher'] == null
publisher: ? null
json['publisher'] == null : SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
? null
: SnPublisher.fromJson(json['publisher'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
stickers: stickers:
(json['stickers'] as List<dynamic>?) (json['stickers'] as List<dynamic>?)
?.map((e) => SnSticker.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnSticker.fromJson(e as Map<String, dynamic>))

View File

@@ -16,14 +16,12 @@ _StreamThinkingRequest _$StreamThinkingRequestFromJson(
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [], const [],
attachedPosts: attachedPosts: (json['attached_posts'] as List<dynamic>?)
(json['attached_posts'] as List<dynamic>?) ?.map((e) => e as String)
?.map((e) => e as String) .toList(),
.toList(), attachedMessages: (json['attached_messages'] as List<dynamic>?)
attachedMessages: ?.map((e) => e as Map<String, dynamic>)
(json['attached_messages'] as List<dynamic>?) .toList(),
?.map((e) => e as Map<String, dynamic>)
.toList(),
serviceId: json['service_id'] as String?, serviceId: json['service_id'] as String?,
); );
@@ -87,18 +85,14 @@ _SnThinkingMessagePart _$SnThinkingMessagePartFromJson(
(json['type'] as num).toInt(), (json['type'] as num).toInt(),
), ),
text: json['text'] as String?, text: json['text'] as String?,
functionCall: functionCall: json['function_call'] == null
json['function_call'] == null ? null
? null : SnFunctionCall.fromJson(json['function_call'] as Map<String, dynamic>),
: SnFunctionCall.fromJson( functionResult: json['function_result'] == null
json['function_call'] as Map<String, dynamic>, ? null
), : SnFunctionResult.fromJson(
functionResult: json['function_result'] as Map<String, dynamic>,
json['function_result'] == null ),
? null
: SnFunctionResult.fromJson(
json['function_result'] as Map<String, dynamic>,
),
); );
Map<String, dynamic> _$SnThinkingMessagePartToJson( Map<String, dynamic> _$SnThinkingMessagePartToJson(
@@ -119,10 +113,9 @@ _SnThinkingSequence _$SnThinkingSequenceFromJson(Map<String, dynamic> json) =>
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) => Map<String, dynamic> _$SnThinkingSequenceToJson(_SnThinkingSequence instance) =>
@@ -159,18 +152,16 @@ _SnThinkingThought _$SnThinkingThoughtFromJson(Map<String, dynamic> json) =>
tokenCount: (json['token_count'] as num?)?.toInt(), tokenCount: (json['token_count'] as num?)?.toInt(),
modelName: json['model_name'] as String?, modelName: json['model_name'] as String?,
sequenceId: json['sequence_id'] as String, sequenceId: json['sequence_id'] as String,
sequence: sequence: json['sequence'] == null
json['sequence'] == null ? null
? null : SnThinkingSequence.fromJson(
: SnThinkingSequence.fromJson( json['sequence'] as Map<String, dynamic>,
json['sequence'] as Map<String, dynamic>, ),
),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) => Map<String, dynamic> _$SnThinkingThoughtToJson(_SnThinkingThought instance) =>
@@ -206,10 +197,9 @@ _ThoughtServicesResponse _$ThoughtServicesResponseFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => _ThoughtServicesResponse( ) => _ThoughtServicesResponse(
defaultService: json['default_service'] as String, defaultService: json['default_service'] as String,
services: services: (json['services'] as List<dynamic>)
(json['services'] as List<dynamic>) .map((e) => ThoughtService.fromJson(e as Map<String, dynamic>))
.map((e) => ThoughtService.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
); );
Map<String, dynamic> _$ThoughtServicesResponseToJson( Map<String, dynamic> _$ThoughtServicesResponseToJson(

View File

@@ -8,21 +8,18 @@ part of 'wallet.dart';
_SnWallet _$SnWalletFromJson(Map<String, dynamic> json) => _SnWallet( _SnWallet _$SnWalletFromJson(Map<String, dynamic> json) => _SnWallet(
id: json['id'] as String, id: json['id'] as String,
pockets: pockets: (json['pockets'] as List<dynamic>)
(json['pockets'] as List<dynamic>) .map((e) => SnWalletPocket.fromJson(e as Map<String, dynamic>))
.map((e) => SnWalletPocket.fromJson(e as Map<String, dynamic>)) .toList(),
.toList(),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletToJson(_SnWallet instance) => <String, dynamic>{ Map<String, dynamic> _$SnWalletToJson(_SnWallet instance) => <String, dynamic>{
@@ -77,10 +74,9 @@ _SnWalletPocket _$SnWalletPocketFromJson(Map<String, dynamic> json) =>
walletId: json['wallet_id'] as String, walletId: json['wallet_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletPocketToJson(_SnWalletPocket instance) => Map<String, dynamic> _$SnWalletPocketToJson(_SnWalletPocket instance) =>
@@ -102,21 +98,18 @@ _SnTransaction _$SnTransactionFromJson(Map<String, dynamic> json) =>
remarks: json['remarks'] as String?, remarks: json['remarks'] as String?,
type: (json['type'] as num).toInt(), type: (json['type'] as num).toInt(),
payerWalletId: json['payer_wallet_id'] as String?, payerWalletId: json['payer_wallet_id'] as String?,
payerWallet: payerWallet: json['payer_wallet'] == null
json['payer_wallet'] == null ? null
? null : SnWallet.fromJson(json['payer_wallet'] as Map<String, dynamic>),
: SnWallet.fromJson(json['payer_wallet'] as Map<String, dynamic>),
payeeWalletId: json['payee_wallet_id'] as String?, payeeWalletId: json['payee_wallet_id'] as String?,
payeeWallet: payeeWallet: json['payee_wallet'] == null
json['payee_wallet'] == null ? null
? null : SnWallet.fromJson(json['payee_wallet'] as Map<String, dynamic>),
: SnWallet.fromJson(json['payee_wallet'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnTransactionToJson(_SnTransaction instance) => Map<String, dynamic> _$SnTransactionToJson(_SnTransaction instance) =>
@@ -140,10 +133,9 @@ _SnWalletSubscription _$SnWalletSubscriptionFromJson(
) => _SnWalletSubscription( ) => _SnWalletSubscription(
id: json['id'] as String, id: json['id'] as String,
begunAt: DateTime.parse(json['begun_at'] as String), begunAt: DateTime.parse(json['begun_at'] as String),
endedAt: endedAt: json['ended_at'] == null
json['ended_at'] == null ? null
? null : DateTime.parse(json['ended_at'] as String),
: DateTime.parse(json['ended_at'] as String),
identifier: json['identifier'] as String, identifier: json['identifier'] as String,
isActive: json['is_active'] as bool? ?? true, isActive: json['is_active'] as bool? ?? true,
isFreeTrial: json['is_free_trial'] as bool? ?? false, isFreeTrial: json['is_free_trial'] as bool? ?? false,
@@ -153,23 +145,20 @@ _SnWalletSubscription _$SnWalletSubscriptionFromJson(
basePrice: (json['base_price'] as num?)?.toDouble(), basePrice: (json['base_price'] as num?)?.toDouble(),
couponId: json['coupon_id'] as String?, couponId: json['coupon_id'] as String?,
coupon: json['coupon'], coupon: json['coupon'],
renewalAt: renewalAt: json['renewal_at'] == null
json['renewal_at'] == null ? null
? null : DateTime.parse(json['renewal_at'] as String),
: DateTime.parse(json['renewal_at'] as String),
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>),
isAvailable: json['is_available'] as bool? ?? true, isAvailable: json['is_available'] as bool? ?? true,
finalPrice: (json['final_price'] as num?)?.toDouble(), finalPrice: (json['final_price'] as num?)?.toDouble(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletSubscriptionToJson( Map<String, dynamic> _$SnWalletSubscriptionToJson(
@@ -204,10 +193,9 @@ _SnWalletSubscriptionRef _$SnWalletSubscriptionRefFromJson(
isActive: json['is_active'] as bool, isActive: json['is_active'] as bool,
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
identifier: json['identifier'] as String, identifier: json['identifier'] as String,
); );
@@ -239,10 +227,9 @@ _SnWalletOrder _$SnWalletOrderFromJson(Map<String, dynamic> json) =>
issuerAppId: json['issuer_app_id'] as String?, issuerAppId: json['issuer_app_id'] as String?,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletOrderToJson(_SnWalletOrder instance) => Map<String, dynamic> _$SnWalletOrderToJson(_SnWalletOrder instance) =>
@@ -269,43 +256,36 @@ _SnWalletGift _$SnWalletGiftFromJson(Map<String, dynamic> json) =>
giftCode: json['gift_code'] as String, giftCode: json['gift_code'] as String,
subscriptionIdentifier: json['subscription_identifier'] as String, subscriptionIdentifier: json['subscription_identifier'] as String,
recipientId: json['recipient_id'] as String?, recipientId: json['recipient_id'] as String?,
recipient: recipient: json['recipient'] == null
json['recipient'] == null ? null
? null : SnAccount.fromJson(json['recipient'] as Map<String, dynamic>),
: SnAccount.fromJson(json['recipient'] as Map<String, dynamic>),
gifterId: json['gifter_id'] as String, gifterId: json['gifter_id'] as String,
gifter: gifter: json['gifter'] == null
json['gifter'] == null ? null
? null : SnAccount.fromJson(json['gifter'] as Map<String, dynamic>),
: SnAccount.fromJson(json['gifter'] as Map<String, dynamic>),
redeemerId: json['redeemer_id'] as String?, redeemerId: json['redeemer_id'] as String?,
redeemer: redeemer: json['redeemer'] == null
json['redeemer'] == null ? null
? null : SnAccount.fromJson(json['redeemer'] as Map<String, dynamic>),
: SnAccount.fromJson(json['redeemer'] as Map<String, dynamic>),
message: json['message'] as String?, message: json['message'] as String?,
status: (json['status'] as num).toInt(), status: (json['status'] as num).toInt(),
redeemedAt: redeemedAt: json['redeemed_at'] == null
json['redeemed_at'] == null ? null
? null : DateTime.parse(json['redeemed_at'] as String),
: DateTime.parse(json['redeemed_at'] as String), expiredAt: json['expired_at'] == null
expiredAt: ? null
json['expired_at'] == null : DateTime.parse(json['expired_at'] as String),
? null
: DateTime.parse(json['expired_at'] as String),
subscriptionId: json['subscription_id'] as String?, subscriptionId: json['subscription_id'] as String?,
subscription: subscription: json['subscription'] == null
json['subscription'] == null ? null
? null : SnWalletSubscription.fromJson(
: SnWalletSubscription.fromJson( json['subscription'] as Map<String, dynamic>,
json['subscription'] as Map<String, dynamic>, ),
),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletGiftToJson(_SnWalletGift instance) => Map<String, dynamic> _$SnWalletGiftToJson(_SnWalletGift instance) =>
@@ -330,35 +310,31 @@ Map<String, dynamic> _$SnWalletGiftToJson(_SnWalletGift instance) =>
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
}; };
_SnWalletFund _$SnWalletFundFromJson( _SnWalletFund _$SnWalletFundFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> json, _SnWalletFund(
) => _SnWalletFund( id: json['id'] as String,
id: json['id'] as String, currency: json['currency'] as String,
currency: json['currency'] as String, totalAmount: (json['total_amount'] as num).toDouble(),
totalAmount: (json['total_amount'] as num).toDouble(), remainingAmount: (json['remaining_amount'] as num).toDouble(),
remainingAmount: (json['remaining_amount'] as num).toDouble(), amountOfSplits: (json['amount_of_splits'] as num).toInt(),
amountOfSplits: (json['amount_of_splits'] as num).toInt(), splitType: (json['split_type'] as num).toInt(),
splitType: (json['split_type'] as num).toInt(), status: (json['status'] as num).toInt(),
status: (json['status'] as num).toInt(), message: json['message'] as String?,
message: json['message'] as String?, creatorAccountId: json['creator_account_id'] as String,
creatorAccountId: json['creator_account_id'] as String, creatorAccount: json['creator_account'] == null
creatorAccount:
json['creator_account'] == null
? null ? null
: SnAccount.fromJson(json['creator_account'] as Map<String, dynamic>), : SnAccount.fromJson(json['creator_account'] as Map<String, dynamic>),
expiredAt: DateTime.parse(json['expired_at'] as String), expiredAt: DateTime.parse(json['expired_at'] as String),
recipients: recipients: (json['recipients'] as List<dynamic>)
(json['recipients'] as List<dynamic>)
.map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>)) .map((e) => SnWalletFundRecipient.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
isOpen: json['is_open'] as bool, isOpen: json['is_open'] as bool,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null
? null ? null
: DateTime.parse(json['deleted_at'] as String), : DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) => Map<String, dynamic> _$SnWalletFundToJson(_SnWalletFund instance) =>
<String, dynamic>{ <String, dynamic>{
@@ -386,24 +362,19 @@ _SnWalletFundRecipient _$SnWalletFundRecipientFromJson(
id: json['id'] as String, id: json['id'] as String,
fundId: json['fund_id'] as String, fundId: json['fund_id'] as String,
recipientAccountId: json['recipient_account_id'] as String, recipientAccountId: json['recipient_account_id'] as String,
recipientAccount: recipientAccount: json['recipient_account'] == null
json['recipient_account'] == null ? null
? null : SnAccount.fromJson(json['recipient_account'] as Map<String, dynamic>),
: SnAccount.fromJson(
json['recipient_account'] as Map<String, dynamic>,
),
amount: (json['amount'] as num).toDouble(), amount: (json['amount'] as num).toDouble(),
isReceived: json['is_received'] as bool, isReceived: json['is_received'] as bool,
receivedAt: receivedAt: json['received_at'] == null
json['received_at'] == null ? null
? null : DateTime.parse(json['received_at'] as String),
: DateTime.parse(json['received_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWalletFundRecipientToJson( Map<String, dynamic> _$SnWalletFundRecipientToJson(
@@ -425,33 +396,29 @@ _SnLotteryTicket _$SnLotteryTicketFromJson(Map<String, dynamic> json) =>
_SnLotteryTicket( _SnLotteryTicket(
id: json['id'] as String, id: json['id'] as String,
accountId: json['account_id'] as String, accountId: json['account_id'] as String,
account: account: json['account'] == null
json['account'] == null ? null
? null : SnAccount.fromJson(json['account'] as Map<String, dynamic>),
: SnAccount.fromJson(json['account'] as Map<String, dynamic>), regionOneNumbers: (json['region_one_numbers'] as List<dynamic>)
regionOneNumbers: .map((e) => (e as num).toInt())
(json['region_one_numbers'] as List<dynamic>) .toList(),
.map((e) => (e as num).toInt())
.toList(),
regionTwoNumber: (json['region_two_number'] as num).toInt(), regionTwoNumber: (json['region_two_number'] as num).toInt(),
multiplier: (json['multiplier'] as num).toInt(), multiplier: (json['multiplier'] as num).toInt(),
drawStatus: (json['draw_status'] as num).toInt(), drawStatus: (json['draw_status'] as num).toInt(),
drawDate: drawDate: json['draw_date'] == null
json['draw_date'] == null ? null
? null : DateTime.parse(json['draw_date'] as String),
: DateTime.parse(json['draw_date'] as String),
matchedRegionOneNumbers: matchedRegionOneNumbers:
(json['matched_region_one_numbers'] as List<dynamic>?) (json['matched_region_one_numbers'] as List<dynamic>?)
?.map((e) => (e as num).toInt()) ?.map((e) => (e as num).toInt())
.toList(), .toList(),
matchedRegionTwoNumber: matchedRegionTwoNumber: (json['matched_region_two_number'] as num?)
(json['matched_region_two_number'] as num?)?.toInt(), ?.toInt(),
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnLotteryTicketToJson(_SnLotteryTicket instance) => Map<String, dynamic> _$SnLotteryTicketToJson(_SnLotteryTicket instance) =>
@@ -471,26 +438,24 @@ Map<String, dynamic> _$SnLotteryTicketToJson(_SnLotteryTicket instance) =>
'deleted_at': instance.deletedAt?.toIso8601String(), 'deleted_at': instance.deletedAt?.toIso8601String(),
}; };
_SnLotteryRecord _$SnLotteryRecordFromJson(Map<String, dynamic> json) => _SnLotteryRecord _$SnLotteryRecordFromJson(
_SnLotteryRecord( Map<String, dynamic> json,
id: json['id'] as String, ) => _SnLotteryRecord(
drawDate: DateTime.parse(json['draw_date'] as String), id: json['id'] as String,
winningRegionOneNumbers: drawDate: DateTime.parse(json['draw_date'] as String),
(json['winning_region_one_numbers'] as List<dynamic>) winningRegionOneNumbers: (json['winning_region_one_numbers'] as List<dynamic>)
.map((e) => (e as num).toInt()) .map((e) => (e as num).toInt())
.toList(), .toList(),
winningRegionTwoNumber: winningRegionTwoNumber: (json['winning_region_two_number'] as num).toInt(),
(json['winning_region_two_number'] as num).toInt(), totalTickets: (json['total_tickets'] as num).toInt(),
totalTickets: (json['total_tickets'] as num).toInt(), totalPrizesAwarded: (json['total_prizes_awarded'] as num).toInt(),
totalPrizesAwarded: (json['total_prizes_awarded'] as num).toInt(), totalPrizeAmount: (json['total_prize_amount'] as num).toDouble(),
totalPrizeAmount: (json['total_prize_amount'] as num).toDouble(), createdAt: DateTime.parse(json['created_at'] as String),
createdAt: DateTime.parse(json['created_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), deletedAt: json['deleted_at'] == null
deletedAt: ? null
json['deleted_at'] == null : DateTime.parse(json['deleted_at'] as String),
? null );
: DateTime.parse(json['deleted_at'] as String),
);
Map<String, dynamic> _$SnLotteryRecordToJson(_SnLotteryRecord instance) => Map<String, dynamic> _$SnLotteryRecordToJson(_SnLotteryRecord instance) =>
<String, dynamic>{ <String, dynamic>{

View File

@@ -17,14 +17,12 @@ _SnWebFeed _$SnWebFeedFromJson(Map<String, dynamic> json) => _SnWebFeed(
url: json['url'] as String, url: json['url'] as String,
title: json['title'] as String, title: json['title'] as String,
description: json['description'] as String?, description: json['description'] as String?,
preview: preview: json['preview'] == null
json['preview'] == null ? null
? null : SnScrappedLink.fromJson(json['preview'] as Map<String, dynamic>),
: SnScrappedLink.fromJson(json['preview'] as Map<String, dynamic>), config: json['config'] == null
config: ? const SnWebFeedConfig()
json['config'] == null : SnWebFeedConfig.fromJson(json['config'] as Map<String, dynamic>),
? const SnWebFeedConfig()
: SnWebFeedConfig.fromJson(json['config'] as Map<String, dynamic>),
publisherId: json['publisher_id'] as String, publisherId: json['publisher_id'] as String,
articles: articles:
(json['articles'] as List<dynamic>?) (json['articles'] as List<dynamic>?)
@@ -33,10 +31,9 @@ _SnWebFeed _$SnWebFeedFromJson(Map<String, dynamic> json) => _SnWebFeed(
const [], const [],
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWebFeedToJson(_SnWebFeed instance) => Map<String, dynamic> _$SnWebFeedToJson(_SnWebFeed instance) =>
@@ -61,28 +58,22 @@ _SnWebArticle _$SnWebArticleFromJson(Map<String, dynamic> json) =>
url: json['url'] as String, url: json['url'] as String,
author: json['author'] as String?, author: json['author'] as String?,
meta: json['meta'] as Map<String, dynamic>?, meta: json['meta'] as Map<String, dynamic>?,
preview: preview: json['preview'] == null
json['preview'] == null ? null
? null : SnScrappedLink.fromJson(json['preview'] as Map<String, dynamic>),
: SnScrappedLink.fromJson( feed: json['feed'] == null
json['preview'] as Map<String, dynamic>, ? null
), : SnWebFeed.fromJson(json['feed'] as Map<String, dynamic>),
feed:
json['feed'] == null
? null
: SnWebFeed.fromJson(json['feed'] as Map<String, dynamic>),
content: json['content'] as String?, content: json['content'] as String?,
publishedAt: publishedAt: json['published_at'] == null
json['published_at'] == null ? null
? null : DateTime.parse(json['published_at'] as String),
: DateTime.parse(json['published_at'] as String),
feedId: json['feed_id'] as String, feedId: json['feed_id'] as String,
createdAt: DateTime.parse(json['created_at'] as String), createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String), updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: deletedAt: json['deleted_at'] == null
json['deleted_at'] == null ? null
? null : DateTime.parse(json['deleted_at'] as String),
: DateTime.parse(json['deleted_at'] as String),
); );
Map<String, dynamic> _$SnWebArticleToJson(_SnWebArticle instance) => Map<String, dynamic> _$SnWebArticleToJson(_SnWebArticle instance) =>

View File

@@ -1 +0,0 @@

View File

@@ -14,7 +14,7 @@ Future<Map<String, dynamic>?> billingUsage(Ref ref) async {
return response.data; return response.data;
} }
final indexedCloudFileListProvider = AsyncNotifierProvider( final indexedCloudFileListProvider = AsyncNotifierProvider.autoDispose(
IndexedCloudFileListNotifier.new, IndexedCloudFileListNotifier.new,
); );
@@ -76,12 +76,12 @@ class IndexedCloudFileListNotifier extends AsyncNotifier<List<FileListItem>>
queryParameters: queryParameters, queryParameters: queryParameters,
); );
final List<String> folders = final List<String> folders = (response.data['folders'] as List)
(response.data['folders'] as List).map((e) => e as String).toList(); .map((e) => e as String)
final List<SnCloudFileIndex> files = .toList();
(response.data['files'] as List) final List<SnCloudFileIndex> files = (response.data['files'] as List)
.map((e) => SnCloudFileIndex.fromJson(e as Map<String, dynamic>)) .map((e) => SnCloudFileIndex.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
final List<FileListItem> items = [ final List<FileListItem> items = [
...folders.map((folderName) => FileListItem.folder(folderName)), ...folders.map((folderName) => FileListItem.folder(folderName)),
@@ -92,7 +92,7 @@ class IndexedCloudFileListNotifier extends AsyncNotifier<List<FileListItem>>
} }
} }
final unindexedFileListProvider = AsyncNotifierProvider( final unindexedFileListProvider = AsyncNotifierProvider.autoDispose(
UnindexedFileListNotifier.new, UnindexedFileListNotifier.new,
); );
@@ -165,13 +165,13 @@ class UnindexedFileListNotifier extends AsyncNotifier<List<FileListItem>>
totalCount = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0; totalCount = int.tryParse(response.headers.value('x-total') ?? '0') ?? 0;
final List<SnCloudFile> files = final List<SnCloudFile> files = (response.data as List)
(response.data as List) .map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>))
.map((e) => SnCloudFile.fromJson(e as Map<String, dynamic>)) .toList();
.toList();
final List<FileListItem> items = final List<FileListItem> items = files
files.map((file) => FileListItem.unindexedFile(file)).toList(); .map((file) => FileListItem.unindexedFile(file))
.toList();
return items; return items;
} }

View File

@@ -7,6 +7,7 @@ abstract class PaginationController<T> {
int get fetchedCount; int get fetchedCount;
bool get fetchedAll; bool get fetchedAll;
bool get isLoading;
FutureOr<List<T>> fetch(); FutureOr<List<T>> fetch();
@@ -32,11 +33,15 @@ mixin AsyncPaginationController<T> on AsyncNotifier<List<T>>
@override @override
bool get fetchedAll => totalCount != null && fetchedCount >= totalCount!; bool get fetchedAll => totalCount != null && fetchedCount >= totalCount!;
@override
bool isLoading = false;
@override @override
FutureOr<List<T>> build() async => fetch(); FutureOr<List<T>> build() async => fetch();
@override @override
Future<void> refresh() async { Future<void> refresh() async {
isLoading = true;
totalCount = null; totalCount = null;
state = AsyncData<List<T>>([]); state = AsyncData<List<T>>([]);
@@ -44,12 +49,14 @@ mixin AsyncPaginationController<T> on AsyncNotifier<List<T>>
return await fetch(); return await fetch();
}); });
state = newState; state = newState;
isLoading = false;
} }
@override @override
Future<void> fetchFurther() async { Future<void> fetchFurther() async {
if (fetchedAll) return; if (fetchedAll) return;
isLoading = true;
state = AsyncLoading<List<T>>(); state = AsyncLoading<List<T>>();
final newState = await AsyncValue.guard<List<T>>(() async { final newState = await AsyncValue.guard<List<T>>(() async {
@@ -58,6 +65,7 @@ mixin AsyncPaginationController<T> on AsyncNotifier<List<T>>
}); });
state = newState; state = newState;
isLoading = false;
} }
} }
@@ -67,6 +75,7 @@ mixin AsyncPaginationFilter<F, T> on AsyncPaginationController<T>
Future<void> applyFilter(F filter) async { Future<void> applyFilter(F filter) async {
if (currentFilter == filter) return; if (currentFilter == filter) return;
// Reset the data // Reset the data
isLoading = true;
totalCount = null; totalCount = null;
state = AsyncData<List<T>>([]); state = AsyncData<List<T>>([]);
currentFilter = filter; currentFilter = filter;
@@ -75,5 +84,6 @@ mixin AsyncPaginationFilter<F, T> on AsyncPaginationController<T>
return await fetch(); return await fetch();
}); });
state = newState; state = newState;
isLoading = false;
} }
} }

View File

@@ -0,0 +1,52 @@
// Post Categories Notifier
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart';
final postCategoriesProvider =
AsyncNotifierProvider.autoDispose<
PostCategoriesNotifier,
List<SnPostCategory>
>(PostCategoriesNotifier.new);
class PostCategoriesNotifier extends AsyncNotifier<List<SnPostCategory>>
with AsyncPaginationController<SnPostCategory> {
@override
Future<List<SnPostCategory>> fetch() async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts/categories',
queryParameters: {'offset': fetchedCount, 'take': 20, 'order': 'usage'},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final data = response.data as List;
return data.map((json) => SnPostCategory.fromJson(json)).toList();
}
}
// Post Tags Notifier
final postTagsProvider =
AsyncNotifierProvider.autoDispose<PostTagsNotifier, List<SnPostTag>>(
PostTagsNotifier.new,
);
class PostTagsNotifier extends AsyncNotifier<List<SnPostTag>>
with AsyncPaginationController<SnPostTag> {
@override
Future<List<SnPostTag>> fetch() async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts/tags',
queryParameters: {'offset': fetchedCount, 'take': 20, 'order': 'usage'},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final data = response.data as List;
return data.map((json) => SnPostTag.fromJson(json)).toList();
}
}

View File

@@ -0,0 +1,95 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart';
part 'post_list.freezed.dart';
@freezed
sealed class PostListQuery with _$PostListQuery {
const factory PostListQuery({
String? pubName,
String? realm,
int? type,
List<String>? categories,
List<String>? tags,
bool? pinned,
@Default(false) bool shuffle,
bool? includeReplies,
bool? mediaOnly,
String? queryTerm,
String? order,
int? periodStart,
int? periodEnd,
@Default(true) bool orderDesc,
}) = _PostListQuery;
}
@freezed
sealed class PostListQueryConfig with _$PostListQueryConfig {
const factory PostListQueryConfig({
String? id,
@Default(PostListQuery()) PostListQuery initialFilter,
}) = _PostListQueryConfig;
}
final postListProvider = AsyncNotifierProvider.autoDispose.family(
PostListNotifier.new,
);
class PostListNotifier extends AsyncNotifier<List<SnPost>>
with
AsyncPaginationController<SnPost>,
AsyncPaginationFilter<PostListQuery, SnPost> {
static const int pageSize = 20;
final String? id;
final PostListQueryConfig config;
PostListNotifier(this.config) : id = config.id;
@override
late PostListQuery currentFilter;
@override
Future<List<SnPost>> build() async {
currentFilter = config.initialFilter;
return fetch();
}
@override
Future<List<SnPost>> fetch() async {
final client = ref.read(apiClientProvider);
final queryParams = {
'offset': fetchedCount,
'take': pageSize,
'replies': currentFilter.includeReplies,
'orderDesc': currentFilter.orderDesc,
if (currentFilter.shuffle) 'shuffle': currentFilter.shuffle,
if (currentFilter.pubName != null) 'pub': currentFilter.pubName,
if (currentFilter.realm != null) 'realm': currentFilter.realm,
if (currentFilter.type != null) 'type': currentFilter.type,
if (currentFilter.tags != null) 'tags': currentFilter.tags,
if (currentFilter.categories != null)
'categories': currentFilter.categories,
if (currentFilter.pinned != null) 'pinned': currentFilter.pinned,
if (currentFilter.order != null) 'order': currentFilter.order,
if (currentFilter.periodStart != null)
'periodStart': currentFilter.periodStart,
if (currentFilter.periodEnd != null) 'periodEnd': currentFilter.periodEnd,
if (currentFilter.queryTerm != null) 'query': currentFilter.queryTerm,
if (currentFilter.mediaOnly != null) 'media': currentFilter.mediaOnly,
};
final response = await client.get(
'/sphere/posts',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
return response.data
.map((json) => SnPost.fromJson(json))
.cast<SnPost>()
.toList();
}
}

View File

@@ -317,4 +317,276 @@ as bool,
} }
/// @nodoc
mixin _$PostListQueryConfig {
String? get id; PostListQuery get initialFilter;
/// Create a copy of PostListQueryConfig
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$PostListQueryConfigCopyWith<PostListQueryConfig> get copyWith => _$PostListQueryConfigCopyWithImpl<PostListQueryConfig>(this as PostListQueryConfig, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is PostListQueryConfig&&(identical(other.id, id) || other.id == id)&&(identical(other.initialFilter, initialFilter) || other.initialFilter == initialFilter));
}
@override
int get hashCode => Object.hash(runtimeType,id,initialFilter);
@override
String toString() {
return 'PostListQueryConfig(id: $id, initialFilter: $initialFilter)';
}
}
/// @nodoc
abstract mixin class $PostListQueryConfigCopyWith<$Res> {
factory $PostListQueryConfigCopyWith(PostListQueryConfig value, $Res Function(PostListQueryConfig) _then) = _$PostListQueryConfigCopyWithImpl;
@useResult
$Res call({
String? id, PostListQuery initialFilter
});
$PostListQueryCopyWith<$Res> get initialFilter;
}
/// @nodoc
class _$PostListQueryConfigCopyWithImpl<$Res>
implements $PostListQueryConfigCopyWith<$Res> {
_$PostListQueryConfigCopyWithImpl(this._self, this._then);
final PostListQueryConfig _self;
final $Res Function(PostListQueryConfig) _then;
/// Create a copy of PostListQueryConfig
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = freezed,Object? initialFilter = null,}) {
return _then(_self.copyWith(
id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String?,initialFilter: null == initialFilter ? _self.initialFilter : initialFilter // ignore: cast_nullable_to_non_nullable
as PostListQuery,
));
}
/// Create a copy of PostListQueryConfig
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$PostListQueryCopyWith<$Res> get initialFilter {
return $PostListQueryCopyWith<$Res>(_self.initialFilter, (value) {
return _then(_self.copyWith(initialFilter: value));
});
}
}
/// Adds pattern-matching-related methods to [PostListQueryConfig].
extension PostListQueryConfigPatterns on PostListQueryConfig {
/// 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( _PostListQueryConfig value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _PostListQueryConfig() 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( _PostListQueryConfig value) $default,){
final _that = this;
switch (_that) {
case _PostListQueryConfig():
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( _PostListQueryConfig value)? $default,){
final _that = this;
switch (_that) {
case _PostListQueryConfig() 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, PostListQuery initialFilter)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _PostListQueryConfig() when $default != null:
return $default(_that.id,_that.initialFilter);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, PostListQuery initialFilter) $default,) {final _that = this;
switch (_that) {
case _PostListQueryConfig():
return $default(_that.id,_that.initialFilter);}
}
/// 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, PostListQuery initialFilter)? $default,) {final _that = this;
switch (_that) {
case _PostListQueryConfig() when $default != null:
return $default(_that.id,_that.initialFilter);case _:
return null;
}
}
}
/// @nodoc
class _PostListQueryConfig implements PostListQueryConfig {
const _PostListQueryConfig({this.id, this.initialFilter = const PostListQuery()});
@override final String? id;
@override@JsonKey() final PostListQuery initialFilter;
/// Create a copy of PostListQueryConfig
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$PostListQueryConfigCopyWith<_PostListQueryConfig> get copyWith => __$PostListQueryConfigCopyWithImpl<_PostListQueryConfig>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _PostListQueryConfig&&(identical(other.id, id) || other.id == id)&&(identical(other.initialFilter, initialFilter) || other.initialFilter == initialFilter));
}
@override
int get hashCode => Object.hash(runtimeType,id,initialFilter);
@override
String toString() {
return 'PostListQueryConfig(id: $id, initialFilter: $initialFilter)';
}
}
/// @nodoc
abstract mixin class _$PostListQueryConfigCopyWith<$Res> implements $PostListQueryConfigCopyWith<$Res> {
factory _$PostListQueryConfigCopyWith(_PostListQueryConfig value, $Res Function(_PostListQueryConfig) _then) = __$PostListQueryConfigCopyWithImpl;
@override @useResult
$Res call({
String? id, PostListQuery initialFilter
});
@override $PostListQueryCopyWith<$Res> get initialFilter;
}
/// @nodoc
class __$PostListQueryConfigCopyWithImpl<$Res>
implements _$PostListQueryConfigCopyWith<$Res> {
__$PostListQueryConfigCopyWithImpl(this._self, this._then);
final _PostListQueryConfig _self;
final $Res Function(_PostListQueryConfig) _then;
/// Create a copy of PostListQueryConfig
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = freezed,Object? initialFilter = null,}) {
return _then(_PostListQueryConfig(
id: freezed == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String?,initialFilter: null == initialFilter ? _self.initialFilter : initialFilter // ignore: cast_nullable_to_non_nullable
as PostListQuery,
));
}
/// Create a copy of PostListQueryConfig
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$PostListQueryCopyWith<$Res> get initialFilter {
return $PostListQueryCopyWith<$Res>(_self.initialFilter, (value) {
return _then(_self.copyWith(initialFilter: value));
});
}
}
// dart format on // dart format on

View File

@@ -1,13 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/activity.dart'; import 'package:island/models/activity.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
final activityListProvider = final activityListProvider = AsyncNotifierProvider.autoDispose(
AsyncNotifierProvider<ActivityListNotifier, List<SnTimelineEvent>>( ActivityListNotifier.new,
ActivityListNotifier.new, );
);
class ActivityListNotifier extends AsyncNotifier<List<SnTimelineEvent>> class ActivityListNotifier extends AsyncNotifier<List<SnTimelineEvent>>
with with
@@ -28,8 +26,6 @@ class ActivityListNotifier extends AsyncNotifier<List<SnTimelineEvent>>
if (cursor != null) 'cursor': cursor, if (cursor != null) 'cursor': cursor,
'take': pageSize, 'take': pageSize,
if (currentFilter != null) 'filter': currentFilter, if (currentFilter != null) 'filter': currentFilter,
if (kDebugMode)
'debugInclude': 'realms,publishers,articles,shuffledPosts',
}; };
final response = await client.get( final response = await client.get(
@@ -37,10 +33,9 @@ class ActivityListNotifier extends AsyncNotifier<List<SnTimelineEvent>>
queryParameters: queryParameters, queryParameters: queryParameters,
); );
final List<SnTimelineEvent> items = final List<SnTimelineEvent> items = (response.data as List)
(response.data as List) .map((e) => SnTimelineEvent.fromJson(e as Map<String, dynamic>))
.map((e) => SnTimelineEvent.fromJson(e as Map<String, dynamic>)) .toList();
.toList();
final hasMore = (items.firstOrNull?.type ?? 'empty') != 'empty'; final hasMore = (items.firstOrNull?.type ?? 'empty') != 'empty';

View File

@@ -5,16 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/webfeed.dart'; import 'package:island/models/webfeed.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
final webFeedListProvider = FutureProvider.family<List<SnWebFeed>, String>(( final webFeedListProvider = FutureProvider.autoDispose
ref, .family<List<SnWebFeed>, String>((ref, pubName) async {
pubName, final client = ref.watch(apiClientProvider);
) async { final response = await client.get('/sphere/publishers/$pubName/feeds');
final client = ref.watch(apiClientProvider); return (response.data as List)
final response = await client.get('/sphere/publishers/$pubName/feeds'); .map((json) => SnWebFeed.fromJson(json))
return (response.data as List) .toList();
.map((json) => SnWebFeed.fromJson(json)) });
.toList();
});
class WebFeedNotifier extends AsyncNotifier<SnWebFeed> { class WebFeedNotifier extends AsyncNotifier<SnWebFeed> {
final ({String pubName, String? feedId}) arg; final ({String pubName, String? feedId}) arg;
@@ -51,10 +49,9 @@ class WebFeedNotifier extends AsyncNotifier<SnWebFeed> {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final url = '/sphere/publishers/${feed.publisherId}/feeds'; final url = '/sphere/publishers/${feed.publisherId}/feeds';
final response = final response = feed.id.isEmpty
feed.id.isEmpty ? await client.post(url, data: feed.toJson())
? await client.post(url, data: feed.toJson()) : await client.patch('$url/${feed.id}', data: feed.toJson());
: await client.patch('$url/${feed.id}', data: feed.toJson());
state = AsyncValue.data(SnWebFeed.fromJson(response.data)); state = AsyncValue.data(SnWebFeed.fromJson(response.data));
} catch (error, stackTrace) { } catch (error, stackTrace) {

View File

@@ -105,10 +105,9 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'articleCompose', name: 'articleCompose',
path: '/articles/compose', path: '/articles/compose',
builder: builder: (context, state) => ArticleComposeScreen(
(context, state) => ArticleComposeScreen( initialState: state.extra as PostComposeInitialState?,
initialState: state.extra as PostComposeInitialState?, ),
),
), ),
GoRoute( GoRoute(
name: 'articleEdit', name: 'articleEdit',
@@ -190,12 +189,11 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'explore', name: 'explore',
path: '/', path: '/',
pageBuilder: pageBuilder: (context, state) => CustomTransitionPage(
(context, state) => CustomTransitionPage( key: const ValueKey('explore'),
key: const ValueKey('explore'), child: const ExploreScreen(),
child: const ExploreScreen(), transitionsBuilder: _tabPagesTransitionBuilder,
transitionsBuilder: _tabPagesTransitionBuilder, ),
),
), ),
GoRoute( GoRoute(
name: 'postSearch', name: 'postSearch',
@@ -220,11 +218,6 @@ final routerProvider = Provider<GoRouter>((ref) {
return PostCategoryDetailScreen(slug: slug, isCategory: true); return PostCategoryDetailScreen(slug: slug, isCategory: true);
}, },
), ),
GoRoute(
name: 'postTags',
path: '/posts/tags',
builder: (context, state) => const PostTagsListScreen(),
),
GoRoute( GoRoute(
name: 'postTagDetail', name: 'postTagDetail',
path: '/posts/tags/:slug', path: '/posts/tags/:slug',
@@ -260,12 +253,11 @@ final routerProvider = Provider<GoRouter>((ref) {
// Chat tab // Chat tab
ShellRoute( ShellRoute(
pageBuilder: pageBuilder: (context, state, child) => CustomTransitionPage(
(context, state, child) => CustomTransitionPage( key: const ValueKey('chat'),
key: const ValueKey('chat'), child: ChatShellScreen(child: child),
child: ChatShellScreen(child: child), transitionsBuilder: _tabPagesTransitionBuilder,
transitionsBuilder: _tabPagesTransitionBuilder, ),
),
routes: [ routes: [
GoRoute( GoRoute(
name: 'chatList', name: 'chatList',
@@ -303,12 +295,11 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'realmList', name: 'realmList',
path: '/realms', path: '/realms',
pageBuilder: pageBuilder: (context, state) => CustomTransitionPage(
(context, state) => CustomTransitionPage( key: const ValueKey('realms'),
key: const ValueKey('realms'), child: const RealmListScreen(),
child: const RealmListScreen(), transitionsBuilder: _tabPagesTransitionBuilder,
transitionsBuilder: _tabPagesTransitionBuilder, ),
),
routes: [ routes: [
GoRoute( GoRoute(
name: 'realmNew', name: 'realmNew',
@@ -336,12 +327,11 @@ final routerProvider = Provider<GoRouter>((ref) {
// Account tab // Account tab
ShellRoute( ShellRoute(
pageBuilder: pageBuilder: (context, state, child) => CustomTransitionPage(
(context, state, child) => CustomTransitionPage( key: const ValueKey('account'),
key: const ValueKey('account'), child: AccountShellScreen(child: child),
child: AccountShellScreen(child: child), transitionsBuilder: _tabPagesTransitionBuilder,
transitionsBuilder: _tabPagesTransitionBuilder, ),
),
routes: [ routes: [
GoRoute( GoRoute(
name: 'account', name: 'account',
@@ -352,8 +342,8 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'stickerMarketplace', name: 'stickerMarketplace',
path: '/stickers', path: '/stickers',
builder: builder: (context, state) =>
(context, state) => const MarketplaceStickersScreen(), const MarketplaceStickersScreen(),
routes: [ routes: [
GoRoute( GoRoute(
name: 'stickerPackDetail', name: 'stickerPackDetail',
@@ -368,8 +358,8 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'webFeedMarketplace', name: 'webFeedMarketplace',
path: '/feeds', path: '/feeds',
builder: builder: (context, state) =>
(context, state) => const MarketplaceWebFeedsScreen(), const MarketplaceWebFeedsScreen(),
routes: [ routes: [
GoRoute( GoRoute(
name: 'webFeedDetail', name: 'webFeedDetail',
@@ -516,29 +506,25 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'developerHub', name: 'developerHub',
path: '/developers', path: '/developers',
builder: builder: (context, state) => DeveloperHubScreen(
(context, state) => DeveloperHubScreen( initialPublisherName: state.uri.queryParameters['publisher'],
initialPublisherName: initialProjectId: state.uri.queryParameters['project'],
state.uri.queryParameters['publisher'], ),
initialProjectId: state.uri.queryParameters['project'],
),
routes: [ routes: [
GoRoute( GoRoute(
name: 'developerProjectNew', name: 'developerProjectNew',
path: ':name/projects/new', path: ':name/projects/new',
builder: builder: (context, state) => NewProjectScreen(
(context, state) => NewProjectScreen( publisherName: state.pathParameters['name']!,
publisherName: state.pathParameters['name']!, ),
),
), ),
GoRoute( GoRoute(
name: 'developerProjectEdit', name: 'developerProjectEdit',
path: ':name/projects/:id/edit', path: ':name/projects/:id/edit',
builder: builder: (context, state) => EditProjectScreen(
(context, state) => EditProjectScreen( publisherName: state.pathParameters['name']!,
publisherName: state.pathParameters['name']!, id: state.pathParameters['id']!,
id: state.pathParameters['id']!, ),
),
), ),
GoRoute( GoRoute(
name: 'developerProjectDetail', name: 'developerProjectDetail',
@@ -558,22 +544,20 @@ final routerProvider = Provider<GoRouter>((ref) {
GoRoute( GoRoute(
name: 'developerAppDetail', name: 'developerAppDetail',
path: 'apps/:appId', path: 'apps/:appId',
builder: builder: (context, state) => AppDetailScreen(
(context, state) => AppDetailScreen( publisherName: state.pathParameters['name']!,
publisherName: state.pathParameters['name']!, projectId: state.pathParameters['projectId']!,
projectId: state.pathParameters['projectId']!, appId: state.pathParameters['appId']!,
appId: state.pathParameters['appId']!, ),
),
), ),
GoRoute( GoRoute(
name: 'developerBotDetail', name: 'developerBotDetail',
path: 'bots/:botId', path: 'bots/:botId',
builder: builder: (context, state) => BotDetailScreen(
(context, state) => BotDetailScreen( publisherName: state.pathParameters['name']!,
publisherName: state.pathParameters['name']!, projectId: state.pathParameters['projectId']!,
projectId: state.pathParameters['projectId']!, botId: state.pathParameters['botId']!,
botId: state.pathParameters['botId']!, ),
),
), ),
], ],
), ),

View File

@@ -23,7 +23,7 @@ Future<double> socialCredits(Ref ref) async {
return response.data?.toDouble() ?? 0.0; return response.data?.toDouble() ?? 0.0;
} }
final socialCreditHistoryNotifierProvider = AsyncNotifierProvider( final socialCreditHistoryNotifierProvider = AsyncNotifierProvider.autoDispose(
SocialCreditHistoryNotifier.new, SocialCreditHistoryNotifier.new,
); );
@@ -45,11 +45,10 @@ class SocialCreditHistoryNotifier
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final records = final records = response.data
response.data .map((json) => SnSocialCreditRecord.fromJson(json))
.map((json) => SnSocialCreditRecord.fromJson(json)) .cast<SnSocialCreditRecord>()
.cast<SnSocialCreditRecord>() .toList();
.toList();
return records; return records;
} }
@@ -68,39 +67,36 @@ class SocialCreditsTab extends HookConsumerWidget {
margin: const EdgeInsets.only(left: 16, right: 16, top: 8), margin: const EdgeInsets.only(left: 16, right: 16, top: 8),
child: socialCredits child: socialCredits
.when( .when(
data: data: (credits) => Stack(
(credits) => Stack( children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Column( Text(
crossAxisAlignment: CrossAxisAlignment.start, credits < 100
children: [ ? 'socialCreditsLevelPoor'.tr()
Text( : credits < 150
credits < 100 ? 'socialCreditsLevelNormal'.tr()
? 'socialCreditsLevelPoor'.tr() : credits < 200
: credits < 150 ? 'socialCreditsLevelGood'.tr()
? 'socialCreditsLevelNormal'.tr() : 'socialCreditsLevelExcellent'.tr(),
: credits < 200 ).tr().bold().fontSize(20),
? 'socialCreditsLevelGood'.tr() Text('${credits.toStringAsFixed(2)} pts').fontSize(14),
: 'socialCreditsLevelExcellent'.tr(), const Gap(8),
).tr().bold().fontSize(20), LinearProgressIndicator(value: credits / 200),
Text(
'${credits.toStringAsFixed(2)} pts',
).fontSize(14),
const Gap(8),
LinearProgressIndicator(value: credits / 200),
],
),
Positioned(
right: 0,
top: 0,
child: IconButton(
onPressed: () {},
icon: const Icon(Symbols.info),
tooltip: 'socialCreditsDescription'.tr(),
),
),
], ],
), ),
Positioned(
right: 0,
top: 0,
child: IconButton(
onPressed: () {},
icon: const Icon(Symbols.info),
tooltip: 'socialCreditsDescription'.tr(),
),
),
],
),
error: (_, _) => Text('Error loading credits'), error: (_, _) => Text('Error loading credits'),
loading: () => const LinearProgressIndicator(), loading: () => const LinearProgressIndicator(),
) )
@@ -119,15 +115,14 @@ class SocialCreditsTab extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text( title: Text(
record.reason, record.reason,
style: style: isExpired
isExpired ? TextStyle(
? TextStyle( decoration: TextDecoration.lineThrough,
decoration: TextDecoration.lineThrough, color: Theme.of(
color: Theme.of( context,
context, ).colorScheme.onSurface.withOpacity(0.8),
).colorScheme.onSurface.withOpacity(0.8), )
) : null,
: null,
), ),
subtitle: Row( subtitle: Row(
spacing: 4, spacing: 4,

View File

@@ -14,7 +14,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
final levelingHistoryNotifierProvider = AsyncNotifierProvider( final levelingHistoryNotifierProvider = AsyncNotifierProvider.autoDispose(
LevelingHistoryNotifier.new, LevelingHistoryNotifier.new,
); );
@@ -35,11 +35,10 @@ class LevelingHistoryNotifier extends AsyncNotifier<List<SnExperienceRecord>>
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final List<SnExperienceRecord> records = final List<SnExperienceRecord> records = response.data
response.data .map((json) => SnExperienceRecord.fromJson(json))
.map((json) => SnExperienceRecord.fromJson(json)) .cast<SnExperienceRecord>()
.cast<SnExperienceRecord>() .toList();
.toList();
return records; return records;
} }
@@ -162,8 +161,9 @@ class LevelingScreen extends HookConsumerWidget {
stopIndicatorRadius: 0, stopIndicatorRadius: 0,
trackGap: 0, trackGap: 0,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.surfaceContainerHigh, context,
).colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
), ),
], ],
@@ -186,38 +186,35 @@ class LevelingScreen extends HookConsumerWidget {
notifier: levelingHistoryNotifierProvider.notifier, notifier: levelingHistoryNotifierProvider.notifier,
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
itemBuilder: itemBuilder: (context, idx, record) => ListTile(
(context, idx, record) => ListTile( title: Column(
title: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Text(record.reason),
Text(record.reason), Row(
Row( spacing: 4,
spacing: 4,
children: [
Text(
record.createdAt.formatRelative(context),
).fontSize(13),
Text('·').fontSize(13).bold(),
Text(record.createdAt.formatSystem()).fontSize(13),
],
).opacity(0.8),
],
),
subtitle: Row(
spacing: 8,
children: [ children: [
Text( Text(
'${record.delta > 0 ? '+' : ''}${record.delta} EXP', record.createdAt.formatRelative(context),
), ).fontSize(13),
if (record.bonusMultiplier != 1.0) Text('·').fontSize(13).bold(),
Text('x${record.bonusMultiplier}'), Text(record.createdAt.formatSystem()).fontSize(13),
], ],
), ).opacity(0.8),
minTileHeight: 56, ],
contentPadding: EdgeInsets.symmetric(horizontal: 4), ),
), subtitle: Row(
spacing: 8,
children: [
Text('${record.delta > 0 ? '+' : ''}${record.delta} EXP'),
if (record.bonusMultiplier != 1.0)
Text('x${record.bonusMultiplier}'),
],
),
minTileHeight: 56,
contentPadding: EdgeInsets.symmetric(horizontal: 4),
),
), ),
SliverGap(20), SliverGap(20),
@@ -249,11 +246,10 @@ class LevelStairsPainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final paint = final paint = Paint()
Paint() ..color = surfaceColor.withOpacity(0.2)
..color = surfaceColor.withOpacity(0.2) ..strokeWidth = 1.5
..strokeWidth = 1.5 ..style = PaintingStyle.stroke;
..style = PaintingStyle.stroke;
// Draw connecting lines between stairs // Draw connecting lines between stairs
for (int i = 0; i < totalLevels - 1; i++) { for (int i = 0; i < totalLevels - 1; i++) {

View File

@@ -29,7 +29,7 @@ Future<List<SnRelationship>> sentFriendRequest(Ref ref) async {
.toList(); .toList();
} }
final relationshipListNotifierProvider = AsyncNotifierProvider( final relationshipListNotifierProvider = AsyncNotifierProvider.autoDispose(
RelationshipListNotifier.new, RelationshipListNotifier.new,
); );
@@ -45,11 +45,10 @@ class RelationshipListNotifier extends AsyncNotifier<List<SnRelationship>>
queryParameters: {'offset': fetchedCount.toString(), 'take': take}, queryParameters: {'offset': fetchedCount.toString(), 'take': take},
); );
final List<SnRelationship> items = final List<SnRelationship> items = (response.data as List)
(response.data as List) .map((e) => SnRelationship.fromJson(e as Map<String, dynamic>))
.map((e) => SnRelationship.fromJson(e as Map<String, dynamic>)) .cast<SnRelationship>()
.cast<SnRelationship>() .toList();
.toList();
totalCount = int.tryParse(response.headers['x-total']?.first ?? '') ?? 0; totalCount = int.tryParse(response.headers['x-total']?.first ?? '') ?? 0;
@@ -83,8 +82,9 @@ class RelationshipListTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final account = final account = showRelatedAccount
showRelatedAccount ? relationship.related : relationship.account; ? relationship.related
: relationship.account;
final isPending = final isPending =
relationship.status == 0 && relationship.relatedId == currentUserId; relationship.status == 0 && relationship.relatedId == currentUserId;
final isWaiting = final isWaiting =
@@ -138,64 +138,56 @@ class RelationshipListTile extends StatelessWidget {
], ],
), ),
subtitle: Text('@${account.name}'), subtitle: Text('@${account.name}'),
trailing: trailing: showActions
showActions ? Row(
? Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (isPending && onAccept != null)
if (isPending && onAccept != null) IconButton(
IconButton( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, onPressed: submitting ? null : onAccept,
onPressed: submitting ? null : onAccept, icon: const Icon(Symbols.check),
icon: const Icon(Symbols.check), ),
), if (isPending && onDecline != null)
if (isPending && onDecline != null) IconButton(
IconButton( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, onPressed: submitting ? null : onDecline,
onPressed: submitting ? null : onDecline, icon: const Icon(Symbols.close),
icon: const Icon(Symbols.close), ),
), if (isWaiting && onCancel != null)
if (isWaiting && onCancel != null) IconButton(
IconButton( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, onPressed: submitting ? null : onCancel,
onPressed: submitting ? null : onCancel, icon: const Icon(Symbols.close),
icon: const Icon(Symbols.close), ),
), if (isEstablished && onUpdateStatus != null)
if (isEstablished && onUpdateStatus != null) PopupMenuButton(
PopupMenuButton( padding: EdgeInsets.zero,
padding: EdgeInsets.zero, icon: const Icon(Symbols.more_vert),
icon: const Icon(Symbols.more_vert), itemBuilder: (context) => [
itemBuilder: if (relationship.status >= 100) // If friend
(context) => [ PopupMenuItem(
if (relationship.status >= 100) // If friend child: ListTile(
PopupMenuItem( leading: const Icon(Symbols.block),
child: ListTile( title: Text('blockUser').tr(),
leading: const Icon(Symbols.block), contentPadding: EdgeInsets.zero,
title: Text('blockUser').tr(), ),
contentPadding: EdgeInsets.zero, onTap: () => onUpdateStatus?.call(relationship, -100),
), )
onTap: else if (relationship.status <= -100) // If blocked
() => onUpdateStatus?.call( PopupMenuItem(
relationship, child: ListTile(
-100, leading: const Icon(Symbols.person_add),
), title: Text('unblockUser').tr(),
) contentPadding: EdgeInsets.zero,
else if (relationship.status <= -100) // If blocked ),
PopupMenuItem( onTap: () => onUpdateStatus?.call(relationship, 100),
child: ListTile( ),
leading: const Icon(Symbols.person_add), ],
title: Text('unblockUser').tr(), ),
contentPadding: EdgeInsets.zero, ],
), )
onTap: : null,
() =>
onUpdateStatus?.call(relationship, 100),
),
],
),
],
)
: null,
); );
} }
} }
@@ -299,6 +291,7 @@ class RelationshipScreen extends HookConsumerWidget {
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: PaginationList( child: PaginationList(
padding: EdgeInsets.zero,
provider: relationshipListNotifierProvider, provider: relationshipListNotifierProvider,
notifier: relationshipListNotifierProvider.notifier, notifier: relationshipListNotifierProvider.notifier,
itemBuilder: (context, index, relationship) { itemBuilder: (context, index, relationship) {
@@ -380,28 +373,26 @@ class _SentFriendRequestsSheet extends HookConsumerWidget {
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: requests.when( child: requests.when(
data: data: (items) => items.isEmpty
(items) => ? Center(
items.isEmpty child: Text(
? Center( 'friendSentRequestEmpty'.tr(),
child: Text( textAlign: TextAlign.center,
'friendSentRequestEmpty'.tr(), ),
textAlign: TextAlign.center, )
), : ListView.builder(
) shrinkWrap: true,
: ListView.builder( itemCount: items.length,
shrinkWrap: true, itemBuilder: (context, index) {
itemCount: items.length, final request = items[index];
itemBuilder: (context, index) { return RelationshipListTile(
final request = items[index]; relationship: request,
return RelationshipListTile( onCancel: () => cancelRequest(request),
relationship: request, currentUserId: user.value?.id,
onCancel: () => cancelRequest(request), showRelatedAccount: true,
currentUserId: user.value?.id, );
showRelatedAccount: true, },
); ),
},
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')), error: (error, stack) => Center(child: Text('Error: $error')),
), ),

View File

@@ -20,6 +20,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:island/pods/chat/chat_room.dart'; import 'package:island/pods/chat/chat_room.dart';
@@ -50,84 +51,124 @@ class ChatRoomListTile extends HookConsumerWidget {
if (validMembers.isNotEmpty) { if (validMembers.isNotEmpty) {
final userInfo = ref.watch(userInfoProvider); final userInfo = ref.watch(userInfoProvider);
if (userInfo.value != null) { if (userInfo.value != null) {
validMembers = validMembers = validMembers
validMembers .where((e) => e.accountId != userInfo.value!.id)
.where((e) => e.accountId != userInfo.value!.id) .toList();
.toList();
} }
} }
Widget buildSubtitle() { Widget buildSubtitle() {
if (subtitle != null) return subtitle!; if (subtitle != null) return subtitle!;
return summary.when( return AnimatedSwitcher(
data: (data) { duration: const Duration(milliseconds: 300),
if (data == null) { layoutBuilder: (currentChild, previousChildren) => Stack(
return isDirect && room.description == null alignment: Alignment.centerLeft,
? Text( children: [
validMembers.map((e) => '@${e.account.name}').join(', '), ...previousChildren,
maxLines: 1, if (currentChild != null) currentChild,
) ],
: Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1); ),
} child: summary.when(
data: (data) => Container(
return Column( key: const ValueKey('data'),
crossAxisAlignment: CrossAxisAlignment.stretch, child: data == null
children: [ ? isDirect && room.description == null
if (data.unreadCount > 0) ? Text(
Text( validMembers
'unreadMessages'.plural(data.unreadCount), .map((e) => '@${e.account.name}')
style: Theme.of(context).textTheme.bodySmall?.copyWith( .join(', '),
color: Theme.of(context).colorScheme.primary, maxLines: 1,
)
: Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (data.unreadCount > 0)
Text(
'unreadMessages'.plural(data.unreadCount),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
if (data.lastMessage == null)
Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
)
else
Row(
spacing: 4,
children: [
Badge(
label: Text(
data.lastMessage!.sender.account.nick,
),
textColor: Theme.of(
context,
).colorScheme.onPrimary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
),
Expanded(
child: Text(
(data.lastMessage!.content?.isNotEmpty ?? false)
? data.lastMessage!.content!
: 'messageNone'.tr(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
),
Align(
alignment: Alignment.centerRight,
child: Text(
RelativeTime(
context,
).format(data.lastMessage!.createdAt),
style: Theme.of(context).textTheme.bodySmall,
),
),
],
),
],
), ),
), ),
if (data.lastMessage == null) loading: () => Container(
Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1) key: const ValueKey('loading'),
else child: Builder(
Row( builder: (context) {
spacing: 4, final seed = DateTime.now().microsecondsSinceEpoch;
children: [ final len = 4 + (seed % 17); // 4..20 inclusive
Badge( const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
label: Text(data.lastMessage!.sender.account.nick), var s = seed;
textColor: Theme.of(context).colorScheme.onPrimary, final buffer = StringBuffer();
backgroundColor: Theme.of(context).colorScheme.primary, for (var i = 0; i < len; i++) {
), s = (s * 1103515245 + 12345) & 0x7fffffff;
Expanded( buffer.write(chars[s % chars.length]);
child: Text( }
(data.lastMessage!.content?.isNotEmpty ?? false) return Skeletonizer(
? data.lastMessage!.content! enabled: true,
: 'messageNone'.tr(), child: Text(buffer.toString()),
maxLines: 1, );
overflow: TextOverflow.ellipsis, },
style: Theme.of(context).textTheme.bodySmall, ),
), ),
), error: (_, _) => Container(
Align( key: const ValueKey('error'),
alignment: Alignment.centerRight, child: isDirect && room.description == null
child: Text( ? Text(
RelativeTime( validMembers.map((e) => '@${e.account.name}').join(', '),
context, maxLines: 1,
).format(data.lastMessage!.createdAt), )
style: Theme.of(context).textTheme.bodySmall, : Text(room.description ?? 'descriptionNone'.tr(), maxLines: 1),
), ),
), ),
],
),
],
);
},
loading: () => const SizedBox.shrink(),
error:
(_, _) =>
isDirect && room.description == null
? Text(
validMembers.map((e) => '@${e.account.name}').join(', '),
maxLines: 1,
)
: Text(
room.description ?? 'descriptionNone'.tr(),
maxLines: 1,
),
); );
} }
@@ -149,17 +190,15 @@ class ChatRoomListTile extends HookConsumerWidget {
loading: () => false, loading: () => false,
error: (_, _) => false, error: (_, _) => false,
), ),
child: child: (isDirect && room.picture?.id == null)
(isDirect && room.picture?.id == null) ? SplitAvatarWidget(
? SplitAvatarWidget( filesId: validMembers
filesId: .map((e) => e.account.profile.picture?.id)
validMembers .toList(),
.map((e) => e.account.profile.picture?.id) )
.toList(), : room.picture?.id == null
) ? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: room.picture?.id == null : ProfilePictureWidget(fileId: room.picture?.id),
? CircleAvatar(child: Text(room.name![0].toUpperCase()))
: ProfilePictureWidget(fileId: room.picture?.id),
), ),
title: Text(titleText), title: Text(titleText),
subtitle: buildSubtitle(), subtitle: buildSubtitle(),
@@ -199,74 +238,67 @@ class ChatListBodyWidget extends HookConsumerWidget {
builder: (context, ref, _) { builder: (context, ref, _) {
final summaryState = ref.watch(chatSummaryProvider); final summaryState = ref.watch(chatSummaryProvider);
return summaryState.maybeWhen( return summaryState.maybeWhen(
loading: loading: () => const LinearProgressIndicator(
() => const LinearProgressIndicator( minHeight: 2,
minHeight: 2, borderRadius: BorderRadius.zero,
borderRadius: BorderRadius.zero, ),
),
orElse: () => const SizedBox.shrink(), orElse: () => const SizedBox.shrink(),
); );
}, },
), ),
Expanded( Expanded(
child: chats.when( child: chats.when(
data: data: (items) => RefreshIndicator(
(items) => RefreshIndicator( onRefresh: () => Future.sync(() {
onRefresh: ref.invalidate(chatRoomJoinedProvider);
() => Future.sync(() { }),
ref.invalidate(chatRoomJoinedProvider); child: SuperListView.builder(
}), padding: EdgeInsets.only(bottom: 96),
child: SuperListView.builder( itemCount: items
padding: EdgeInsets.only(bottom: 96), .where(
itemCount: (item) =>
items selectedTab.value == 0 ||
.where( (selectedTab.value == 1 && item.type == 1) ||
(item) => (selectedTab.value == 2 && item.type != 1),
selectedTab.value == 0 || )
(selectedTab.value == 1 && item.type == 1) || .length,
(selectedTab.value == 2 && item.type != 1), itemBuilder: (context, index) {
) final filteredItems = items
.length, .where(
itemBuilder: (context, index) { (item) =>
final filteredItems = selectedTab.value == 0 ||
items (selectedTab.value == 1 && item.type == 1) ||
.where( (selectedTab.value == 2 && item.type != 1),
(item) => )
selectedTab.value == 0 || .toList();
(selectedTab.value == 1 && final item = filteredItems[index];
item.type == 1) || return ChatRoomListTile(
(selectedTab.value == 2 && item.type != 1), room: item,
) isDirect: item.type == 1,
.toList(); onTap: () {
final item = filteredItems[index]; if (isWideScreen(context)) {
return ChatRoomListTile( context.replaceNamed(
room: item, 'chatRoom',
isDirect: item.type == 1, pathParameters: {'id': item.id},
onTap: () { );
if (isWideScreen(context)) { } else {
context.replaceNamed( context.pushNamed(
'chatRoom', 'chatRoom',
pathParameters: {'id': item.id}, pathParameters: {'id': item.id},
); );
} else { }
context.pushNamed(
'chatRoom',
pathParameters: {'id': item.id},
);
}
},
);
}, },
), );
), },
),
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (error, stack) => ResponseErrorWidget(
(error, stack) => ResponseErrorWidget( error: error,
error: error, onRetry: () {
onRetry: () { ref.invalidate(chatRoomJoinedProvider);
ref.invalidate(chatRoomJoinedProvider); },
}, ),
),
), ),
), ),
], ],
@@ -552,53 +584,47 @@ class _ChatInvitesSheet extends HookConsumerWidget {
), ),
], ],
child: invites.when( child: invites.when(
data: data: (items) => items.isEmpty
(items) => ? Center(
items.isEmpty child: Text('invitesEmpty', textAlign: TextAlign.center).tr(),
? Center( )
child: : ListView.builder(
Text( shrinkWrap: true,
'invitesEmpty', itemCount: items.length,
textAlign: TextAlign.center, itemBuilder: (context, index) {
).tr(), final invite = items[index];
) return ChatRoomListTile(
: ListView.builder( room: invite.chatRoom!,
shrinkWrap: true, isDirect: invite.chatRoom!.type == 1,
itemCount: items.length, subtitle: Row(
itemBuilder: (context, index) { spacing: 6,
final invite = items[index]; children: [
return ChatRoomListTile( if (invite.chatRoom!.type == 1)
room: invite.chatRoom!, Badge(
isDirect: invite.chatRoom!.type == 1, label: const Text('directMessage').tr(),
subtitle: Row( backgroundColor: Theme.of(
spacing: 6, context,
children: [ ).colorScheme.primary,
if (invite.chatRoom!.type == 1) textColor: Theme.of(context).colorScheme.onPrimary,
Badge(
label: const Text('directMessage').tr(),
backgroundColor:
Theme.of(context).colorScheme.primary,
textColor:
Theme.of(context).colorScheme.onPrimary,
),
],
), ),
trailing: Row( ],
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
), ),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')), error: (error, stack) => Center(child: Text('Error: $error')),
), ),

View File

@@ -98,11 +98,10 @@ class PublisherMemberListNotifier extends AsyncNotifier<List<SnPublisherMember>>
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final members = final members = response.data
response.data .map((e) => SnPublisherMember.fromJson(e))
.map((e) => SnPublisherMember.fromJson(e)) .cast<SnPublisherMember>()
.cast<SnPublisherMember>() .toList();
.toList();
return members; return members;
} }
@@ -173,14 +172,12 @@ class PublisherSelector extends StatelessWidget {
iconStyleData: IconStyleData( iconStyleData: IconStyleData(
icon: Icon(Icons.arrow_drop_down), icon: Icon(Icons.arrow_drop_down),
iconSize: 19, iconSize: 19,
iconEnabledColor: iconEnabledColor: isWideScreen(context)
isWideScreen(context) ? null
? null : Theme.of(context).appBarTheme.foregroundColor!,
: Theme.of(context).appBarTheme.foregroundColor!, iconDisabledColor: isWideScreen(context)
iconDisabledColor: ? null
isWideScreen(context) : Theme.of(context).appBarTheme.foregroundColor!,
? null
: Theme.of(context).appBarTheme.foregroundColor!,
), ),
), ),
); );
@@ -204,16 +201,24 @@ class _PublisherUnselectedWidget extends HookConsumerWidget {
child: Column( child: Column(
children: [ children: [
if (!hasPublishers) ...[ if (!hasPublishers) ...[
const Icon( if (publishers.isLoading)
Symbols.info, Padding(
fill: 1, padding: const EdgeInsets.all(8),
size: 32, child: const CircularProgressIndicator(),
).padding(bottom: 6, top: 24), )
Text( else
'creatorHubUnselectedHint', ...([
textAlign: TextAlign.center, const Icon(
style: Theme.of(context).textTheme.bodyLarge, Symbols.info,
).tr(), fill: 1,
size: 32,
).padding(bottom: 6, top: 24),
Text(
'creatorHubUnselectedHint',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
).tr(),
]),
const Gap(24), const Gap(24),
], ],
if (hasPublishers) if (hasPublishers)
@@ -288,14 +293,14 @@ class CreatorHubScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) =>
(context) => EditPublisherScreen(name: currentPublisher.value!.name),
EditPublisherScreen(name: currentPublisher.value!.name),
).then((value) async { ).then((value) async {
if (value == null) return; if (value == null) return;
final data = await ref.refresh(publishersManagedProvider.future); final data = await ref.refresh(publishersManagedProvider.future);
currentPublisher.value = currentPublisher.value = data
data.where((e) => e.id == currentPublisher.value!.id).firstOrNull; .where((e) => e.id == currentPublisher.value!.id)
.firstOrNull;
}); });
} }
@@ -315,29 +320,26 @@ class CreatorHubScreen extends HookConsumerWidget {
} }
final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when( final List<DropdownMenuItem<SnPublisher>> publishersMenu = publishers.when(
data: data: (data) => data
(data) => .map(
data (item) => DropdownMenuItem<SnPublisher>(
.map( value: item,
(item) => DropdownMenuItem<SnPublisher>( child: ListTile(
value: item, minTileHeight: 48,
child: ListTile( leading: ProfilePictureWidget(
minTileHeight: 48, radius: 16,
leading: ProfilePictureWidget( fileId: item.picture?.id,
radius: 16, ),
fileId: item.picture?.id, title: Text(item.nick),
), subtitle: Text('@${item.name}'),
title: Text(item.nick), trailing: currentPublisher.value?.id == item.id
subtitle: Text('@${item.name}'), ? const Icon(Icons.check)
trailing: : null,
currentPublisher.value?.id == item.id contentPadding: EdgeInsets.symmetric(horizontal: 8),
? const Icon(Icons.check) ),
: null, ),
contentPadding: EdgeInsets.symmetric(horizontal: 8), )
), .toList(),
),
)
.toList(),
loading: () => [], loading: () => [],
error: (_, _) => [], error: (_, _) => [],
); );
@@ -443,10 +445,9 @@ class CreatorHubScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: builder: (context) => _PublisherMemberListSheet(
(context) => _PublisherMemberListSheet( publisherUname: currentPublisher.value!.name,
publisherUname: currentPublisher.value!.name, ),
),
); );
}, },
), ),
@@ -567,51 +568,49 @@ class CreatorHubScreen extends HookConsumerWidget {
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth), constraints: BoxConstraints(maxWidth: maxWidth),
child: publisherStats.when( child: publisherStats.when(
data: data: (stats) => SingleChildScrollView(
(stats) => SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 24),
padding: const EdgeInsets.symmetric(vertical: 24), child: currentPublisher.value == null
child: ? ConstrainedBox(
currentPublisher.value == null constraints: BoxConstraints(maxWidth: 640),
? ConstrainedBox( child: _PublisherUnselectedWidget(
constraints: BoxConstraints(maxWidth: 640), onPublisherSelected: (publisher) {
child: _PublisherUnselectedWidget( currentPublisher.value = publisher;
onPublisherSelected: (publisher) { },
currentPublisher.value = publisher; ),
}, ).center()
), : isWide
).center() ? Column(
: isWide spacing: 8,
? Column( children: [
spacing: 8, const SizedBox.shrink(),
children: [ PublisherSelector(
const SizedBox.shrink(), currentPublisher: currentPublisher.value,
PublisherSelector( publishersMenu: publishersMenu,
currentPublisher: currentPublisher.value, onChanged: (value) {
publishersMenu: publishersMenu, currentPublisher.value = value;
onChanged: (value) { },
currentPublisher.value = value; ),
}, if (stats != null)
), _PublisherStatsWidget(
if (stats != null) stats: stats,
_PublisherStatsWidget( heatmap: publisherHeatmap.value,
stats: stats, ).padding(horizontal: 12),
heatmap: publisherHeatmap.value, buildNavigationWidget(true),
).padding(horizontal: 12), ],
buildNavigationWidget(true), )
], : Column(
) spacing: 12,
: Column( children: [
spacing: 12, if (stats != null)
children: [ _PublisherStatsWidget(
if (stats != null) stats: stats,
_PublisherStatsWidget( heatmap: publisherHeatmap.value,
stats: stats, ).padding(horizontal: 16),
heatmap: publisherHeatmap.value, buildNavigationWidget(false),
).padding(horizontal: 16), ],
buildNavigationWidget(false), ),
], ),
),
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
), ),
@@ -876,11 +875,10 @@ class _PublisherMemberListSheet extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: builder: (context) => _PublisherMemberRoleSheet(
(context) => _PublisherMemberRoleSheet( publisherUname: publisherUname,
publisherUname: publisherUname, member: member,
member: member, ),
),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
memberNotifier.refresh(); memberNotifier.refresh();
@@ -991,23 +989,19 @@ class _PublisherMemberRoleSheet extends HookConsumerWidget {
onSelected: (int selection) { onSelected: (int selection) {
roleController.text = selection.toString(); roleController.text = selection.toString();
}, },
fieldViewBuilder: ( fieldViewBuilder:
context, (context, controller, focusNode, onFieldSubmitted) {
controller, return TextField(
focusNode, controller: controller,
onFieldSubmitted, focusNode: focusNode,
) { keyboardType: TextInputType.number,
return TextField( decoration: InputDecoration(
controller: controller, labelText: 'memberRole'.tr(),
focusNode: focusNode, helperText: 'memberRoleHint'.tr(),
keyboardType: TextInputType.number, ),
decoration: InputDecoration( onTapOutside: (event) => focusNode.unfocus(),
labelText: 'memberRole'.tr(), );
helperText: 'memberRoleHint'.tr(), },
),
onTapOutside: (event) => focusNode.unfocus(),
);
},
), ),
const Gap(16), const Gap(16),
FilledButton.icon( FilledButton.icon(
@@ -1085,57 +1079,49 @@ class _PublisherInviteSheet extends HookConsumerWidget {
), ),
], ],
child: invites.when( child: invites.when(
data: data: (items) => items.isEmpty
(items) => ? Center(
items.isEmpty child: Text('invitesEmpty', textAlign: TextAlign.center).tr(),
? Center( )
child: : ListView.builder(
Text( shrinkWrap: true,
'invitesEmpty', itemCount: items.length,
textAlign: TextAlign.center, itemBuilder: (context, index) {
).tr(), final invite = items[index];
) return ListTile(
: ListView.builder( leading: ProfilePictureWidget(
shrinkWrap: true, fileId: invite.publisher!.picture?.id,
itemCount: items.length, fallbackIcon: Symbols.group,
itemBuilder: (context, index) {
final invite = items[index];
return ListTile(
leading: ProfilePictureWidget(
fileId: invite.publisher!.picture?.id,
fallbackIcon: Symbols.group,
),
title: Text(invite.publisher!.nick),
subtitle:
Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
), ),
title: Text(invite.publisher!.nick),
subtitle: Text(
invite.role >= 100
? 'permissionOwner'
: invite.role >= 50
? 'permissionModerator'
: 'permissionMember',
).tr(),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Symbols.check),
onPressed: () => acceptInvite(invite),
),
IconButton(
icon: const Icon(Symbols.close),
onPressed: () => declineInvite(invite),
),
],
),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (error, _) => ResponseErrorWidget(
(error, _) => ResponseErrorWidget( error: error,
error: error, onRetry: () => ref.invalidate(publisherInvitesProvider),
onRetry: () => ref.invalidate(publisherInvitesProvider), ),
),
), ),
); );
} }

View File

@@ -44,11 +44,10 @@ class PollListNotifier extends AsyncNotifier<List<SnPollWithStats>>
queryParameters: queryParams, queryParameters: queryParams,
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final items = final items = response.data
response.data .map((json) => SnPollWithStats.fromJson(json))
.map((json) => SnPollWithStats.fromJson(json)) .cast<SnPollWithStats>()
.cast<SnPollWithStats>() .toList();
.toList();
return items; return items;
} }
@@ -91,6 +90,7 @@ class CreatorPollListScreen extends HookConsumerWidget {
body: ExtendedRefreshIndicator( body: ExtendedRefreshIndicator(
onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future), onRefresh: () => ref.refresh(pollListNotifierProvider(pubName).future),
child: PaginationList( child: PaginationList(
footerSkeletonMaxWidth: 640,
provider: pollListNotifierProvider(pubName), provider: pollListNotifierProvider(pubName),
notifier: pollListNotifierProvider(pubName).notifier, notifier: pollListNotifierProvider(pubName).notifier,
padding: const EdgeInsets.only(top: 12), padding: const EdgeInsets.only(top: 12),
@@ -119,10 +119,9 @@ class _CreatorPollItem extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final ended = pollWithStats.endedAt; final ended = pollWithStats.endedAt;
final endedText = final endedText = ended == null
ended == null ? 'No end'
? 'No end' : MaterialLocalizations.of(context).formatFullDate(ended);
: MaterialLocalizations.of(context).formatFullDate(ended);
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
@@ -152,78 +151,69 @@ class _CreatorPollItem extends HookConsumerWidget {
], ],
), ),
trailing: PopupMenuButton<String>( trailing: PopupMenuButton<String>(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.edit),
const Icon(Symbols.edit), const Gap(16),
const Gap(16), Text('edit').tr(),
Text('edit').tr(), ],
),
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(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('Delete Poll'),
content: Text('Are you sure you want to delete this poll?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('Delete'),
),
], ],
), ),
onTap: () async { );
final result = await showModalBottomSheet<SnPoll>( if (confirmed == true) {
context: context, try {
isScrollControlled: true, final client = ref.read(apiClientProvider);
isDismissible: false, await client.delete('/sphere/polls/${pollWithStats.id}');
builder: ref.invalidate(pollListNotifierProvider(pubName));
(context) => PollEditorScreen( showSnackBar('Poll deleted successfully');
initialPublisher: pubName, } catch (e) {
initialPollId: pollWithStats.id, showErrorAlert(e);
), }
); }
if (result != null && context.mounted) { },
ref.invalidate(pollListNotifierProvider(pubName)); ),
} ],
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('Delete Poll'),
content: Text(
'Are you sure you want to delete this poll?',
),
actions: [
TextButton(
onPressed:
() => Navigator.of(context).pop(false),
child: Text('Cancel'),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('Delete'),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/sphere/polls/${pollWithStats.id}',
);
ref.invalidate(pollListNotifierProvider(pubName));
showSnackBar('Poll deleted successfully');
} catch (e) {
showErrorAlert(e);
}
}
},
),
],
), ),
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(

View File

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
@@ -20,7 +21,7 @@ class CreatorPostListScreen extends HookConsumerWidget {
key: ValueKey(refreshKey.value), key: ValueKey(refreshKey.value),
slivers: [ slivers: [
SliverPostList( SliverPostList(
pubName: pubName, query: PostListQuery(pubName: pubName),
itemType: PostItemType.creator, itemType: PostItemType.creator,
maxWidth: 640, maxWidth: 640,
backgroundColor: Theme.of(context).colorScheme.surfaceContainer, backgroundColor: Theme.of(context).colorScheme.surfaceContainer,

View File

@@ -12,7 +12,6 @@ import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
final siteListNotifierProvider = AsyncNotifierProvider.family.autoDispose( final siteListNotifierProvider = AsyncNotifierProvider.family.autoDispose(
@@ -38,11 +37,10 @@ class SiteListNotifier extends AsyncNotifier<List<SnPublicationSite>>
queryParameters: queryParams, queryParameters: queryParams,
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final items = final items = response.data
response.data .map((json) => SnPublicationSite.fromJson(json))
.map((json) => SnPublicationSite.fromJson(json)) .cast<SnPublicationSite>()
.cast<SnPublicationSite>() .toList();
.toList();
return items; return items;
} }
@@ -70,23 +68,17 @@ class CreatorSiteListScreen extends HookConsumerWidget {
onPressed: () => _createSite(context), onPressed: () => _createSite(context),
child: Icon(Icons.add), child: Icon(Icons.add),
), ),
body: ExtendedRefreshIndicator( body: PaginationList(
onRefresh: () => ref.refresh(siteListNotifierProvider(pubName).future), footerSkeletonMaxWidth: 640,
child: CustomScrollView( provider: siteListNotifierProvider(pubName),
slivers: [ notifier: siteListNotifierProvider(pubName).notifier,
const SliverGap(8), padding: const EdgeInsets.only(top: 12),
PaginationList( itemBuilder: (context, index, site) {
provider: siteListNotifierProvider(pubName), return ConstrainedBox(
notifier: siteListNotifierProvider(pubName).notifier, constraints: BoxConstraints(maxWidth: 640),
itemBuilder: (context, index, site) { child: _CreatorSiteItem(site: site, pubName: pubName),
return ConstrainedBox( ).center();
constraints: BoxConstraints(maxWidth: 640), },
child: _CreatorSiteItem(site: site, pubName: pubName),
).center();
},
),
],
),
), ),
); );
} }
@@ -148,73 +140,53 @@ class _CreatorSiteItem extends HookConsumerWidget {
), ),
), ),
PopupMenuButton<String>( PopupMenuButton<String>(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.edit),
const Icon(Symbols.edit), const Gap(16),
const Gap(16), Text('edit').tr(),
Text('edit').tr(), ],
], ),
), onTap: () {
onTap: () { showModalBottomSheet(
showModalBottomSheet( context: context,
context: context, isScrollControlled: true,
isScrollControlled: true, builder: (context) =>
builder: SiteForm(pubName: pubName, siteSlug: site.slug),
(context) => SiteForm( );
pubName: pubName, },
siteSlug: site.slug, ),
), PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.delete, color: Colors.red),
const Gap(16),
Text('delete').tr().textColor(Colors.red),
],
),
onTap: () async {
final confirmed = await showConfirmAlert(
'publicationSiteDeleteConfirm'.tr(),
'deleteSite'.tr(),
isDanger: true,
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/zone/sites/$pubName/${site.slug}',
); );
}, ref.invalidate(siteListNotifierProvider(pubName));
), showSnackBar('siteDeletedSuccess'.tr());
PopupMenuItem( } catch (e) {
child: Row( showErrorAlert(e);
children: [ }
const Icon(Symbols.delete, color: Colors.red), }
const Gap(16), },
Text('delete').tr().textColor(Colors.red), ),
], ],
),
onTap: () async {
final confirmed = await showDialog<bool>(
context: context,
builder:
(context) => AlertDialog(
title: Text('deleteSite'.tr()),
content: Text('deleteSiteConfirm'.tr()),
actions: [
TextButton(
onPressed:
() =>
Navigator.of(context).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed:
() => Navigator.of(context).pop(true),
child: Text('delete'.tr()),
),
],
),
);
if (confirmed == true) {
try {
final client = ref.read(apiClientProvider);
await client.delete(
'/zone/sites/$pubName/${site.slug}',
);
ref.invalidate(siteListNotifierProvider(pubName));
showSnackBar('siteDeletedSuccess'.tr());
} catch (e) {
showErrorAlert(e);
}
}
},
),
],
), ),
], ],
), ),

View File

@@ -41,11 +41,10 @@ class StickersScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'createStickerPack'.tr(),
titleText: 'createStickerPack'.tr(), child: StickerPackForm(pubName: pubName),
child: StickerPackForm(pubName: pubName), ),
),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
ref.invalidate(stickerPacksProvider(pubName)); ref.invalidate(stickerPacksProvider(pubName));
@@ -54,24 +53,23 @@ class StickersScreen extends HookConsumerWidget {
}, },
child: const Icon(Symbols.add), child: const Icon(Symbols.add),
), ),
body: body: isWideScreen(context)
isWideScreen(context) ? Center(
? Center( child: ConstrainedBox(
child: ConstrainedBox( constraints: BoxConstraints(maxWidth: 640),
constraints: BoxConstraints(maxWidth: 640), child: Card(
child: Card( shape: RoundedRectangleBorder(
shape: RoundedRectangleBorder( borderRadius: const BorderRadius.only(
borderRadius: const BorderRadius.only( topLeft: Radius.circular(8),
topLeft: Radius.circular(8), topRight: Radius.circular(8),
topRight: Radius.circular(8),
),
), ),
margin: const EdgeInsets.only(top: 16),
child: content,
), ),
margin: const EdgeInsets.only(top: 16),
child: content,
), ),
) ),
: content, )
: content,
); );
} }
} }
@@ -83,6 +81,7 @@ class SliverStickerPacksList extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return PaginationList( return PaginationList(
padding: EdgeInsets.zero,
provider: stickerPacksProvider(pubName), provider: stickerPacksProvider(pubName),
notifier: stickerPacksProvider(pubName).notifier, notifier: stickerPacksProvider(pubName).notifier,
itemBuilder: (context, index, sticker) { itemBuilder: (context, index, sticker) {
@@ -97,40 +96,38 @@ class SliverStickerPacksList extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: sticker.name,
titleText: sticker.name, actions: [
actions: [ IconButton(
IconButton( icon: const Icon(Symbols.add_circle),
icon: const Icon(Symbols.add_circle), onPressed: () {
onPressed: () { final id = sticker.id;
final id = sticker.id; showModalBottomSheet(
showModalBottomSheet( context: context,
context: context, isScrollControlled: true,
isScrollControlled: true, builder: (context) => SheetScaffold(
builder: titleText: 'createSticker'.tr(),
(context) => SheetScaffold( child: StickerForm(packId: id),
titleText: 'createSticker'.tr(), ),
child: StickerForm(packId: id), ).then((value) {
), if (value != null) {
).then((value) { ref.invalidate(stickerPackContentProvider(id));
if (value != null) { }
ref.invalidate(stickerPackContentProvider(id)); });
} },
});
},
),
StickerPackActionMenu(
pubName: pubName,
packId: sticker.id,
iconShadow: Shadow(),
),
],
child: StickerPackDetailContent(
id: sticker.id,
pubName: pubName,
),
), ),
StickerPackActionMenu(
pubName: pubName,
packId: sticker.id,
iconShadow: Shadow(),
),
],
child: StickerPackDetailContent(
id: sticker.id,
pubName: pubName,
),
),
); );
}, },
); );
@@ -165,11 +162,10 @@ class StickerPacksNotifier extends AsyncNotifier<List<SnStickerPack>>
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final stickers = final stickers = response.data
response.data .map((e) => SnStickerPack.fromJson(e))
.map((e) => SnStickerPack.fromJson(e)) .cast<SnStickerPack>()
.cast<SnStickerPack>() .toList();
.toList();
return stickers; return stickers;
} catch (err) { } catch (err) {
@@ -262,10 +258,9 @@ class StickerPackForm extends HookConsumerWidget {
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.all(Radius.circular(8)), borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
child: child: (icon.value?.isEmpty ?? true)
(icon.value?.isEmpty ?? true) ? const SizedBox.shrink()
? const SizedBox.shrink() : CloudImageWidget(fileId: icon.value!),
: CloudImageWidget(fileId: icon.value!),
), ),
), ),
), ),
@@ -273,10 +268,9 @@ class StickerPackForm extends HookConsumerWidget {
onPressed: () { onPressed: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: builder: (context) => CloudFilePicker(
(context) => CloudFilePicker( allowedTypes: {UniversalFileType.image},
allowedTypes: {UniversalFileType.image}, ),
),
).then((value) { ).then((value) {
if (value == null) return; if (value == null) return;
icon.value = value[0].id; icon.value = value[0].id;
@@ -300,8 +294,8 @@ class StickerPackForm extends HookConsumerWidget {
} }
return null; return null;
}, },
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
TextFormField( TextFormField(
controller: descriptionController, controller: descriptionController,
@@ -314,8 +308,8 @@ class StickerPackForm extends HookConsumerWidget {
), ),
minLines: 3, minLines: 3,
maxLines: null, maxLines: null,
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
TextFormField( TextFormField(
controller: prefixController, controller: prefixController,
@@ -332,8 +326,8 @@ class StickerPackForm extends HookConsumerWidget {
} }
return null; return null;
}, },
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
], ],
), ),

View File

@@ -39,19 +39,18 @@ class AppDetailScreen extends HookConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.edit), icon: const Icon(Symbols.edit),
onPressed: onPressed: appData.value == null
appData.value == null ? null
? null : () {
: () { context.pushNamed(
context.pushNamed( 'developerAppEdit',
'developerAppEdit', pathParameters: {
pathParameters: { 'name': publisherName,
'name': publisherName, 'projectId': projectId,
'projectId': projectId, 'id': appId,
'id': appId, },
}, );
); },
},
), ),
const Gap(8), const Gap(8),
], ],
@@ -85,24 +84,34 @@ class AppDetailScreen extends HookConsumerWidget {
controller: tabController, controller: tabController,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
_AppOverview(app: app), Align(
AppSecretsScreen( alignment: Alignment.topCenter,
publisherName: publisherName, child: ConstrainedBox(
projectId: projectId, constraints: const BoxConstraints(maxWidth: 640),
appId: appId, child: _AppOverview(app: app),
),
),
Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: AppSecretsScreen(
publisherName: publisherName,
projectId: projectId,
appId: appId,
),
),
), ),
], ],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () => ref.invalidate(
onRetry: customAppProvider(publisherName, projectId, appId),
() => ref.invalidate( ),
customAppProvider(publisherName, projectId, appId), ),
),
),
), ),
); );
} }
@@ -115,6 +124,7 @@ class _AppOverview extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SingleChildScrollView( return SingleChildScrollView(
padding: EdgeInsets.zero,
child: Column( child: Column(
children: [ children: [
AspectRatio( AspectRatio(
@@ -125,13 +135,12 @@ class _AppOverview extends StatelessWidget {
children: [ children: [
Container( Container(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child: child: app.background != null
app.background != null ? CloudFileWidget(
? CloudFileWidget( item: app.background!,
item: app.background!, fit: BoxFit.cover,
fit: BoxFit.cover, )
) : const SizedBox.shrink(),
: const SizedBox.shrink(),
), ),
Positioned( Positioned(
left: 20, left: 20,

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/custom_app_secret.dart'; import 'package:island/models/custom_app_secret.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
@@ -53,37 +54,36 @@ class AppSecretsScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'newSecretGenerated'.tr(),
titleText: 'newSecretGenerated'.tr(), child: Padding(
child: Padding( padding: const EdgeInsets.all(20.0),
padding: const EdgeInsets.all(20.0), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Text('copySecretHint'.tr()),
Text('copySecretHint'.tr()), const SizedBox(height: 16),
const SizedBox(height: 16), Container(
Container( padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12), decoration: BoxDecoration(
decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer,
color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8), ),
), child: SelectableText(newSecret),
child: SelectableText(newSecret),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: newSecret));
},
icon: const Icon(Symbols.copy_all),
label: Text('copy'.tr()),
),
],
), ),
), const SizedBox(height: 20),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: newSecret));
},
icon: const Icon(Symbols.copy_all),
label: Text('copy'.tr()),
),
],
), ),
),
),
).whenComplete(() { ).whenComplete(() {
ref.invalidate( ref.invalidate(
customAppSecretsProvider(publisherName, projectId, appId), customAppSecretsProvider(publisherName, projectId, appId),
@@ -114,22 +114,38 @@ class AppSecretsScreen extends HookConsumerWidget {
controller: descriptionController, controller: descriptionController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'description'.tr(), labelText: 'description'.tr(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
), ),
autofocus: true, autofocus: true,
), ),
const SizedBox(height: 20), const Gap(16),
TextFormField( TextFormField(
controller: expiresInController, controller: expiresInController,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'expiresIn'.tr(), labelText: 'expiresIn'.tr(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12),
),
),
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
const SizedBox(height: 20), const Gap(16),
SwitchListTile( Card(
title: Text('isOidc'.tr()), margin: EdgeInsets.zero,
value: isOidc.value, child: SwitchListTile(
onChanged: (value) => isOidc.value = value, shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
title: Text('isOidc'.tr()),
value: isOidc.value,
onChanged: (value) => isOidc.value = value,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
FilledButton.icon( FilledButton.icon(
@@ -175,14 +191,9 @@ class AppSecretsScreen extends HookConsumerWidget {
return secrets.when( return secrets.when(
data: (data) { data: (data) {
return RefreshIndicator( return RefreshIndicator(
onRefresh: onRefresh: () => ref.refresh(
() => ref.refresh( customAppSecretsProvider(publisherName, projectId, appId).future,
customAppSecretsProvider( ),
publisherName,
projectId,
appId,
).future,
),
child: Column( child: Column(
children: [ children: [
ListTile( ListTile(
@@ -240,14 +251,12 @@ class AppSecretsScreen extends HookConsumerWidget {
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () => ref.invalidate(
onRetry: customAppSecretsProvider(publisherName, projectId, appId),
() => ref.invalidate( ),
customAppSecretsProvider(publisherName, projectId, appId), ),
),
),
); );
} }
} }

View File

@@ -76,15 +76,14 @@ class CustomAppsScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'createCustomApp'.tr(),
titleText: 'createCustomApp'.tr(), child: NewCustomAppScreen(
child: NewCustomAppScreen( publisherName: publisherName,
publisherName: publisherName, projectId: projectId,
projectId: projectId, isModal: true,
isModal: true, ),
), ),
),
); );
}, },
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
@@ -95,10 +94,8 @@ class CustomAppsScreen extends HookConsumerWidget {
); );
} }
return ExtendedRefreshIndicator( return ExtendedRefreshIndicator(
onRefresh: onRefresh: () =>
() => ref.refresh( ref.refresh(customAppsProvider(publisherName, projectId).future),
customAppsProvider(publisherName, projectId).future,
),
child: Column( child: Column(
children: [ children: [
const Gap(8), const Gap(8),
@@ -110,15 +107,14 @@ class CustomAppsScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'createCustomApp'.tr(),
titleText: 'createCustomApp'.tr(), child: NewCustomAppScreen(
child: NewCustomAppScreen( publisherName: publisherName,
publisherName: publisherName, projectId: projectId,
projectId: projectId, isModal: true,
isModal: true, ),
), ),
),
); );
}, },
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
@@ -146,31 +142,20 @@ class CustomAppsScreen extends HookConsumerWidget {
}, },
child: Column( child: Column(
children: [ children: [
SizedBox( if (app.background != null)
height: 150, AspectRatio(
child: Stack( aspectRatio: 16 / 7,
fit: StackFit.expand, child: CloudFileWidget(
children: [ item: app.background!,
if (app.background != null) fit: BoxFit.cover,
CloudFileWidget( ).clipRRect(topLeft: 8, topRight: 8),
item: app.background!,
fit: BoxFit.cover,
).clipRRect(topLeft: 8, topRight: 8),
if (app.picture != null)
Positioned(
left: 16,
bottom: 16,
child: ProfilePictureWidget(
fileId: app.picture!.id,
radius: 40,
fallbackIcon: Symbols.apps,
),
),
],
), ),
),
ListTile( ListTile(
title: Text(app.name), title: Text(app.name),
leading: ProfilePictureWidget(
fileId: app.picture?.id,
fallbackIcon: Symbols.apps,
),
subtitle: Text( subtitle: Text(
app.slug, app.slug,
style: GoogleFonts.robotoMono(fontSize: 12), style: GoogleFonts.robotoMono(fontSize: 12),
@@ -180,52 +165,48 @@ class CustomAppsScreen extends HookConsumerWidget {
right: 12, right: 12,
), ),
trailing: PopupMenuButton( trailing: PopupMenuButton(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( value: 'edit',
value: 'edit', child: Row(
child: Row( children: [
children: [ const Icon(Symbols.edit),
const Icon(Symbols.edit), const SizedBox(width: 12),
const SizedBox(width: 12), Text('edit').tr(),
Text('edit').tr(), ],
], ),
),
PopupMenuItem(
value: 'delete',
child: Row(
children: [
const Icon(
Symbols.delete,
color: Colors.red,
), ),
), const SizedBox(width: 12),
PopupMenuItem( Text(
value: 'delete', 'delete',
child: Row( style: TextStyle(color: Colors.red),
children: [ ).tr(),
const Icon( ],
Symbols.delete, ),
color: Colors.red, ),
), ],
const SizedBox(width: 12),
Text(
'delete',
style: TextStyle(
color: Colors.red,
),
).tr(),
],
),
),
],
onSelected: (value) { onSelected: (value) {
if (value == 'edit') { if (value == 'edit') {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'editCustomApp'.tr(),
titleText: 'editCustomApp'.tr(), child: EditAppScreen(
child: EditAppScreen( publisherName: publisherName,
publisherName: publisherName, projectId: projectId,
projectId: projectId, id: app.id,
id: app.id, isModal: true,
isModal: true, ),
), ),
),
); );
} else if (value == 'delete') { } else if (value == 'delete') {
showConfirmAlert( showConfirmAlert(
@@ -264,14 +245,11 @@ class CustomAppsScreen extends HookConsumerWidget {
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () =>
onRetry: ref.invalidate(customAppsProvider(publisherName, projectId)),
() => ref.invalidate( ),
customAppsProvider(publisherName, projectId),
),
),
); );
} }
} }

View File

@@ -36,19 +36,18 @@ class BotDetailScreen extends HookConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.edit), icon: const Icon(Symbols.edit),
onPressed: onPressed: botData.value == null
botData.value == null ? null
? null : () {
: () { context.pushNamed(
context.pushNamed( 'developerBotEdit',
'developerBotEdit', pathParameters: {
pathParameters: { 'name': publisherName,
'name': publisherName, 'projectId': projectId,
'projectId': projectId, 'id': botId,
'id': botId, },
}, );
); },
},
), ),
], ],
bottom: TabBar( bottom: TabBar(
@@ -84,24 +83,33 @@ class BotDetailScreen extends HookConsumerWidget {
controller: tabController, controller: tabController,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
children: [ children: [
_BotOverview(bot: bot), Align(
BotKeysScreen( alignment: Alignment.topCenter,
publisherName: publisherName, child: ConstrainedBox(
projectId: projectId, constraints: const BoxConstraints(maxWidth: 640),
botId: botId, child: _BotOverview(bot: bot),
),
),
Align(
alignment: Alignment.topCenter,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: BotKeysScreen(
publisherName: publisherName,
projectId: projectId,
botId: botId,
),
),
), ),
], ],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () =>
onRetry: ref.invalidate(botProvider(publisherName, projectId, botId)),
() => ref.invalidate( ),
botProvider(publisherName, projectId, botId),
),
),
), ),
); );
} }
@@ -124,13 +132,12 @@ class _BotOverview extends StatelessWidget {
children: [ children: [
Container( Container(
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child: child: bot.account.profile.background != null
bot.account.profile.background != null ? CloudFileWidget(
? CloudFileWidget( item: bot.account.profile.background!,
item: bot.account.profile.background!, fit: BoxFit.cover,
fit: BoxFit.cover, )
) : const SizedBox.shrink(),
: const SizedBox.shrink(),
), ),
Positioned( Positioned(
left: 20, left: 20,

View File

@@ -53,37 +53,36 @@ class BotKeysScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'newKeyGenerated'.tr(),
titleText: 'newKeyGenerated'.tr(), child: Padding(
child: Padding( padding: const EdgeInsets.all(20.0),
padding: const EdgeInsets.all(20.0), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Text('copyKeyHint'.tr()),
Text('copyKeyHint'.tr()), const SizedBox(height: 16),
const SizedBox(height: 16), Container(
Container( padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12), decoration: BoxDecoration(
decoration: BoxDecoration( color: Theme.of(context).colorScheme.surfaceContainer,
color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8), ),
), child: SelectableText(token),
child: SelectableText(token),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: token));
},
icon: const Icon(Symbols.copy_all),
label: Text('copy'.tr()),
),
],
), ),
), const SizedBox(height: 20),
FilledButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: token));
},
icon: const Icon(Symbols.copy_all),
label: Text('copy'.tr()),
),
],
), ),
),
),
).whenComplete(() { ).whenComplete(() {
ref.invalidate(botKeysProvider(publisherName, projectId, botId)); ref.invalidate(botKeysProvider(publisherName, projectId, botId));
}); });
@@ -94,45 +93,50 @@ class BotKeysScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( heightFactor: 0.7,
titleText: 'newBotKey'.tr(), titleText: 'newBotKey'.tr(),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20.0), padding: const EdgeInsets.all(20.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextFormField( TextFormField(
controller: keyNameController, controller: keyNameController,
decoration: InputDecoration(labelText: 'keyName'.tr()), decoration: InputDecoration(
autofocus: true, labelText: 'keyName'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
), ),
const SizedBox(height: 20), ),
FilledButton.icon( autofocus: true,
onPressed: () async {
if (keyNameController.text.isEmpty) return;
final keyName = keyNameController.text;
Navigator.pop(context); // Close the sheet
try {
final client = ref.read(apiClientProvider);
final resp = await client.post(
'/develop/developers/$publisherName/projects/$projectId/bots/$botId/keys',
data: {'label': keyName},
);
final newApiKey = SnAccountApiKey.fromJson(resp.data);
showNewKeySheet(newApiKey);
} catch (e) {
showErrorAlert(e.toString());
}
},
icon: const Icon(Symbols.add),
label: Text('create'.tr()),
),
],
), ),
), const SizedBox(height: 20),
FilledButton.icon(
onPressed: () async {
if (keyNameController.text.isEmpty) return;
final keyName = keyNameController.text;
Navigator.pop(context); // Close the sheet
try {
final client = ref.read(apiClientProvider);
final resp = await client.post(
'/develop/developers/$publisherName/projects/$projectId/bots/$botId/keys',
data: {'label': keyName},
);
final newApiKey = SnAccountApiKey.fromJson(resp.data);
showNewKeySheet(newApiKey);
} catch (e) {
showErrorAlert(e.toString());
}
},
icon: const Icon(Symbols.add),
label: Text('create'.tr()),
),
],
), ),
),
),
); );
} }
@@ -189,92 +193,79 @@ class BotKeysScreen extends HookConsumerWidget {
ListTile( ListTile(
leading: const Icon(Symbols.add), leading: const Icon(Symbols.add),
title: Text('newBotKey'.tr()), title: Text('newBotKey'.tr()),
trailing: const Icon(Symbols.chevron_right),
onTap: createKey, onTap: createKey,
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: child: data.isEmpty
data.isEmpty ? Center(child: Text('noBotKeys'.tr()))
? Center(child: Text('noBotKeys'.tr())) : RefreshIndicator(
: RefreshIndicator( onRefresh: () => ref.refresh(
onRefresh: botKeysProvider(publisherName, projectId, botId).future,
() => ref.refresh( ),
botKeysProvider( child: ListView.builder(
publisherName, padding: EdgeInsets.zero,
projectId, itemCount: data.length,
botId, itemBuilder: (context, index) {
).future, final apiKey = data[index];
return ListTile(
title: Text(apiKey.label),
subtitle: Text(apiKey.createdAt.formatSystem()),
contentPadding: EdgeInsets.only(
left: 16,
right: 12,
), ),
child: ListView.builder( trailing: PopupMenuButton(
padding: EdgeInsets.zero, itemBuilder: (context) => [
itemCount: data.length, PopupMenuItem(
itemBuilder: (context, index) { value: 'rotate',
final apiKey = data[index]; child: Row(
return ListTile( children: [
title: Text(apiKey.label), const Icon(Symbols.refresh),
subtitle: Text(apiKey.createdAt.formatSystem()), const Gap(12),
contentPadding: EdgeInsets.only( Text('rotateKey'.tr()),
left: 16, ],
right: 12, ),
), ),
trailing: PopupMenuButton( PopupMenuItem(
itemBuilder: value: 'revoke',
(context) => [ child: Row(
PopupMenuItem( children: [
value: 'rotate', const Icon(
child: Row( Symbols.delete,
children: [ color: Colors.red,
const Icon(Symbols.refresh),
const Gap(12),
Text('rotateKey'.tr()),
],
),
), ),
PopupMenuItem( const Gap(12),
value: 'revoke', Text(
child: Row( 'revoke'.tr(),
children: [ style: TextStyle(color: Colors.red),
const Icon(
Symbols.delete,
color: Colors.red,
),
const Gap(12),
Text(
'revoke'.tr(),
style: TextStyle(
color: Colors.red,
),
),
],
),
), ),
], ],
onSelected: (value) { ),
if (value == 'rotate') { ),
rotateKey(apiKey.id); ],
} else if (value == 'revoke') { onSelected: (value) {
revokeKey(apiKey.id); if (value == 'rotate') {
} rotateKey(apiKey.id);
}, } else if (value == 'revoke') {
), revokeKey(apiKey.id);
); }
}, },
), ),
);
},
), ),
),
), ),
], ],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () =>
onRetry: ref.invalidate(botKeysProvider(publisherName, projectId, botId)),
() => ref.invalidate( ),
botKeysProvider(publisherName, projectId, botId),
),
),
); );
} }
} }

View File

@@ -54,15 +54,14 @@ class BotsScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'createBot'.tr(),
titleText: 'createBot'.tr(), child: NewBotScreen(
child: NewBotScreen( publisherName: publisherName,
publisherName: publisherName, projectId: projectId,
projectId: projectId, isModal: true,
isModal: true, ),
), ),
),
); );
}, },
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
@@ -73,8 +72,8 @@ class BotsScreen extends HookConsumerWidget {
); );
} }
return ExtendedRefreshIndicator( return ExtendedRefreshIndicator(
onRefresh: onRefresh: () =>
() => ref.refresh(botsProvider(publisherName, projectId).future), ref.refresh(botsProvider(publisherName, projectId).future),
child: Column( child: Column(
children: [ children: [
const Gap(8), const Gap(8),
@@ -86,15 +85,14 @@ class BotsScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'createBot'.tr(),
titleText: 'createBot'.tr(), child: NewBotScreen(
child: NewBotScreen( publisherName: publisherName,
publisherName: publisherName, projectId: projectId,
projectId: projectId, isModal: true,
isModal: true, ),
), ),
),
); );
}, },
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
@@ -108,23 +106,30 @@ class BotsScreen extends HookConsumerWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
final bot = data[index]; final bot = data[index];
return Card( return Card(
child: ListTile( child: Column(
shape: const RoundedRectangleBorder( children: [
borderRadius: BorderRadius.all(Radius.circular(8.0)), if (bot.account.profile.background != null)
), AspectRatio(
leading: CircleAvatar( aspectRatio: 16 / 7,
child: child: CloudFileWidget(
bot.account.profile.picture != null item: bot.account.profile.background!,
? ProfilePictureWidget( fit: BoxFit.cover,
file: bot.account.profile.picture!, ).clipRRect(topLeft: 8, topRight: 8),
) ),
: const Icon(Symbols.smart_toy), ListTile(
), shape: const RoundedRectangleBorder(
title: Text(bot.account.nick), borderRadius: BorderRadius.all(
subtitle: Text(bot.account.name), Radius.circular(8.0),
trailing: PopupMenuButton( ),
itemBuilder: ),
(context) => [ leading: ProfilePictureWidget(
fallbackIcon: Symbols.smart_toy,
file: bot.account.profile.picture,
),
title: Text(bot.account.nick),
subtitle: Text(bot.account.name),
trailing: PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem( PopupMenuItem(
value: 'edit', value: 'edit',
child: Row( child: Row(
@@ -152,13 +157,12 @@ class BotsScreen extends HookConsumerWidget {
), ),
), ),
], ],
onSelected: (value) { onSelected: (value) {
if (value == 'edit') { if (value == 'edit') {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold(
titleText: 'editBot'.tr(), titleText: 'editBot'.tr(),
child: EditBotScreen( child: EditBotScreen(
publisherName: publisherName, publisherName: publisherName,
@@ -167,36 +171,40 @@ class BotsScreen extends HookConsumerWidget {
isModal: true, isModal: true,
), ),
), ),
);
} else if (value == 'delete') {
showConfirmAlert(
'deleteBotHint'.tr(),
'deleteBot'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
final client = ref.read(apiClientProvider);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/bots/${bot.id}',
);
ref.invalidate(
botsProvider(publisherName, projectId),
); );
} else if (value == 'delete') {
showConfirmAlert(
'deleteBotHint'.tr(),
'deleteBot'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
final client = ref.read(
apiClientProvider,
);
client.delete(
'/develop/developers/$publisherName/projects/$projectId/bots/${bot.id}',
);
ref.invalidate(
botsProvider(publisherName, projectId),
);
}
});
} }
}); },
} ),
}, onTap: () {
), context.pushNamed(
onTap: () { 'developerBotDetail',
context.pushNamed( pathParameters: {
'developerBotDetail', 'name': publisherName,
pathParameters: { 'projectId': projectId,
'name': publisherName, 'botId': bot.id,
'projectId': projectId, },
'botId': bot.id, );
}, },
); ),
}, ],
), ),
); );
}, },
@@ -207,12 +215,10 @@ class BotsScreen extends HookConsumerWidget {
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () => ref.invalidate(botsProvider(publisherName, projectId)),
onRetry: ),
() => ref.invalidate(botsProvider(publisherName, projectId)),
),
); );
} }
} }

View File

@@ -68,12 +68,11 @@ class DeveloperHubScreen extends HookConsumerWidget {
developers.value?.firstOrNull, developers.value?.firstOrNull,
); );
final projects = final projects = currentDeveloper.value?.publisher?.name != null
currentDeveloper.value?.publisher?.name != null ? ref.watch(
? ref.watch( devProjectsProvider(currentDeveloper.value!.publisher!.name),
devProjectsProvider(currentDeveloper.value!.publisher!.name), )
) : const AsyncValue<List<DevProject>>.data([]);
: const AsyncValue<List<DevProject>>.data([]);
final currentProject = useState<DevProject?>( final currentProject = useState<DevProject?>(
projects.value?.where((p) => p.id == initialProjectId).firstOrNull, projects.value?.where((p) => p.id == initialProjectId).firstOrNull,
@@ -126,14 +125,13 @@ class DeveloperHubScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'createProject'.tr(),
titleText: 'createProject'.tr(), child: ProjectForm(
child: ProjectForm( publisherName:
publisherName: currentDeveloper.value!.publisher!.name,
currentDeveloper.value!.publisher!.name, ),
), ),
),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
ref.invalidate( ref.invalidate(
@@ -211,108 +209,96 @@ class _MainContentSection extends HookConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: developerStats.when( child: developerStats.when(
data: data: (stats) => currentDeveloper == null
(stats) => ? ConstrainedBox(
currentDeveloper == null constraints: BoxConstraints(maxWidth: 640),
? ConstrainedBox( child: _DeveloperUnselectedWidget(
constraints: BoxConstraints(maxWidth: 640), onDeveloperSelected: onDeveloperSelected,
child: _DeveloperUnselectedWidget( ),
onDeveloperSelected: onDeveloperSelected, ).center()
: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Developer Stats
if (stats != null) ...[
Text(
'Overview',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
), ),
).center() const Gap(16),
: Padding( _DeveloperStatsWidget(stats: stats),
padding: const EdgeInsets.all(16), const Gap(24),
child: Column( ],
crossAxisAlignment: CrossAxisAlignment.start,
children: [ // Projects Section
// Developer Stats Row(
if (stats != null) ...[ children: [
Text( Text(
'Overview', 'Projects',
style: Theme.of( style: Theme.of(context).textTheme.titleLarge
context, ?.copyWith(
).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface, color: Theme.of(context).colorScheme.onSurface,
), ),
),
const Spacer(),
ElevatedButton.icon(
onPressed: onCreateProject,
icon: const Icon(Symbols.add),
label: const Text('Create Project'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
), ),
const Gap(16),
_DeveloperStatsWidget(stats: stats),
const Gap(24),
],
// Projects Section
Row(
children: [
Text(
'Projects',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(
color:
Theme.of(context).colorScheme.onSurface,
),
),
const Spacer(),
ElevatedButton.icon(
onPressed: onCreateProject,
icon: const Icon(Symbols.add),
label: const Text('Create Project'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF1A73E8),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
),
],
), ),
const Gap(16), ),
],
// Projects List
projects.value?.isNotEmpty ?? false
? Column(
children:
projects.value!
.map(
(project) => _ProjectListTile(
project: project,
publisherName:
currentDeveloper!
.publisher!
.name,
onProjectSelected:
onProjectSelected,
),
)
.toList(),
)
: Container(
padding: const EdgeInsets.all(48),
alignment: Alignment.center,
child: Text(
'No projects available',
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
),
),
],
),
), ),
const Gap(16),
// Projects List
projects.value?.isNotEmpty ?? false
? Column(
children: projects.value!
.map(
(project) => _ProjectListTile(
project: project,
publisherName:
currentDeveloper!.publisher!.name,
onProjectSelected: onProjectSelected,
),
)
.toList(),
)
: Container(
padding: const EdgeInsets.all(48),
alignment: Alignment.center,
child: Text(
'No projects available',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
fontSize: 16,
),
),
),
],
),
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (err, stack) => ResponseErrorWidget(
(err, stack) => ResponseErrorWidget( error: err,
error: err, onRetry: () {
onRetry: () { ref.invalidate(
ref.invalidate( developerStatsProvider(currentDeveloper?.publisher?.name),
developerStatsProvider(currentDeveloper?.publisher?.name), );
); },
}, ),
),
), ),
); );
} }
@@ -335,29 +321,26 @@ class DeveloperSelector extends HookConsumerWidget {
final developers = ref.watch(developersProvider); final developers = ref.watch(developersProvider);
final List<DropdownMenuItem<SnDeveloper>> developersMenu = developers.when( final List<DropdownMenuItem<SnDeveloper>> developersMenu = developers.when(
data: data: (data) => data
(data) => .map(
data (item) => DropdownMenuItem<SnDeveloper>(
.map( value: item,
(item) => DropdownMenuItem<SnDeveloper>( child: ListTile(
value: item, minTileHeight: 48,
child: ListTile( leading: ProfilePictureWidget(
minTileHeight: 48, radius: 16,
leading: ProfilePictureWidget( fileId: item.publisher?.picture?.id,
radius: 16, ),
fileId: item.publisher?.picture?.id, title: Text(item.publisher!.nick),
), subtitle: Text('@${item.publisher!.name}'),
title: Text(item.publisher!.nick), trailing: currentDeveloper?.id == item.id
subtitle: Text('@${item.publisher!.name}'), ? const Icon(Icons.check)
trailing: : null,
currentDeveloper?.id == item.id contentPadding: EdgeInsets.symmetric(horizontal: 8),
? const Icon(Icons.check) ),
: null, ),
contentPadding: EdgeInsets.symmetric(horizontal: 8), )
), .toList(),
),
)
.toList(),
loading: () => [], loading: () => [],
error: (_, _) => [], error: (_, _) => [],
); );
@@ -446,38 +429,36 @@ class ProjectSelector extends HookConsumerWidget {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final List<DropdownMenuItem<DevProject>> projectsMenu = final List<DropdownMenuItem<DevProject>> projectsMenu = projects.value!
projects.value! .map(
.map( (item) => DropdownMenuItem<DevProject>(
(item) => DropdownMenuItem<DevProject>( value: item,
value: item, child: ListTile(
child: ListTile( minTileHeight: 48,
minTileHeight: 48, leading: CircleAvatar(
leading: CircleAvatar( radius: 16,
radius: 16, backgroundColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.primary, child: Text(
child: Text( item.name.isNotEmpty ? item.name[0].toUpperCase() : '?',
item.name.isNotEmpty ? item.name[0].toUpperCase() : '?', style: TextStyle(
style: TextStyle( color: Theme.of(context).colorScheme.onPrimary,
color: Theme.of(context).colorScheme.onPrimary,
),
),
), ),
title: Text(item.name),
subtitle: Text(
item.description ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing:
currentProject?.id == item.id
? const Icon(Icons.check)
: null,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
), ),
), ),
) title: Text(item.name),
.toList(); subtitle: Text(
item.description ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: currentProject?.id == item.id
? const Icon(Icons.check)
: null,
contentPadding: EdgeInsets.symmetric(horizontal: 8),
),
),
)
.toList();
return DropdownButtonHideUnderline( return DropdownButtonHideUnderline(
child: DropdownButton2<DevProject>( child: DropdownButton2<DevProject>(
@@ -496,50 +477,47 @@ class ProjectSelector extends HookConsumerWidget {
final isWider = isWiderScreen(context); final isWider = isWiderScreen(context);
return projectsMenu return projectsMenu
.map( .map(
(e) => (e) => isWider
isWider ? Row(
? Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ CircleAvatar(
CircleAvatar( radius: 16,
radius: 16, backgroundColor: Theme.of(
backgroundColor: context,
Theme.of(context).colorScheme.primary, ).colorScheme.primary,
child: Text( child: Text(
e.value?.name.isNotEmpty ?? false e.value?.name.isNotEmpty ?? false
? e.value!.name[0].toUpperCase() ? e.value!.name[0].toUpperCase()
: '?', : '?',
style: TextStyle(
color:
Theme.of(context).colorScheme.onPrimary,
),
),
),
const Gap(8),
Text(
e.value?.name ?? '?',
style: TextStyle( style: TextStyle(
color: color: Theme.of(context).colorScheme.onPrimary,
Theme.of(
context,
).appBarTheme.foregroundColor,
), ),
), ),
],
).padding(right: 8)
: CircleAvatar(
radius: 16,
backgroundColor:
Theme.of(context).colorScheme.primary,
child: Text(
e.value?.name.isNotEmpty ?? false
? e.value!.name[0].toUpperCase()
: '?',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
), ),
).center().padding(right: 8), const Gap(8),
Text(
e.value?.name ?? '?',
style: TextStyle(
color: Theme.of(
context,
).appBarTheme.foregroundColor,
),
),
],
).padding(right: 8)
: CircleAvatar(
radius: 16,
backgroundColor: Theme.of(context).colorScheme.primary,
child: Text(
e.value?.name.isNotEmpty ?? false
? e.value!.name[0].toUpperCase()
: '?',
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimary,
),
),
).center().padding(right: 8),
) )
.toList(); .toList();
}, },
@@ -590,45 +568,40 @@ class _ProjectListTile extends HookConsumerWidget {
subtitle: Text(project.description ?? ''), subtitle: Text(project.description ?? ''),
contentPadding: const EdgeInsets.only(left: 16, right: 17), contentPadding: const EdgeInsets.only(left: 16, right: 17),
trailing: PopupMenuButton( trailing: PopupMenuButton(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( value: 'edit',
value: 'edit', child: Row(
child: Row( children: [
children: [ const Icon(Symbols.edit),
const Icon(Symbols.edit), const SizedBox(width: 12),
const SizedBox(width: 12), Text('edit').tr(),
Text('edit').tr(), ],
], ),
), ),
), PopupMenuItem(
PopupMenuItem( value: 'delete',
value: 'delete', child: Row(
child: Row( children: [
children: [ const Icon(Symbols.delete, color: Colors.red),
const Icon(Symbols.delete, color: Colors.red), const SizedBox(width: 12),
const SizedBox(width: 12), Text('delete', style: const TextStyle(color: Colors.red)).tr(),
Text( ],
'delete', ),
style: const TextStyle(color: Colors.red), ),
).tr(), ],
],
),
),
],
onSelected: (value) { onSelected: (value) {
if (value == 'edit') { if (value == 'edit') {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'editProject'.tr(),
titleText: 'editProject'.tr(), child: ProjectForm(
child: ProjectForm( publisherName: publisherName,
publisherName: publisherName, project: project,
project: project, ),
), ),
),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
ref.invalidate(devProjectsProvider(publisherName)); ref.invalidate(devProjectsProvider(publisherName));
@@ -735,16 +708,24 @@ class _DeveloperUnselectedWidget extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (!hasDevelopers) ...[ if (!hasDevelopers) ...[
const Icon( if (developers.isLoading)
Symbols.info, Padding(
fill: 1, padding: const EdgeInsets.all(8),
size: 32, child: const CircularProgressIndicator(),
).padding(bottom: 6, top: 24), )
Text( else
'developerHubUnselectedHint', ...([
textAlign: TextAlign.center, const Icon(
style: Theme.of(context).textTheme.bodyLarge, Symbols.info,
).tr(), fill: 1,
size: 32,
).padding(bottom: 6, top: 24),
Text(
'developerHubUnselectedHint',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
).tr(),
]),
const Gap(24), const Gap(24),
], ],
if (hasDevelopers) if (hasDevelopers)
@@ -818,16 +799,15 @@ class ProjectForm extends HookConsumerWidget {
'description': descriptionController.text, 'description': descriptionController.text,
}; };
final resp = final resp = isEditing
isEditing ? await client.put(
? await client.put( '/develop/developers/$publisherName/projects/${project!.id}',
'/develop/developers/$publisherName/projects/${project!.id}', data: data,
data: data, )
) : await client.post(
: await client.post( '/develop/developers/$publisherName/projects',
'/develop/developers/$publisherName/projects', data: data,
data: data, );
);
if (!context.mounted) return; if (!context.mounted) return;
Navigator.of(context).pop(DevProject.fromJson(resp.data)); Navigator.of(context).pop(DevProject.fromJson(resp.data));
@@ -860,8 +840,8 @@ class ProjectForm extends HookConsumerWidget {
} }
return null; return null;
}, },
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
TextFormField( TextFormField(
controller: slugController, controller: slugController,
@@ -878,8 +858,8 @@ class ProjectForm extends HookConsumerWidget {
} }
return null; return null;
}, },
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
TextFormField( TextFormField(
controller: descriptionController, controller: descriptionController,
@@ -892,8 +872,8 @@ class ProjectForm extends HookConsumerWidget {
), ),
minLines: 3, minLines: 3,
maxLines: null, maxLines: null,
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
], ],
), ),
@@ -934,38 +914,34 @@ class _DeveloperEnrollmentSheet extends HookConsumerWidget {
return SheetScaffold( return SheetScaffold(
titleText: 'enrollDeveloper'.tr(), titleText: 'enrollDeveloper'.tr(),
child: publishers.when( child: publishers.when(
data: data: (items) => items.isEmpty
(items) => ? Center(
items.isEmpty child: Text(
? Center( 'noDevelopersToEnroll',
child: textAlign: TextAlign.center,
Text( ).tr(),
'noDevelopersToEnroll', )
textAlign: TextAlign.center, : ListView.builder(
).tr(), shrinkWrap: true,
) itemCount: items.length,
: ListView.builder( itemBuilder: (context, index) {
shrinkWrap: true, final publisher = items[index];
itemCount: items.length, return ListTile(
itemBuilder: (context, index) { leading: ProfilePictureWidget(
final publisher = items[index]; fileId: publisher.picture?.id,
return ListTile( fallbackIcon: Symbols.group,
leading: ProfilePictureWidget(
fileId: publisher.picture?.id,
fallbackIcon: Symbols.group,
),
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
onTap: () => enroll(publisher),
);
},
), ),
title: Text(publisher.nick),
subtitle: Text('@${publisher.name}'),
onTap: () => enroll(publisher),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (error, _) => ResponseErrorWidget(
(error, _) => ResponseErrorWidget( error: error,
error: error, onRetry: () => ref.invalidate(publishersManagedProvider),
onRetry: () => ref.invalidate(publishersManagedProvider), ),
),
), ),
); );
} }

View File

@@ -24,6 +24,16 @@ class ProjectDetailView extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final tabController = useTabController(initialLength: 2); final tabController = useTabController(initialLength: 2);
final currentDest = useState(0);
useEffect(() {
tabController.addListener(() {
if (tabController.indexIsChanging) {
currentDest.value = tabController.index;
}
});
return null;
});
final isWide = isWideScreen(context); final isWide = isWideScreen(context);
@@ -38,14 +48,13 @@ class ProjectDetailView extends HookConsumerWidget {
child: NavigationRail( child: NavigationRail(
extended: isWiderScreen(context), extended: isWiderScreen(context),
scrollable: true, scrollable: true,
labelType: labelType: isWiderScreen(context)
isWiderScreen(context) ? null
? null : NavigationRailLabelType.selected,
: NavigationRailLabelType.selected,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
selectedIndex: tabController.index, selectedIndex: currentDest.value,
onDestinationSelected: onDestinationSelected: (index) =>
(index) => tabController.animateTo(index), tabController.animateTo(index),
destinations: [ destinations: [
NavigationRailDestination( NavigationRailDestination(
icon: Icon(Icons.apps), icon: Icon(Icons.apps),

View File

@@ -33,7 +33,12 @@ class ArticlesListNotifier extends AsyncNotifier<List<SnWebArticle>>
Future<List<SnWebArticle>> fetch() async { Future<List<SnWebArticle>> fetch() async {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final queryParams = {'limit': pageSize, 'offset': fetchedCount.toString()}; final queryParams = {
'limit': pageSize,
'offset': fetchedCount.toString(),
'feedId': arg.feedId,
'publisherId': arg.publisherId,
}..removeWhere((key, value) => value == null);
try { try {
final response = await client.get( final response = await client.get(
@@ -41,13 +46,10 @@ class ArticlesListNotifier extends AsyncNotifier<List<SnWebArticle>>
queryParameters: queryParams, queryParameters: queryParams,
); );
final articles = final articles = response.data
response.data .map((json) => SnWebArticle.fromJson(json as Map<String, dynamic>))
.map( .cast<SnWebArticle>()
(json) => SnWebArticle.fromJson(json as Map<String, dynamic>), .toList();
)
.cast<SnWebArticle>()
.toList();
totalCount = int.tryParse(response.headers.value('X-Total') ?? '0') ?? 0; totalCount = int.tryParse(response.headers.value('X-Total') ?? '0') ?? 0;
@@ -81,6 +83,7 @@ class SliverArticlesList extends ConsumerWidget {
ArticleListQuery(feedId: feedId, publisherId: publisherId), ArticleListQuery(feedId: feedId, publisherId: publisherId),
); );
return PaginationList( return PaginationList(
spacing: 12,
provider: provider, provider: provider,
notifier: provider.notifier, notifier: provider.notifier,
isRefreshable: false, isRefreshable: false,
@@ -184,18 +187,16 @@ class ArticlesScreen extends ConsumerWidget {
), ),
); );
}, },
loading: loading: () => AppScaffold(
() => AppScaffold( isNoBackground: false,
isNoBackground: false, appBar: AppBar(title: const Text('Articles')),
appBar: AppBar(title: const Text('Articles')), body: const Center(child: CircularProgressIndicator()),
body: const Center(child: CircularProgressIndicator()), ),
), error: (err, stack) => AppScaffold(
error: isNoBackground: false,
(err, stack) => AppScaffold( appBar: AppBar(title: const Text('Articles')),
isNoBackground: false, body: Center(child: Text('Error: $err')),
appBar: AppBar(title: const Text('Articles')), ),
body: Center(child: Text('Error: $err')),
),
); );
} }
} }

View File

@@ -44,11 +44,10 @@ class MarketplaceWebFeedContentNotifier
queryParameters: queryParams, queryParameters: queryParams,
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final articles = final articles = response.data
response.data .map((json) => SnWebArticle.fromJson(json))
.map((json) => SnWebArticle.fromJson(json)) .cast<SnWebArticle>()
.cast<SnWebArticle>() .toList();
.toList();
return articles; return articles;
} }
@@ -116,31 +115,30 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
// Feed meta // Feed meta
feed feed
.when( .when(
data: data: (data) => Column(
(data) => Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
Text(data.description ?? 'descriptionNone'.tr()),
Row(
spacing: 4,
children: [ children: [
Text(data.description ?? 'descriptionNone'.tr()), const Icon(Symbols.rss_feed, size: 16),
Row( Text(
spacing: 4, 'webFeedArticleCount'.plural(
children: [ feedNotifier.totalCount ?? 0,
const Icon(Symbols.rss_feed, size: 16), ),
Text( ),
'webFeedArticleCount'.plural(
feedNotifier.totalCount ?? 0,
),
),
],
).opacity(0.85),
Row(
spacing: 4,
children: [
const Icon(Symbols.link, size: 16),
SelectableText(data.url),
],
).opacity(0.85),
], ],
), ).opacity(0.85),
Row(
spacing: 4,
children: [
const Icon(Symbols.link, size: 16),
SelectableText(data.url),
],
).opacity(0.85),
],
),
error: (err, _) => Text(err.toString()), error: (err, _) => Text(err.toString()),
loading: () => CircularProgressIndicator().center(), loading: () => CircularProgressIndicator().center(),
) )
@@ -149,10 +147,12 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
// Articles list // Articles list
Expanded( Expanded(
child: PaginationList( child: PaginationList(
spacing: 8,
padding: EdgeInsets.symmetric(vertical: 8),
provider: marketplaceWebFeedContentNotifierProvider(id), provider: marketplaceWebFeedContentNotifierProvider(id),
notifier: marketplaceWebFeedContentNotifierProvider(id).notifier, notifier: marketplaceWebFeedContentNotifierProvider(id).notifier,
itemBuilder: (context, index, article) { itemBuilder: (context, index, article) {
return WebArticleCard(article: article); return WebArticleCard(article: article).padding(horizontal: 12);
}, },
), ),
), ),
@@ -165,29 +165,25 @@ class MarketplaceWebFeedDetailScreen extends HookConsumerWidget {
), ),
color: Theme.of(context).colorScheme.surfaceContainer, color: Theme.of(context).colorScheme.surfaceContainer,
child: subscribed.when( child: subscribed.when(
data: data: (isSubscribed) => FilledButton.icon(
(isSubscribed) => FilledButton.icon( onPressed: isSubscribed ? unsubscribeFromFeed : subscribeToFeed,
onPressed: icon: Icon(
isSubscribed ? unsubscribeFromFeed : subscribeToFeed, isSubscribed ? Symbols.remove_circle : Symbols.add_circle,
icon: Icon( ),
isSubscribed ? Symbols.remove_circle : Symbols.add_circle, label: Text(
), isSubscribed ? 'unsubscribe'.tr() : 'subscribe'.tr(),
label: Text( ),
isSubscribed ? 'unsubscribe'.tr() : 'subscribe'.tr(), ),
), loading: () => const SizedBox(
), height: 32,
loading: width: 32,
() => const SizedBox( child: CircularProgressIndicator(strokeWidth: 2),
height: 32, ).center(),
width: 32, error: (_, _) => OutlinedButton.icon(
child: CircularProgressIndicator(strokeWidth: 2), onPressed: subscribeToFeed,
), icon: const Icon(Symbols.add_circle),
error: label: Text('subscribe').tr(),
(_, _) => OutlinedButton.icon( ),
onPressed: subscribeToFeed,
icon: const Icon(Symbols.add_circle),
label: Text('subscribe').tr(),
),
), ),
), ),
], ],

View File

@@ -12,7 +12,7 @@ import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
final marketplaceWebFeedsNotifierProvider = AsyncNotifierProvider( final marketplaceWebFeedsNotifierProvider = AsyncNotifierProvider.autoDispose(
MarketplaceWebFeedsNotifier.new, MarketplaceWebFeedsNotifier.new,
); );
@@ -38,11 +38,10 @@ class MarketplaceWebFeedsNotifier extends AsyncNotifier<List<SnWebFeed>>
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final feeds = final feeds = response.data
response.data .map((e) => SnWebFeed.fromJson(e))
.map((e) => SnWebFeed.fromJson(e)) .cast<SnWebFeed>()
.cast<SnWebFeed>() .toList();
.toList();
return feeds; return feeds;
} }
@@ -92,8 +91,8 @@ class MarketplaceWebFeedsScreen extends HookConsumerWidget {
padding: WidgetStateProperty.all( padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24), const EdgeInsets.symmetric(horizontal: 24),
), ),
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
trailing: [ trailing: [
if (query.value != null && query.value!.isNotEmpty) if (query.value != null && query.value!.isNotEmpty)
IconButton( IconButton(
@@ -128,6 +127,7 @@ class MarketplaceWebFeedsScreen extends HookConsumerWidget {
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemBuilder: (context, index, feed) { itemBuilder: (context, index, feed) {
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(feed.title), title: Text(feed.title),
subtitle: Text(feed.description ?? ''), subtitle: Text(feed.description ?? ''),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),

View File

@@ -23,7 +23,7 @@ class DiscoveryRealmsScreen extends HookConsumerWidget {
children: [ children: [
CustomScrollView( CustomScrollView(
slivers: [ slivers: [
SliverGap(80), SliverGap(88),
SliverRealmList( SliverRealmList(
query: currentQuery.value, query: currentQuery.value,
key: ValueKey(currentQuery.value), key: ValueKey(currentQuery.value),

View File

@@ -26,6 +26,7 @@ import 'package:island/widgets/navigation/fab_menu.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:island/widgets/post/post_featured.dart'; import 'package:island/widgets/post/post_featured.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_skeleton.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:island/widgets/realm/realm_card.dart'; import 'package:island/widgets/realm/realm_card.dart';
import 'package:island/widgets/publisher/publisher_card.dart'; import 'package:island/widgets/publisher/publisher_card.dart';
@@ -101,7 +102,7 @@ class ExploreScreen extends HookConsumerWidget {
// Listen for post creation events to refresh activities // Listen for post creation events to refresh activities
useEffect(() { useEffect(() {
final subscription = eventBus.on<PostCreatedEvent>().listen((event) { final subscription = eventBus.on<PostCreatedEvent>().listen((event) {
ref.invalidate(activityListProvider); ref.read(activityListProvider.notifier).refresh();
}); });
return subscription.cancel; return subscription.cancel;
}, []); }, []);
@@ -137,10 +138,9 @@ class ExploreScreen extends HookConsumerWidget {
), ),
tooltip: 'explore'.tr(), tooltip: 'explore'.tr(),
isSelected: currentFilter.value == null, isSelected: currentFilter.value == null,
color: color: currentFilter.value == null
currentFilter.value == null ? Theme.of(context).colorScheme.primary
? Theme.of(context).colorScheme.primary : null,
: null,
), ),
IconButton( IconButton(
onPressed: () => handleFilterChange('subscriptions'), onPressed: () => handleFilterChange('subscriptions'),
@@ -150,10 +150,9 @@ class ExploreScreen extends HookConsumerWidget {
), ),
tooltip: 'exploreFilterSubscriptions'.tr(), tooltip: 'exploreFilterSubscriptions'.tr(),
isSelected: currentFilter.value == 'subscriptions', isSelected: currentFilter.value == 'subscriptions',
color: color: currentFilter.value == 'subscriptions'
currentFilter.value == 'subscriptions' ? Theme.of(context).colorScheme.primary
? Theme.of(context).colorScheme.primary : null,
: null,
), ),
IconButton( IconButton(
onPressed: () => handleFilterChange('friends'), onPressed: () => handleFilterChange('friends'),
@@ -163,10 +162,9 @@ class ExploreScreen extends HookConsumerWidget {
), ),
tooltip: 'exploreFilterFriends'.tr(), tooltip: 'exploreFilterFriends'.tr(),
isSelected: currentFilter.value == 'friends', isSelected: currentFilter.value == 'friends',
color: color: currentFilter.value == 'friends'
currentFilter.value == 'friends' ? Theme.of(context).colorScheme.primary
? Theme.of(context).colorScheme.primary : null,
: null,
), ),
], ],
), ),
@@ -179,57 +177,44 @@ class ExploreScreen extends HookConsumerWidget {
tooltip: 'webArticlesStand'.tr(), tooltip: 'webArticlesStand'.tr(),
), ),
PopupMenuButton( PopupMenuButton(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.category),
const Icon(Symbols.category), const Gap(12),
const Gap(12), Text('categoriesAndTags').tr(),
Text('categories').tr(), ],
], ),
), onTap: () {
onTap: () { context.pushNamed('postCategories');
context.pushNamed('postCategories'); },
}, ),
), PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.shuffle),
const Icon(Symbols.label), const Gap(12),
const Gap(12), Text('postShuffle').tr(),
Text('tags').tr(), ],
], ),
), onTap: () {
onTap: () { context.pushNamed('postShuffle');
context.pushNamed('postTags'); },
}, ),
), PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.search),
const Icon(Symbols.shuffle), const Gap(12),
const Gap(12), Text('search').tr(),
Text('postShuffle').tr(), ],
], ),
), onTap: () {
onTap: () { context.pushNamed('postSearch');
context.pushNamed('postShuffle'); },
}, ),
), ],
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.search),
const Gap(12),
Text('search').tr(),
],
),
onTap: () {
context.pushNamed('postSearch');
},
),
],
icon: Icon(Symbols.action_key), icon: Icon(Symbols.action_key),
tooltip: 'search'.tr(), tooltip: 'search'.tr(),
), ),
@@ -237,10 +222,9 @@ class ExploreScreen extends HookConsumerWidget {
).padding(horizontal: 8, vertical: 4), ).padding(horizontal: 8, vertical: 4),
); );
final appBar = final appBar = isWide
isWide ? null
? null : _buildAppBar(currentFilter.value, handleFilterChange, context);
: _buildAppBar(currentFilter.value, handleFilterChange, context);
final dragging = useState(false); final dragging = useState(false);
@@ -263,19 +247,18 @@ class ExploreScreen extends HookConsumerWidget {
AppScaffold( AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: appBar, appBar: appBar,
body: body: isWide
isWide ? _buildWideBody(
? _buildWideBody( context,
context, ref,
ref, filterBar,
filterBar, user,
user, notificationCount,
notificationCount, query,
query, events,
events, selectedDay,
selectedDay, )
) : _buildNarrowBody(context, ref, currentFilter.value),
: _buildNarrowBody(context, ref, currentFilter.value),
), ),
if (dragging.value) if (dragging.value)
Positioned.fill( Positioned.fill(
@@ -295,12 +278,11 @@ class ExploreScreen extends HookConsumerWidget {
const Gap(16), const Gap(16),
Text( Text(
'dropToShare'.tr(), 'dropToShare'.tr(),
style: Theme.of( style: Theme.of(context).textTheme.headlineMedium
context, ?.copyWith(
).textTheme.headlineMedium?.copyWith( color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, ),
),
), ),
], ],
), ),
@@ -321,9 +303,9 @@ class ExploreScreen extends HookConsumerWidget {
// Sliver list cannot provide refresh handled by the pagination list // Sliver list cannot provide refresh handled by the pagination list
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
contentBuilder: footerSkeletonChild: const PostItemSkeleton(),
(data, footer) => contentBuilder: (data, footer) =>
_ActivityListView(data: data, isWide: isWide, footer: footer), _ActivityListView(data: data, isWide: isWide, footer: footer),
); );
} }
@@ -393,39 +375,38 @@ class ExploreScreen extends HookConsumerWidget {
else else
Flexible( Flexible(
flex: 2, flex: 2,
child: child: Column(
Column( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center, children: [
children: [ const Icon(Symbols.emoji_people_rounded, size: 40),
const Icon(Symbols.emoji_people_rounded, size: 40), const Gap(8),
const Gap(8), Text(
Text( 'Welcome to\nthe Solar Network',
'Welcome to\nthe Solar Network', style: Theme.of(context).textTheme.titleLarge,
style: Theme.of(context).textTheme.titleLarge, textAlign: TextAlign.center,
textAlign: TextAlign.center, ).bold(),
).bold(), const Gap(2),
const Gap(2), Text(
Text( 'Login to explore more!',
'Login to explore more!', style: Theme.of(context).textTheme.bodyLarge,
style: Theme.of(context).textTheme.bodyLarge, textAlign: TextAlign.center,
textAlign: TextAlign.center, ),
), const Gap(4),
const Gap(4), TextButton.icon(
TextButton.icon( onPressed: () {
onPressed: () { showModalBottomSheet(
showModalBottomSheet( context: context,
context: context, useRootNavigator: true,
useRootNavigator: true, isScrollControlled: true,
isScrollControlled: true, builder: (context) => LoginModal(),
builder: (context) => LoginModal(), );
); },
}, icon: const Icon(Symbols.login),
icon: const Icon(Symbols.login), label: Text('login').tr(),
label: Text('login').tr(), ),
), ],
], ).padding(horizontal: 36, vertical: 16).center(),
).padding(horizontal: 36, vertical: 16).center(),
), ),
], ],
).padding(horizontal: 12); ).padding(horizontal: 12);
@@ -491,57 +472,44 @@ class ExploreScreen extends HookConsumerWidget {
tooltip: 'webArticlesStand'.tr(), tooltip: 'webArticlesStand'.tr(),
), ),
PopupMenuButton( PopupMenuButton(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.category),
const Icon(Symbols.category), const Gap(12),
const Gap(12), Text('categoriesAndTags').tr(),
Text('categories').tr(), ],
], ),
), onTap: () {
onTap: () { context.pushNamed('postCategories');
context.pushNamed('postCategories'); },
}, ),
), PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.shuffle),
const Icon(Symbols.label), const Gap(12),
const Gap(12), Text('postShuffle').tr(),
Text('tags').tr(), ],
], ),
), onTap: () {
onTap: () { context.pushNamed('postShuffle');
context.pushNamed('postTags'); },
}, ),
), PopupMenuItem(
PopupMenuItem( child: Row(
child: Row( children: [
children: [ const Icon(Symbols.search),
const Icon(Symbols.shuffle), const Gap(12),
const Gap(12), Text('search').tr(),
Text('postShuffle').tr(), ],
], ),
), onTap: () {
onTap: () { context.pushNamed('postSearch');
context.pushNamed('postShuffle'); },
}, ),
), ],
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.search),
const Gap(12),
Text('search').tr(),
],
),
onTap: () {
context.pushNamed('postSearch');
},
),
],
icon: Icon(Symbols.action_key, color: foregroundColor), icon: Icon(Symbols.action_key, color: foregroundColor),
tooltip: 'search'.tr(), tooltip: 'search'.tr(),
), ),

View File

@@ -10,9 +10,9 @@ import 'package:google_fonts/google_fonts.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/file_references.dart'; import 'package:island/pods/drive/file_references.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/upload_tasks.dart'; import 'package:island/pods/drive/upload_tasks.dart';
import 'package:island/models/drive_task.dart'; import 'package:island/models/drive_task.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
@@ -120,8 +120,9 @@ class FileDetailScreen extends HookConsumerWidget {
child: SizedBox( child: SizedBox(
width: 400, width: 400,
child: Material( child: Material(
color: color: Theme.of(
Theme.of(context).colorScheme.surfaceContainer, context,
).colorScheme.surfaceContainer,
elevation: 8, elevation: 8,
child: FileInfoSheet( child: FileInfoSheet(
item: item, item: item,
@@ -176,17 +177,15 @@ class FileDetailScreen extends HookConsumerWidget {
actions.add( actions.add(
IconButton( IconButton(
icon: Icon(Icons.link), icon: Icon(Icons.link),
onPressed: onPressed: () => showModalBottomSheet(
() => showModalBottomSheet( useRootNavigator: true,
useRootNavigator: true, context: context,
context: context, isScrollControlled: true,
isScrollControlled: true, builder: (context) => SheetScaffold(
builder: titleText: 'File References',
(context) => SheetScaffold( child: ReferencesList(fileId: item.id),
titleText: 'File References', ),
child: ReferencesList(fileId: item.id), ),
),
),
), ),
); );
@@ -300,43 +299,39 @@ class ReferencesList extends ConsumerWidget {
final asyncReferences = ref.watch(fileReferencesProvider(fileId)); final asyncReferences = ref.watch(fileReferencesProvider(fileId));
return asyncReferences.when( return asyncReferences.when(
data: data: (references) => ListView.builder(
(references) => ListView.builder( itemCount: references.length,
itemCount: references.length, itemBuilder: (context, index) {
itemBuilder: (context, index) { final reference = references[index];
final reference = references[index]; return ListTile(
return ListTile( leading: const Icon(Icons.link),
leading: const Icon(Icons.link), title: Row(
title: Row( spacing: 6,
spacing: 6, children: [
children: [ Text(
Text( reference.usage,
reference.usage, style: GoogleFonts.robotoMono(
style: GoogleFonts.robotoMono( fontWeight: FontWeight.bold,
fontWeight: FontWeight.bold, fontSize: 13,
fontSize: 13, ),
),
),
Text(
reference.id,
style: GoogleFonts.robotoMono(fontSize: 13),
),
],
), ),
subtitle: Row( Text(reference.id, style: GoogleFonts.robotoMono(fontSize: 13)),
spacing: 8, ],
children: [ ),
Text(reference.createdAt.formatRelative(context)), subtitle: Row(
const VerticalDivider(width: 1, thickness: 1).height(12), spacing: 8,
Text(reference.createdAt.formatSystem()), children: [
], Text(reference.createdAt.formatRelative(context)),
), const VerticalDivider(width: 1, thickness: 1).height(12),
); Text(reference.createdAt.formatSystem()),
}, ],
), ),
);
},
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: error: (error, _) =>
(error, _) => Center(child: Text('Error loading references: $error')), Center(child: Text('Error loading references: $error')),
); );
} }
} }

View File

@@ -7,7 +7,7 @@ import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart'; import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.dart'; import 'package:island/pods/drive/file_list.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
@@ -40,38 +40,31 @@ class FileListScreen extends HookConsumerWidget {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Symbols.bar_chart), icon: const Icon(Symbols.bar_chart),
onPressed: onPressed: () =>
() => _showUsageSheet( _showUsageSheet(context, usageAsync.value, quotaAsync.value),
context,
usageAsync.value,
quotaAsync.value,
),
), ),
const Gap(8), const Gap(8),
], ],
), ),
body: usageAsync.when( body: usageAsync.when(
data: data: (usage) => quotaAsync.when(
(usage) => quotaAsync.when( data: (quota) => FileListView(
data: usage: usage,
(quota) => FileListView( quota: quota,
usage: usage, currentPath: currentPath,
quota: quota, selectedPool: selectedPool,
currentPath: currentPath, onPickAndUpload: () => _pickAndUploadFile(
selectedPool: selectedPool, ref,
onPickAndUpload: currentPath.value,
() => _pickAndUploadFile( selectedPool.value?.id,
ref,
currentPath.value,
selectedPool.value?.id,
),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),
), ),
onShowCreateDirectory: _showCreateDirectoryDialog,
mode: mode,
viewMode: viewMode,
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading quota')),
),
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error loading usage')), error: (e, _) => Center(child: Text('Error loading usage')),
), ),
@@ -158,44 +151,43 @@ class FileListScreen extends HookConsumerWidget {
await showDialog( await showDialog(
context: context, context: context,
builder: builder: (context) => AlertDialog(
(context) => AlertDialog( title: const Text('Navigate to Directory'),
title: const Text('Navigate to Directory'), content: Column(
content: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ const Gap(8),
const Gap(8), TextField(
TextField( controller: controller,
controller: controller, decoration: const InputDecoration(
decoration: const InputDecoration( labelText: 'Directory path',
labelText: 'Directory path', hintText: 'e.g., documents, projects/my-app',
hintText: 'e.g., documents, projects/my-app', helperText:
helperText: 'Enter a directory path. The directory will be created when you upload files to it.',
'Enter a directory path. The directory will be created when you upload files to it.', helperMaxLines: 3,
helperMaxLines: 3, border: OutlineInputBorder(
border: OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(12)),
borderRadius: BorderRadius.all(Radius.circular(12)),
),
),
onSubmitted: (_) {
handleChangeDirectory(context);
},
), ),
], ),
onSubmitted: (_) {
handleChangeDirectory(context);
},
), ),
actions: [ ],
TextButton( ),
onPressed: () => Navigator.of(context).pop(), actions: [
child: const Text('Cancel'), TextButton(
), onPressed: () => Navigator.of(context).pop(),
TextButton.icon( child: const Text('Cancel'),
onPressed: () => handleChangeDirectory(context),
label: const Text('Go to Directory'),
icon: const Icon(Symbols.arrow_right_alt),
),
],
), ),
TextButton.icon(
onPressed: () => handleChangeDirectory(context),
label: const Text('Go to Directory'),
icon: const Icon(Symbols.arrow_right_alt),
),
],
),
); );
} }
@@ -207,14 +199,13 @@ class FileListScreen extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'Usage Overview',
titleText: 'Usage Overview', child: UsageOverviewWidget(
child: UsageOverviewWidget( usage: usage,
usage: usage, quota: quota,
quota: quota, ).padding(horizontal: 8, vertical: 16),
).padding(horizontal: 8, vertical: 16), ),
),
); );
} }
} }

View File

@@ -3,6 +3,7 @@ import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/account.dart'; import 'package:island/models/account.dart';
@@ -10,7 +11,6 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
import 'package:island/pods/websocket.dart'; import 'package:island/pods/websocket.dart';
import 'package:island/route.dart'; import 'package:island/route.dart';
import 'package:island/widgets/alert.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
@@ -18,11 +18,92 @@ import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:relative_time/relative_time.dart'; import 'package:relative_time/relative_time.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
part 'notification.g.dart'; part 'notification.g.dart';
class SkeletonNotificationTile extends StatelessWidget {
const SkeletonNotificationTile({super.key});
@override
Widget build(BuildContext context) {
const fakeTitle = 'New notification';
const fakeSubtitle = 'You have a new message from someone';
const fakeContent =
'This is a preview of the notification content. It may contain formatted text.';
const List<String> fakeImageIds = []; // Empty list for no images
const String? fakePfp = null; // No profile picture
return ListTile(
isThreeLine: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: fakePfp != null
? ProfilePictureWidget(fileId: fakePfp, radius: 20)
: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Symbols.notifications,
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: const Text(fakeTitle),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(fakeSubtitle).bold(),
Row(
spacing: 6,
children: [
Text('Loading...').fontSize(11),
Skeleton.ignore(child: Text('·').fontSize(11).bold()),
Text('Now').fontSize(11),
],
).opacity(0.75).padding(bottom: 4),
MarkdownTextContent(
content: fakeContent,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.8),
),
),
if (fakeImageIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: fakeImageIds.map((imageId) {
return SizedBox(
width: 80,
height: 80,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CloudImageWidget(
fileId: imageId,
aspectRatio: 1,
fit: BoxFit.cover,
),
),
);
}).toList(),
),
),
],
),
trailing: Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
onTap: () {},
);
}
}
@riverpod @riverpod
class NotificationUnreadCountNotifier class NotificationUnreadCountNotifier
extends _$NotificationUnreadCountNotifier { extends _$NotificationUnreadCountNotifier {
@@ -82,7 +163,7 @@ class NotificationUnreadCountNotifier
} }
} }
final notificationListProvider = AsyncNotifierProvider( final notificationListProvider = AsyncNotifierProvider.autoDispose(
NotificationListNotifier.new, NotificationListNotifier.new,
); );
@@ -101,11 +182,10 @@ class NotificationListNotifier extends AsyncNotifier<List<SnNotification>>
queryParameters: queryParams, queryParameters: queryParams,
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final notifications = final notifications = response.data
response.data .map((json) => SnNotification.fromJson(json))
.map((json) => SnNotification.fromJson(json)) .cast<SnNotification>()
.cast<SnNotification>() .toList();
.toList();
final unreadCount = notifications.where((n) => n.viewedAt == null).length; final unreadCount = notifications.where((n) => n.viewedAt == null).length;
ref.read(notificationUnreadCountProvider.notifier).decrement(unreadCount); ref.read(notificationUnreadCountProvider.notifier).decrement(unreadCount);
@@ -145,15 +225,22 @@ class NotificationSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
// Refresh unread count when sheet opens to sync across devices // Refresh unread count when sheet opens to sync across devices
ref.read(notificationUnreadCountProvider.notifier).refresh(); useEffect(() {
Future(() {
ref.read(notificationUnreadCountProvider.notifier).refresh();
});
return null;
}, []);
final isLoading = useState(false);
Future<void> markAllRead() async { Future<void> markAllRead() async {
showLoadingModal(context); isLoading.value = true;
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
await apiClient.post('/ring/notifications/all/read'); await apiClient.post('/ring/notifications/all/read');
if (!context.mounted) return; if (!context.mounted) return;
hideLoadingModal(context); isLoading.value = false;
ref.invalidate(notificationListProvider); ref.read(notificationListProvider.notifier).refresh();
ref.watch(notificationUnreadCountProvider.notifier).clear(); ref.watch(notificationUnreadCountProvider.notifier).clear();
} }
@@ -165,108 +252,126 @@ class NotificationSheet extends HookConsumerWidget {
icon: const Icon(Symbols.mark_as_unread), icon: const Icon(Symbols.mark_as_unread),
), ),
], ],
child: PaginationList( child: Column(
provider: notificationListProvider, children: [
notifier: notificationListProvider.notifier, if (isLoading.value)
itemBuilder: (context, index, notification) { LinearProgressIndicator(
final pfp = notification.meta['pfp'] as String?; minHeight: 2,
final images = notification.meta['images'] as List?; color: Theme.of(context).colorScheme.primary,
final imageIds = images?.cast<String>() ?? [];
return ListTile(
isThreeLine: true,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading:
pfp != null
? ProfilePictureWidget(fileId: pfp, radius: 20)
: CircleAvatar(
backgroundColor:
Theme.of(context).colorScheme.primaryContainer,
child: Icon(
_getNotificationIcon(notification.topic),
color: Theme.of(context).colorScheme.onPrimaryContainer,
),
),
title: Text(notification.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (notification.subtitle.isNotEmpty)
Text(notification.subtitle).bold(),
Row(
spacing: 6,
children: [
Text(
DateFormat().format(notification.createdAt.toLocal()),
).fontSize(11),
Text('·').fontSize(11).bold(),
Text(
RelativeTime(
context,
).format(notification.createdAt.toLocal()),
).fontSize(11),
],
).opacity(0.75).padding(bottom: 4),
MarkdownTextContent(
content: notification.content,
textStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.8),
),
),
if (imageIds.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 8,
runSpacing: 8,
children:
imageIds.map((imageId) {
return SizedBox(
width: 80,
height: 80,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CloudImageWidget(
fileId: imageId,
aspectRatio: 1,
fit: BoxFit.cover,
),
),
);
}).toList(),
),
),
],
), ),
trailing: Expanded(
notification.viewedAt != null child: PaginationList(
? null provider: notificationListProvider,
: Container( notifier: notificationListProvider.notifier,
width: 12, footerSkeletonChild: const SkeletonNotificationTile(),
height: 12, itemBuilder: (context, index, notification) {
decoration: const BoxDecoration( final pfp = notification.meta['pfp'] as String?;
color: Colors.blue, final images = notification.meta['images'] as List?;
shape: BoxShape.circle, final imageIds = images?.cast<String>() ?? [];
return ListTile(
isThreeLine: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
leading: pfp != null
? ProfilePictureWidget(fileId: pfp, radius: 20)
: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primaryContainer,
child: Icon(
_getNotificationIcon(notification.topic),
color: Theme.of(
context,
).colorScheme.onPrimaryContainer,
),
),
title: Text(notification.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (notification.subtitle.isNotEmpty)
Text(notification.subtitle).bold(),
Row(
spacing: 6,
children: [
Text(
DateFormat().format(
notification.createdAt.toLocal(),
),
).fontSize(11),
Text('·').fontSize(11).bold(),
Text(
RelativeTime(
context,
).format(notification.createdAt.toLocal()),
).fontSize(11),
],
).opacity(0.75).padding(bottom: 4),
MarkdownTextContent(
content: notification.content,
textStyle: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withOpacity(0.8),
),
), ),
), if (imageIds.isNotEmpty)
onTap: () { Padding(
if (notification.meta['action_uri'] != null) { padding: const EdgeInsets.only(top: 8),
var uri = notification.meta['action_uri'] as String; child: Wrap(
if (uri.startsWith('/')) { spacing: 8,
// In-app routes runSpacing: 8,
rootNavigatorKey.currentContext?.push( children: imageIds.map((imageId) {
notification.meta['action_uri'], return SizedBox(
); width: 80,
} else { height: 80,
// External URLs child: ClipRRect(
launchUrlString(uri); borderRadius: BorderRadius.circular(8),
} child: CloudImageWidget(
} fileId: imageId,
}, aspectRatio: 1,
); fit: BoxFit.cover,
}, ),
),
);
}).toList(),
),
),
],
),
trailing: notification.viewedAt != null
? null
: Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
),
onTap: () {
if (notification.meta['action_uri'] != null) {
var uri = notification.meta['action_uri'] as String;
if (uri.startsWith('/')) {
// In-app routes
rootNavigatorKey.currentContext?.push(
notification.meta['action_uri'],
);
} else {
// External URLs
launchUrlString(uri);
}
}
},
);
},
),
),
],
), ),
); );
} }

View File

@@ -18,14 +18,12 @@ _PostComposeInitialState _$PostComposeInitialStateFromJson(
.toList() ?? .toList() ??
const [], const [],
visibility: (json['visibility'] as num?)?.toInt(), visibility: (json['visibility'] as num?)?.toInt(),
replyingTo: replyingTo: json['replying_to'] == null
json['replying_to'] == null ? null
? null : SnPost.fromJson(json['replying_to'] as Map<String, dynamic>),
: SnPost.fromJson(json['replying_to'] as Map<String, dynamic>), forwardingTo: json['forwarding_to'] == null
forwardingTo: ? null
json['forwarding_to'] == null : SnPost.fromJson(json['forwarding_to'] as Map<String, dynamic>),
? null
: SnPost.fromJson(json['forwarding_to'] as Map<String, dynamic>),
); );
Map<String, dynamic> _$PostComposeInitialStateToJson( Map<String, dynamic> _$PostComposeInitialStateToJson(

View File

@@ -2,118 +2,113 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post_category.dart'; import 'package:island/pods/post/post_categories.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
// Post Categories Notifier class PostCategoriesListScreen extends HookConsumerWidget {
final postCategoriesProvider = AsyncNotifierProvider.autoDispose<
PostCategoriesNotifier,
List<SnPostCategory>
>(PostCategoriesNotifier.new);
class PostCategoriesNotifier extends AsyncNotifier<List<SnPostCategory>>
with AsyncPaginationController<SnPostCategory> {
@override
Future<List<SnPostCategory>> fetch() async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts/categories',
queryParameters: {'offset': fetchedCount, 'take': 20, 'order': 'usage'},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final data = response.data as List;
return data.map((json) => SnPostCategory.fromJson(json)).toList();
}
}
// Post Tags Notifier
final postTagsProvider =
AsyncNotifierProvider.autoDispose<PostTagsNotifier, List<SnPostTag>>(
PostTagsNotifier.new,
);
class PostTagsNotifier extends AsyncNotifier<List<SnPostTag>>
with AsyncPaginationController<SnPostTag> {
@override
Future<List<SnPostTag>> fetch() async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts/tags',
queryParameters: {'offset': fetchedCount, 'take': 20, 'order': 'usage'},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final data = response.data as List;
return data.map((json) => SnPostTag.fromJson(json)).toList();
}
}
class PostCategoriesListScreen extends ConsumerWidget {
const PostCategoriesListScreen({super.key}); const PostCategoriesListScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold( return DefaultTabController(
appBar: AppBar(title: const Text('categories').tr()), length: 2,
body: PaginationList( child: AppScaffold(
provider: postCategoriesProvider, isNoBackground: false,
notifier: postCategoriesProvider.notifier, appBar: AppBar(
padding: EdgeInsets.zero, title: const Text('categoriesAndTags').tr(),
itemBuilder: (context, index, category) { bottom: TabBar(
return ListTile( tabs: [
leading: const Icon(Symbols.category), Tab(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), child: Text(
trailing: const Icon(Symbols.chevron_right), 'categories'.tr(),
title: Text(category.categoryDisplayTitle), style: TextStyle(
subtitle: Text('postCount'.plural(category.usage)), color: Theme.of(context).appBarTheme.foregroundColor,
onTap: () { ),
context.pushNamed( ),
'postCategoryDetail', ),
pathParameters: {'slug': category.slug}, Tab(
); child: Text(
}, 'tags'.tr(),
); style: TextStyle(
}, color: Theme.of(context).appBarTheme.foregroundColor,
),
),
),
],
),
),
body: const TabBarView(children: [_CategoriesTab(), _TagsTab()]),
), ),
); );
} }
} }
class PostTagsListScreen extends ConsumerWidget { class _CategoriesTab extends ConsumerWidget {
const PostTagsListScreen({super.key}); const _CategoriesTab();
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold( return PaginationList(
appBar: AppBar(title: const Text('tags').tr()), provider: postCategoriesProvider,
body: PaginationList( notifier: postCategoriesProvider.notifier,
provider: postTagsProvider, footerSkeletonMaxWidth: 640,
notifier: postTagsProvider.notifier, padding: EdgeInsets.zero,
padding: EdgeInsets.zero, itemBuilder: (context, index, category) {
itemBuilder: (context, index, tag) { return Center(
return ListTile( child: ConstrainedBox(
title: Text(tag.name ?? '#${tag.slug}'), constraints: const BoxConstraints(maxWidth: 640),
contentPadding: const EdgeInsets.symmetric(horizontal: 24), child: ListTile(
leading: const Icon(Symbols.label), leading: const Icon(Symbols.category),
trailing: const Icon(Symbols.chevron_right), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
subtitle: Text('postCount'.plural(tag.usage)), trailing: const Icon(Symbols.chevron_right),
onTap: () { title: Text(category.categoryDisplayTitle),
context.pushNamed( subtitle: Text('postCount'.plural(category.usage)),
'postTagDetail', onTap: () {
pathParameters: {'slug': tag.slug}, context.pushNamed(
); 'postCategoryDetail',
}, pathParameters: {'slug': category.slug},
); );
}, },
), ),
),
);
},
);
}
}
class _TagsTab extends ConsumerWidget {
const _TagsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return PaginationList(
provider: postTagsProvider,
notifier: postTagsProvider.notifier,
footerSkeletonMaxWidth: 640,
padding: EdgeInsets.zero,
itemBuilder: (context, index, tag) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: ListTile(
title: Text(tag.name ?? '#${tag.slug}'),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.label),
trailing: const Icon(Symbols.chevron_right),
subtitle: Text('postCount'.plural(tag.usage)),
onTap: () {
context.pushNamed(
'postTagDetail',
pathParameters: {'slug': tag.slug},
);
},
),
),
);
},
); );
} }
} }

View File

@@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post_category.dart'; import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart'; import 'package:island/models/post_tag.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
@@ -82,17 +83,17 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final postCategory = final postCategory = isCategory
isCategory ? ref.watch(postCategoryProvider(slug)) : null; ? ref.watch(postCategoryProvider(slug))
: null;
final postTag = isCategory ? null : ref.watch(postTagProvider(slug)); final postTag = isCategory ? null : ref.watch(postTagProvider(slug));
final subscriptionStatus = ref.watch( final subscriptionStatus = ref.watch(
postCategorySubscriptionStatusProvider(slug, isCategory), postCategorySubscriptionStatusProvider(slug, isCategory),
); );
final postFilterTitle = final postFilterTitle = isCategory
isCategory ? postCategory?.value?.categoryDisplayTitle ?? 'loading'
? postCategory?.value?.categoryDisplayTitle ?? 'loading' : postTag?.value?.name ?? postTag?.value?.slug ?? 'loading';
: postTag?.value?.name ?? postTag?.value?.slug ?? 'loading';
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
@@ -108,63 +109,50 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
child: Card( child: Card(
margin: EdgeInsets.only(top: 8), margin: EdgeInsets.only(top: 8),
child: postCategory!.when( child: postCategory!.when(
data: data: (category) => Column(
(category) => Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Text(
Text( category.categoryDisplayTitle,
category.categoryDisplayTitle, ).bold().fontSize(15),
).bold().fontSize(15), Text('A category'),
Text('A category'), const Gap(8),
const Gap(8), subscriptionStatus.when(
subscriptionStatus.when( data: (isSubscribed) => isSubscribed
data: ? FilledButton.icon(
(isSubscribed) => onPressed: () async {
isSubscribed await _unsubscribeFromCategoryOrTag(
? FilledButton.icon( ref,
onPressed: () async { slug: slug,
await _unsubscribeFromCategoryOrTag( isCategory: isCategory,
ref, );
slug: slug, },
isCategory: isCategory, icon: const Icon(Symbols.remove_circle),
); label: Text('unsubscribe'.tr()),
}, )
icon: const Icon( : FilledButton.icon(
Symbols.remove_circle, onPressed: () async {
), await _subscribeToCategoryOrTag(
label: Text('unsubscribe'.tr()), ref,
) slug: slug,
: FilledButton.icon( isCategory: isCategory,
onPressed: () async { );
await _subscribeToCategoryOrTag( },
ref, icon: const Icon(Symbols.add_circle),
slug: slug, label: Text('subscribe'.tr()),
isCategory: isCategory, ),
); error: (error, _) =>
}, Text('Error loading subscription status'),
icon: const Icon( loading: () =>
Symbols.add_circle, CircularProgressIndicator().center(),
),
label: Text('subscribe'.tr()),
),
error:
(error, _) => Text(
'Error loading subscription status',
),
loading:
() =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() => ref.invalidate(
postCategoryProvider(slug),
),
), ),
],
).padding(horizontal: 24, vertical: 16),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () =>
ref.invalidate(postCategoryProvider(slug)),
),
loading: () => ResponseLoadingWidget(), loading: () => ResponseLoadingWidget(),
), ),
), ),
@@ -179,61 +167,49 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
child: Card( child: Card(
margin: EdgeInsets.only(top: 8), margin: EdgeInsets.only(top: 8),
child: postTag!.when( child: postTag!.when(
data: data: (tag) => Column(
(tag) => Column( crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ Text(
Text( tag.name ?? '#${tag.slug}',
tag.name ?? '#${tag.slug}', ).bold().fontSize(15),
).bold().fontSize(15), Text('A tag'),
Text('A tag'), const Gap(8),
const Gap(8), subscriptionStatus.when(
subscriptionStatus.when( data: (isSubscribed) => isSubscribed
data: ? FilledButton.icon(
(isSubscribed) => onPressed: () async {
isSubscribed await _unsubscribeFromCategoryOrTag(
? FilledButton.icon( ref,
onPressed: () async { slug: slug,
await _unsubscribeFromCategoryOrTag( isCategory: isCategory,
ref, );
slug: slug, },
isCategory: isCategory, icon: const Icon(Symbols.remove_circle),
); label: Text('unsubscribe'.tr()),
}, )
icon: const Icon( : FilledButton.icon(
Symbols.remove_circle, onPressed: () async {
), await _subscribeToCategoryOrTag(
label: Text('unsubscribe'.tr()), ref,
) slug: slug,
: FilledButton.icon( isCategory: isCategory,
onPressed: () async { );
await _subscribeToCategoryOrTag( },
ref, icon: const Icon(Symbols.add_circle),
slug: slug, label: Text('subscribe'.tr()),
isCategory: isCategory, ),
); error: (error, _) =>
}, Text('Error loading subscription status'),
icon: const Icon( loading: () =>
Symbols.add_circle, CircularProgressIndicator().center(),
),
label: Text('subscribe'.tr()),
),
error:
(error, _) => Text(
'Error loading subscription status',
),
loading:
() =>
CircularProgressIndicator().center(),
),
],
).padding(horizontal: 24, vertical: 16),
error:
(error, _) => ResponseErrorWidget(
error: error,
onRetry:
() => ref.invalidate(postTagProvider(slug)),
), ),
],
).padding(horizontal: 24, vertical: 16),
error: (error, _) => ResponseErrorWidget(
error: error,
onRetry: () => ref.invalidate(postTagProvider(slug)),
),
loading: () => ResponseLoadingWidget(), loading: () => ResponseLoadingWidget(),
), ),
), ),
@@ -242,8 +218,11 @@ class PostCategoryDetailScreen extends HookConsumerWidget {
), ),
const SliverGap(4), const SliverGap(4),
SliverPostList( SliverPostList(
categories: isCategory ? [slug] : null, query: PostListQuery(
tags: isCategory ? null : [slug], categories: isCategory ? [slug] : null,
tags: isCategory ? null : [slug],
),
maxWidth: 540 + 16, maxWidth: 540 + 16,
), ),
SliverGap(MediaQuery.of(context).padding.bottom + 8), SliverGap(MediaQuery.of(context).padding.bottom + 8),

View File

@@ -3,102 +3,19 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart';
import 'package:island/pods/network.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_skeleton.dart';
import 'package:island/pods/paging.dart'; import 'package:island/widgets/posts/post_filter.dart';
import 'package:gap/gap.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
final postSearchProvider = AsyncNotifierProvider.autoDispose( const kSearchPostListId = 'search';
PostSearchNotifier.new,
);
class PostSearchNotifier extends AsyncNotifier<List<SnPost>>
with AsyncPaginationController<SnPost> {
static const int _pageSize = 20;
String _currentQuery = '';
String? _pubName;
String? _realm;
int? _type;
List<String>? _categories;
List<String>? _tags;
bool _shuffle = false;
bool? _pinned;
@override
FutureOr<List<SnPost>> build() async {
// Initial state is empty if no query/filters, or fetch if needed
// But original logic allowed initial empty state.
// Let's replicate original logic: return empty list initially if no query.
return [];
}
Future<void> search(
String query, {
String? pubName,
String? realm,
int? type,
List<String>? categories,
List<String>? tags,
bool shuffle = false,
bool? pinned,
}) async {
_currentQuery = query.trim();
_pubName = pubName;
_realm = realm;
_type = type;
_categories = categories;
_tags = tags;
_shuffle = shuffle;
_pinned = pinned;
final hasFilters =
pubName != null ||
realm != null ||
type != null ||
categories != null ||
tags != null ||
shuffle ||
pinned != null;
if (_currentQuery.isEmpty && !hasFilters) {
state = const AsyncData([]);
totalCount = null;
return;
}
await refresh();
}
@override
Future<List<SnPost>> fetch() async {
final client = ref.read(apiClientProvider);
final response = await client.get(
'/sphere/posts',
queryParameters: {
'query': _currentQuery,
'offset': fetchedCount,
'take': _pageSize,
'vector': false,
if (_pubName != null) 'pub': _pubName,
if (_realm != null) 'realm': _realm,
if (_type != null) 'type': _type,
if (_tags != null) 'tags': _tags,
if (_categories != null) 'categories': _categories,
if (_shuffle) 'shuffle': true,
if (_pinned != null) 'pinned': _pinned,
},
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final data = response.data as List;
return data.map((json) => SnPost.fromJson(json)).toList();
}
}
class PostSearchScreen extends HookConsumerWidget { class PostSearchScreen extends HookConsumerWidget {
const PostSearchScreen({super.key}); const PostSearchScreen({super.key});
@@ -111,11 +28,16 @@ class PostSearchScreen extends HookConsumerWidget {
final showFilters = useState(false); final showFilters = useState(false);
final pubNameController = useTextEditingController(); final pubNameController = useTextEditingController();
final realmController = useTextEditingController(); final realmController = useTextEditingController();
final typeValue = useState<int?>(null);
final selectedCategories = useState<List<String>>([]); // State variables for PostFilterWidget
final selectedTags = useState<List<String>>([]); final categoryTabController = useTabController(initialLength: 3);
final shuffleValue = useState(false);
final pinnedValue = useState<bool?>(null); // Single query state
final queryState = useState(const PostListQuery());
final noti = ref.read(
postListProvider(PostListQueryConfig(id: kSearchPostListId)).notifier,
);
useEffect(() { useEffect(() {
return () { return () {
@@ -126,225 +48,256 @@ class PostSearchScreen extends HookConsumerWidget {
}; };
}, []); }, []);
void onSearchChanged(String query) { void onSearchChanged(String query, {bool skipDebounce = false}) {
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel(); queryState.value = queryState.value.copyWith(queryTerm: query);
if (skipDebounce) {
noti.applyFilter(queryState.value);
return;
}
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
debounceTimer.value = Timer(debounce, () { debounceTimer.value = Timer(debounce, () {
ref.read(postSearchProvider.notifier).search(query); noti.applyFilter(queryState.value);
}); });
} }
void onSearchWithFilters(String query) { void toggleFilterDisplay() {
if (debounceTimer.value?.isActive ?? false) debounceTimer.value!.cancel();
debounceTimer.value = Timer(debounce, () {
ref
.read(postSearchProvider.notifier)
.search(
query,
pubName:
pubNameController.text.isNotEmpty
? pubNameController.text
: null,
realm:
realmController.text.isNotEmpty ? realmController.text : null,
type: typeValue.value,
categories:
selectedCategories.value.isNotEmpty
? selectedCategories.value
: null,
tags: selectedTags.value.isNotEmpty ? selectedTags.value : null,
shuffle: shuffleValue.value,
pinned: pinnedValue.value,
);
});
}
void toggleFilters() {
showFilters.value = !showFilters.value; showFilters.value = !showFilters.value;
} }
void applyFilters() {
onSearchWithFilters(searchController.text);
}
void clearFilters() {
pubNameController.clear();
realmController.clear();
typeValue.value = null;
selectedCategories.value = [];
selectedTags.value = [];
shuffleValue.value = false;
pinnedValue.value = null;
onSearchChanged(searchController.text);
}
Widget buildFilterPanel() { Widget buildFilterPanel() {
return Card( return PostFilterWidget(
margin: EdgeInsets.symmetric(vertical: 8, horizontal: 8), categoryTabController: categoryTabController,
child: Padding( initialQuery: queryState.value,
padding: EdgeInsets.all(16), onQueryChanged: (newQuery) {
child: Column( queryState.value = newQuery;
crossAxisAlignment: CrossAxisAlignment.start, noti.applyFilter(newQuery);
children: [ },
Row( hideSearch: true,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'filters'.tr(),
style: Theme.of(context).textTheme.titleMedium,
).padding(left: 4),
Row(
children: [
TextButton(
onPressed: applyFilters,
child: Text('apply'.tr()),
),
TextButton(
onPressed: clearFilters,
child: Text('clear'.tr()),
),
],
),
],
),
SizedBox(height: 16),
TextField(
controller: pubNameController,
decoration: InputDecoration(
labelText: 'pubName'.tr(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),
),
SizedBox(height: 8),
TextField(
controller: realmController,
decoration: InputDecoration(
labelText: 'realm'.tr(),
border: OutlineInputBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
),
onChanged:
(value) => onSearchWithFilters(searchController.text),
),
SizedBox(height: 8),
Row(
children: [
Checkbox(
value: shuffleValue.value,
onChanged: (value) {
shuffleValue.value = value ?? false;
onSearchWithFilters(searchController.text);
},
),
Text('shuffle'.tr()),
],
),
Row(
children: [
Checkbox(
value: pinnedValue.value ?? false,
onChanged: (value) {
pinnedValue.value = value;
onSearchWithFilters(searchController.text);
},
),
Text('pinned'.tr()),
],
),
],
),
),
); );
} }
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: AppBar( appBar: AppBar(
title: Row( title: Text('searchPosts'.tr()),
children: [ actions: [
Expanded( if (!isWideScreen(context))
child: TextField(
controller: searchController,
decoration: InputDecoration(
hintText: 'search'.tr(),
border: InputBorder.none,
hintStyle: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchWithFilters(value);
},
autofocus: true,
),
),
IconButton( IconButton(
icon: Icon( icon: Icon(
showFilters.value showFilters.value
? Icons.filter_alt ? Icons.filter_alt
: Icons.filter_alt_outlined, : Icons.filter_alt_outlined,
), ),
onPressed: toggleFilters, onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(), tooltip: 'toggleFilters'.tr(),
), ),
], ],
),
), ),
body: Consumer( body: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final searchState = ref.watch(postSearchProvider); final searchState = ref.watch(
postListProvider(PostListQueryConfig(id: kSearchPostListId)),
);
return CustomScrollView( return isWideScreen(context)
slivers: [ ? Row(
if (showFilters.value) children: [
SliverToBoxAdapter( Flexible(
child: Center( flex: 4,
child: ConstrainedBox( child: ExtendedRefreshIndicator(
constraints: const BoxConstraints(maxWidth: 600), onRefresh: noti.refresh,
child: buildFilterPanel(), child: CustomScrollView(
), slivers: [
), SliverGap(16),
), SliverToBoxAdapter(
// Use PaginationList with isSliver=true child: Padding(
PaginationList( padding: const EdgeInsets.symmetric(
provider: postSearchProvider, horizontal: 8,
notifier: postSearchProvider.notifier, ),
isSliver: true, child: SearchBar(
isRefreshable: elevation: WidgetStateProperty.all(4),
false, // CustomScrollView handles refreshing usually, but here we don't have PullToRefresh controller: searchController,
itemBuilder: (context, index, post) { hintText: 'search'.tr(),
return Center( leading: const Icon(Icons.search),
child: ConstrainedBox( padding: WidgetStateProperty.all(
constraints: BoxConstraints(maxWidth: 600), const EdgeInsets.symmetric(horizontal: 24),
child: Card( ),
margin: EdgeInsets.symmetric( onChanged: onSearchChanged,
horizontal: 8, onSubmitted: (value) {
vertical: 4, onSearchChanged(value, skipDebounce: true);
), },
child: PostActionableItem(item: post, borderRadius: 8), ),
),
),
const SliverGap(12),
PaginationList(
provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
),
notifier: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
).notifier,
isSliver: true,
isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) {
return Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
);
},
),
if (searchState.value?.isEmpty == true &&
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(
child: Text('noResultsFound'.tr()),
),
),
SliverGap(
MediaQuery.of(context).padding.bottom + 16,
),
],
).padding(left: 8),
), ),
), ),
); Flexible(
}, flex: 3,
), child: Align(
if (searchState.value?.isEmpty == true && alignment: Alignment.topLeft,
searchController.text.isNotEmpty && child: SingleChildScrollView(
!searchState.isLoading) child: Column(
SliverFillRemaining( crossAxisAlignment: CrossAxisAlignment.stretch,
child: Center(child: Text('noResultsFound'.tr())), children: [
), Gap(16),
], Card(
); margin: EdgeInsets.symmetric(horizontal: 8),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Row(
children: [
const Icon(
Symbols.tune,
).padding(horizontal: 8),
Expanded(
child: Text(
'filters'.tr(),
style: Theme.of(
context,
).textTheme.bodyLarge,
),
),
IconButton(
icon: Icon(
Symbols.filter_alt,
fill: showFilters.value ? 1 : null,
),
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
),
const Gap(4),
],
),
),
),
const Gap(8),
if (showFilters.value) buildFilterPanel(),
],
),
),
),
),
],
)
: CustomScrollView(
slivers: [
const SliverGap(4),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: SearchBar(
elevation: WidgetStateProperty.all(4),
controller: searchController,
hintText: 'search'.tr(),
leading: const Icon(Icons.search),
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24),
),
onChanged: onSearchChanged,
onSubmitted: (value) {
onSearchChanged(value, skipDebounce: true);
},
),
),
),
if (showFilters.value)
SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: buildFilterPanel(),
),
),
),
PaginationList(
provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
),
notifier: postListProvider(
PostListQueryConfig(id: kSearchPostListId),
).notifier,
isSliver: true,
isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 600),
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: PostActionableItem(
item: post,
borderRadius: 8,
),
),
),
);
},
),
if (searchState.value?.isEmpty == true &&
searchController.text.isNotEmpty &&
!searchState.isLoading)
SliverFillRemaining(
child: Center(child: Text('noResultsFound'.tr())),
),
],
);
}, },
), ),
); );

View File

@@ -11,6 +11,7 @@ import 'package:island/models/account.dart';
import 'package:island/models/heatmap.dart'; import 'package:island/models/heatmap.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/services/color.dart'; import 'package:island/services/color.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:island/widgets/account/account_name.dart'; import 'package:island/widgets/account/account_name.dart';
@@ -22,6 +23,7 @@ import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/markdown.dart'; import 'package:island/widgets/content/markdown.dart';
import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/post/post_list.dart';
import 'package:island/widgets/activity_heatmap.dart'; import 'package:island/widgets/activity_heatmap.dart';
import 'package:island/widgets/posts/post_filter.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:island/services/color_extraction.dart'; import 'package:island/services/color_extraction.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -82,8 +84,9 @@ class _PublisherBasisWidget extends StatelessWidget {
size: 12, size: 12,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
), ),
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.primary, context,
).colorScheme.primary,
offset: Offset(0, 48), offset: Offset(0, 48),
child: ProfilePictureWidget( child: ProfilePictureWidget(
file: data.picture, file: data.picture,
@@ -121,8 +124,9 @@ class _PublisherBasisWidget extends StatelessWidget {
size: 16, size: 16,
color: Theme.of(context).colorScheme.onPrimary, color: Theme.of(context).colorScheme.onPrimary,
), ),
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.primary, context,
).colorScheme.primary,
offset: Offset(0, 48), offset: Offset(0, 48),
child: ProfilePictureWidget( child: ProfilePictureWidget(
file: data.picture, file: data.picture,
@@ -201,45 +205,41 @@ class _PublisherBasisWidget extends StatelessWidget {
), ),
subStatus subStatus
.when( .when(
data: data: (status) => FilledButton.icon(
(status) => FilledButton.icon( onPressed: subscribing.value
onPressed: ? null
subscribing.value : (status.isSubscribed
? null ? unsubscribe
: (status.isSubscribed : subscribe),
? unsubscribe icon: Icon(
: subscribe), status.isSubscribed
icon: Icon( ? Symbols.remove_circle
status.isSubscribed : Symbols.add_circle,
? Symbols.remove_circle ),
: Symbols.add_circle, label: Text(
), status.isSubscribed
label: ? 'unsubscribe'
Text( : 'subscribe',
status.isSubscribed ).tr(),
? 'unsubscribe' style: ButtonStyle(
: 'subscribe', visualDensity: VisualDensity(
).tr(), vertical: -2,
style: ButtonStyle(
visualDensity: VisualDensity(
vertical: -2,
),
),
), ),
),
),
error: (_, _) => const SizedBox(), error: (_, _) => const SizedBox(),
loading: loading: () => const SizedBox(
() => const SizedBox( height: 36,
height: 36, child: Center(
child: Center( child: SizedBox(
child: SizedBox( width: 20,
width: 20, height: 20,
height: 20, child: CircularProgressIndicator(
child: CircularProgressIndicator( strokeWidth: 2,
strokeWidth: 2,
),
),
), ),
), ),
),
),
) )
.padding(vertical: 12), .padding(vertical: 12),
], ],
@@ -271,10 +271,10 @@ class _PublisherBadgesWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return (badges.value?.isNotEmpty ?? false) return (badges.value?.isNotEmpty ?? false)
? Card( ? Card(
child: BadgeList( child: BadgeList(
badges: badges.value!, badges: badges.value!,
).padding(horizontal: 26, vertical: 20), ).padding(horizontal: 26, vertical: 20),
).padding(horizontal: 4) ).padding(horizontal: 4)
: const SizedBox.shrink(); : const SizedBox.shrink();
} }
} }
@@ -288,9 +288,9 @@ class _PublisherVerificationWidget extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return (data.verification != null) return (data.verification != null)
? Card( ? Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: VerificationStatusCard(mark: data.verification!), child: VerificationStatusCard(mark: data.verification!),
) )
: const SizedBox.shrink(); : const SizedBox.shrink();
} }
} }
@@ -333,285 +333,18 @@ class _PublisherHeatmapWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return heatmap.when( return heatmap.when(
data: data: (data) => data != null
(data) => ? ActivityHeatmapWidget(
data != null heatmap: data,
? ActivityHeatmapWidget( forceDense: forceDense,
heatmap: data, ).padding(horizontal: 8)
forceDense: forceDense, : const SizedBox.shrink(),
).padding(horizontal: 8)
: const SizedBox.shrink(),
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(),
); );
} }
} }
class _PublisherCategoryTabWidget extends StatelessWidget {
final TabController categoryTabController;
final ValueNotifier<bool?> includeReplies;
final ValueNotifier<bool> mediaOnly;
final ValueNotifier<String?> queryTerm;
final ValueNotifier<String?> order;
final ValueNotifier<bool> orderDesc;
final ValueNotifier<int?> periodStart;
final ValueNotifier<int?> periodEnd;
final ValueNotifier<bool> showAdvancedFilters;
const _PublisherCategoryTabWidget({
required this.categoryTabController,
required this.includeReplies,
required this.mediaOnly,
required this.queryTerm,
required this.order,
required this.orderDesc,
required this.periodStart,
required this.periodEnd,
required this.showAdvancedFilters,
});
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
children: [
TabBar(
controller: categoryTabController,
dividerColor: Colors.transparent,
splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
tabs: [
Tab(text: 'all'.tr()),
Tab(text: 'postTypePost'.tr()),
Tab(text: 'postArticle'.tr()),
],
),
const Divider(height: 1),
Column(
children: [
Row(
children: [
Expanded(
child: CheckboxListTile(
title: Text('reply'.tr()),
value: includeReplies.value,
tristate: true,
onChanged: (value) {
// Cycle through: null -> false -> true -> null
if (includeReplies.value == null) {
includeReplies.value = false;
} else if (includeReplies.value == false) {
includeReplies.value = true;
} else {
includeReplies.value = null;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.reply),
),
),
Expanded(
child: CheckboxListTile(
title: Text('attachments'.tr()),
value: mediaOnly.value,
onChanged: (value) {
if (value != null) {
mediaOnly.value = value;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.attachment),
),
),
],
),
CheckboxListTile(
title: Text('descendingOrder'.tr()),
value: orderDesc.value,
onChanged: (value) {
if (value != null) {
orderDesc.value = value;
}
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.sort),
),
],
),
const Divider(height: 1),
ListTile(
title: Text('advancedFilters'.tr()),
leading: const Icon(Symbols.filter_list),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(const Radius.circular(8)),
),
trailing: Icon(
showAdvancedFilters.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap: () {
showAdvancedFilters.value = !showAdvancedFilters.value;
},
),
if (showAdvancedFilters.value) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(
labelText: 'search'.tr(),
hintText: 'searchPosts'.tr(),
prefixIcon: const Icon(Symbols.search),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onChanged: (value) {
queryTerm.value = value.isEmpty ? null : value;
},
),
const Gap(12),
DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: 'sortBy'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
value: order.value,
items: [
DropdownMenuItem(value: 'date', child: Text('date'.tr())),
DropdownMenuItem(
value: 'popularity',
child: Text('popularity'.tr()),
),
],
onChanged: (value) {
order.value = value;
},
),
const Gap(12),
Row(
children: [
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate:
periodStart.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
periodStart.value =
pickedDate.millisecondsSinceEpoch ~/ 1000;
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'fromDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodStart.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodStart.value! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
const Gap(8),
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate:
periodEnd.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
periodEnd.value =
pickedDate.millisecondsSinceEpoch ~/ 1000;
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'toDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
periodEnd.value != null
? DateTime.fromMillisecondsSinceEpoch(
periodEnd.value! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
],
),
],
),
),
],
],
),
);
}
}
@riverpod @riverpod
Future<SnPublisher> publisher(Ref ref, String uname) async { Future<SnPublisher> publisher(Ref ref, String uname) async {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
@@ -683,24 +416,22 @@ class PublisherProfileScreen extends HookConsumerWidget {
); );
final categoryTabController = useTabController(initialLength: 3); final categoryTabController = useTabController(initialLength: 3);
final categoryTab = useState(0);
categoryTabController.addListener(() {
categoryTab.value = categoryTabController.index;
});
final includeReplies = useState<bool?>(null); final queryState = useState(PostListQuery(pubName: name));
final mediaOnly = useState(false);
final queryTerm = useState<String?>(null);
final order = useState<String?>('date'); // 'popularity' or 'date'
final orderDesc = useState(
true,
); // true for descending, false for ascending
final periodStart = useState<int?>(null);
final periodEnd = useState<int?>(null);
final showAdvancedFilters = useState(false);
final subscribing = useState(false); final subscribing = useState(false);
final isPinnedExpanded = useState(true); final isPinnedExpanded = useState(true);
useEffect(() {
final index = switch (queryState.value.type) {
0 => 1,
1 => 2,
_ => 0,
};
categoryTabController.index = index;
return null;
}, []);
Future<void> subscribe() async { Future<void> subscribe() async {
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
subscribing.value = true; subscribing.value = true;
@@ -739,205 +470,34 @@ class PublisherProfileScreen extends HookConsumerWidget {
); );
return publisher.when( return publisher.when(
data: data: (data) => AppScaffold(
(data) => AppScaffold( isNoBackground: false,
isNoBackground: false, appBar: isWideScreen(context)
appBar: ? AppBar(
isWideScreen(context) foregroundColor: appbarColor.value,
? AppBar( leading: PageBackButton(
foregroundColor: appbarColor.value, color: appbarColor.value,
leading: PageBackButton( shadows: [appbarShadow],
color: appbarColor.value, ),
shadows: [appbarShadow], title: Text(
), data.nick,
title: Text( style: TextStyle(
data.nick, color:
style: TextStyle( appbarColor.value ??
color: Theme.of(context).appBarTheme.foregroundColor,
appbarColor.value ?? shadows: [appbarShadow],
Theme.of(context).appBarTheme.foregroundColor, ),
shadows: [appbarShadow], ),
), )
), : null,
) body: isWideScreen(context)
: null, ? Row(
body: children: [
isWideScreen(context) Flexible(
? Row( flex: 4,
children: [ child: CustomScrollView(
Flexible(
flex: 4,
child: CustomScrollView(
slivers: [
SliverGap(16),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: ListTile(
title: Text('pinnedPosts'.tr()),
leading: const Icon(Symbols.push_pin),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap:
() =>
isPinnedExpanded.value =
!isPinnedExpanded.value,
),
),
),
...[
if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true),
],
SliverToBoxAdapter(
child: _PublisherCategoryTabWidget(
categoryTabController: categoryTabController,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
),
),
SliverPostList(
key: ValueKey(
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}',
),
pubName: name,
pinned: false,
type:
categoryTab.value == 1
? 0
: (categoryTab.value == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
),
SliverGap(
MediaQuery.of(context).padding.bottom + 16,
),
],
).padding(left: 8),
),
Flexible(
flex: 3,
child: Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(horizontal: 4, top: 20),
_PublisherBadgesWidget(
data: data,
badges: badges,
),
_PublisherVerificationWidget(data: data),
_PublisherBioWidget(data: data),
_PublisherHeatmapWidget(
heatmap: heatmap,
forceDense: true,
).padding(vertical: 4),
],
),
),
),
),
],
)
: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverGap(16),
foregroundColor: appbarColor.value,
expandedHeight: 180,
pinned: true,
leading: PageBackButton(
color: appbarColor.value,
shadows: [appbarShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
data.background?.id != null
? CloudImageWidget(
file: data.background,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
),
SliverToBoxAdapter(
child: _PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(horizontal: 4, top: 8),
),
SliverToBoxAdapter(
child: _PublisherBadgesWidget(
data: data,
badges: badges,
),
),
SliverToBoxAdapter(
child: _PublisherVerificationWidget(data: data),
),
SliverToBoxAdapter(
child: _PublisherBioWidget(data: data),
),
SliverToBoxAdapter(
child: _PublisherHeatmapWidget(
heatmap: heatmap,
).padding(vertical: 4),
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Card( child: Card(
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
@@ -946,69 +506,180 @@ class PublisherProfileScreen extends HookConsumerWidget {
), ),
child: ListTile( child: ListTile(
title: Text('pinnedPosts'.tr()), title: Text('pinnedPosts'.tr()),
leading: const Icon(Symbols.push_pin),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
trailing: Icon( trailing: Icon(
isPinnedExpanded.value isPinnedExpanded.value
? Symbols.expand_less ? Symbols.expand_less
: Symbols.expand_more, : Symbols.expand_more,
), ),
onTap: onTap: () => isPinnedExpanded.value =
() => !isPinnedExpanded.value,
isPinnedExpanded.value =
!isPinnedExpanded.value,
), ),
), ),
), ),
...[ ...[
if (isPinnedExpanded.value) if (isPinnedExpanded.value)
SliverPostList(pubName: name, pinned: true), SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
], ],
SliverToBoxAdapter( SliverToBoxAdapter(
child: _PublisherCategoryTabWidget( child: PostFilterWidget(
categoryTabController: categoryTabController, categoryTabController: categoryTabController,
includeReplies: includeReplies, initialQuery: queryState.value,
mediaOnly: mediaOnly, onQueryChanged: (newQuery) =>
queryTerm: queryTerm, queryState.value = newQuery,
order: order,
orderDesc: orderDesc,
periodStart: periodStart,
periodEnd: periodEnd,
showAdvancedFilters: showAdvancedFilters,
), ),
), ),
SliverPostList( SliverPostList(
key: ValueKey( query: queryState.value,
'${categoryTab.value}-${includeReplies.value}-${mediaOnly.value}-${queryTerm.value}-${order.value}-${orderDesc.value}-${periodStart.value}-${periodEnd.value}', queryKey: 'publisher-$name',
),
pubName: name,
pinned: false,
type:
categoryTab.value == 1
? 0
: (categoryTab.value == 2 ? 1 : null),
includeReplies: includeReplies.value,
mediaOnly: mediaOnly.value,
queryTerm: queryTerm.value,
order: order.value,
orderDesc: orderDesc.value,
periodStart: periodStart.value,
periodEnd: periodEnd.value,
), ),
SliverGap(MediaQuery.of(context).padding.bottom + 16), SliverGap(MediaQuery.of(context).padding.bottom + 16),
], ],
).padding(left: 8),
),
Flexible(
flex: 3,
child: Align(
alignment: Alignment.topLeft,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(horizontal: 4, top: 20),
_PublisherBadgesWidget(data: data, badges: badges),
_PublisherVerificationWidget(data: data),
_PublisherBioWidget(data: data),
_PublisherHeatmapWidget(
heatmap: heatmap,
forceDense: true,
).padding(vertical: 4),
],
),
),
), ),
), ),
error: ],
(error, stackTrace) => AppScaffold( )
isNoBackground: false, : CustomScrollView(
appBar: AppBar(leading: const PageBackButton()), slivers: [
body: Center(child: Text(error.toString())), SliverAppBar(
), foregroundColor: appbarColor.value,
loading: expandedHeight: 180,
() => AppScaffold( pinned: true,
isNoBackground: false, leading: PageBackButton(
appBar: AppBar(leading: const PageBackButton()), color: appbarColor.value,
body: Center(child: CircularProgressIndicator()), shadows: [appbarShadow],
), ),
flexibleSpace: Stack(
children: [
Positioned.fill(
child: data.background?.id != null
? CloudImageWidget(file: data.background)
: Container(
color: Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
data.nick,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [appbarShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
),
SliverToBoxAdapter(
child: _PublisherBasisWidget(
data: data,
subStatus: subStatus,
subscribing: subscribing,
subscribe: subscribe,
unsubscribe: unsubscribe,
).padding(horizontal: 4, top: 8),
),
SliverToBoxAdapter(
child: _PublisherBadgesWidget(data: data, badges: badges),
),
SliverToBoxAdapter(
child: _PublisherVerificationWidget(data: data),
),
SliverToBoxAdapter(child: _PublisherBioWidget(data: data)),
SliverToBoxAdapter(
child: _PublisherHeatmapWidget(
heatmap: heatmap,
).padding(vertical: 4),
),
SliverToBoxAdapter(
child: Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ListTile(
title: Text('pinnedPosts'.tr()),
trailing: Icon(
isPinnedExpanded.value
? Symbols.expand_less
: Symbols.expand_more,
),
onTap: () =>
isPinnedExpanded.value = !isPinnedExpanded.value,
),
),
),
...[
if (isPinnedExpanded.value)
SliverPostList(
query: PostListQuery(pubName: name, pinned: true),
queryKey: 'publisher-$name-pinned',
),
],
SliverToBoxAdapter(
child: PostFilterWidget(
categoryTabController: categoryTabController,
initialQuery: queryState.value,
onQueryChanged: (newQuery) => queryState.value = newQuery,
),
),
SliverPostList(
key: ValueKey(queryState.value),
query: queryState.value,
queryKey: 'publisher-$name',
),
SliverGap(MediaQuery.of(context).padding.bottom + 16),
],
),
),
error: (error, stackTrace) => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: Text(error.toString())),
),
loading: () => AppScaffold(
isNoBackground: false,
appBar: AppBar(leading: const PageBackButton()),
body: Center(child: CircularProgressIndicator()),
),
); );
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/screens/chat/chat.dart'; import 'package:island/screens/chat/chat.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:island/models/chat.dart'; import 'package:island/models/chat.dart';
@@ -171,204 +172,180 @@ class RealmDetailScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: appBar: isWideScreen(context)
isWideScreen(context) ? realmState.when(
? realmState.when( data: (realm) => AppBar(
data: foregroundColor: appbarColor.value,
(realm) => AppBar( leading: PageBackButton(
foregroundColor: appbarColor.value, color: appbarColor.value,
leading: PageBackButton( shadows: [iconShadow],
color: appbarColor.value, ),
shadows: [iconShadow], flexibleSpace: Stack(
), children: [
flexibleSpace: Stack( Positioned.fill(
children: [ child: realm!.background?.id != null
Positioned.fill( ? CloudImageWidget(fileId: realm.background!.id)
child: : Container(
realm!.background?.id != null color: Theme.of(
? CloudImageWidget( context,
fileId: realm.background!.id, ).appBarTheme.backgroundColor,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
), ),
background: Container(),
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(
realmSlug: slug,
iconShadow: iconShadow,
),
const Gap(8),
],
), ),
error: (_, _) => AppBar(leading: PageBackButton()), FlexibleSpaceBar(
loading: () => AppBar(leading: PageBackButton()), title: Text(
) realm.name,
: null, style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
background: Container(),
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
error: (_, _) => AppBar(leading: PageBackButton()),
loading: () => AppBar(leading: PageBackButton()),
)
: null,
body: realmState.when( body: realmState.when(
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')), error: (error, _) => Center(child: Text('Error: $error')),
data: data: (realm) => isWideScreen(context)
(realm) => ? Row(
isWideScreen(context) children: [
? Row( Flexible(
children: [ flex: 3,
Flexible( child: CustomScrollView(
flex: 3,
child: CustomScrollView(
slivers: [
SliverPostList(realm: slug, pinned: true),
SliverPostList(realm: slug, pinned: false),
],
),
),
Flexible(
flex: 2,
child: Column(
children: [
realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm!),
if (identity == null &&
realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
realmChatRoomListWidget(realm!),
],
),
),
],
).padding(horizontal: 8, top: 8)
: CustomScrollView(
slivers: [ slivers: [
SliverAppBar( SliverPostList(
expandedHeight: 180, query: PostListQuery(realm: slug, pinned: true),
pinned: true,
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child:
realm!.background?.id != null
? CloudImageWidget(
fileId: realm.background!.id,
)
: Container(
color:
Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(
context,
).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder:
(context) => _RealmMemberListSheet(
realmSlug: slug,
),
);
},
),
_RealmActionMenu(
realmSlug: slug,
iconShadow: iconShadow,
),
const Gap(8),
],
), ),
SliverGap(4), SliverPostList(
SliverToBoxAdapter( query: PostListQuery(realm: slug, pinned: false),
child: realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data:
(identity) => Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm),
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
), ),
SliverToBoxAdapter(
child: realmChatRoomListWidget(realm),
),
SliverPostList(realm: slug, pinned: true),
SliverPostList(realm: slug, pinned: false),
], ],
), ),
),
Flexible(
flex: 2,
child: Column(
children: [
realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm!),
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
realmChatRoomListWidget(realm!),
],
),
),
],
).padding(horizontal: 8, top: 8)
: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 180,
pinned: true,
foregroundColor: appbarColor.value,
leading: PageBackButton(
color: appbarColor.value,
shadows: [iconShadow],
),
flexibleSpace: Stack(
children: [
Positioned.fill(
child: realm!.background?.id != null
? CloudImageWidget(fileId: realm.background!.id)
: Container(
color: Theme.of(
context,
).appBarTheme.backgroundColor,
),
),
FlexibleSpaceBar(
title: Text(
realm.name,
style: TextStyle(
color:
appbarColor.value ??
Theme.of(context).appBarTheme.foregroundColor,
shadows: [iconShadow],
),
),
background:
Container(), // Empty container since background is handled by Stack
),
],
),
actions: [
IconButton(
icon: Icon(Icons.people, shadows: [iconShadow]),
onPressed: () {
showModalBottomSheet(
isScrollControlled: true,
context: context,
builder: (context) =>
_RealmMemberListSheet(realmSlug: slug),
);
},
),
_RealmActionMenu(realmSlug: slug, iconShadow: iconShadow),
const Gap(8),
],
),
SliverGap(4),
SliverToBoxAdapter(
child: realmIdentity.when(
loading: () => const SizedBox.shrink(),
error: (_, _) => const SizedBox.shrink(),
data: (identity) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
realmDescriptionWidget(realm),
if (identity == null && realm.isCommunity)
realmActionWidget(realm)
else
const SizedBox.shrink(),
],
),
),
),
SliverToBoxAdapter(child: realmChatRoomListWidget(realm)),
SliverPostList(
query: PostListQuery(realm: slug, pinned: true),
),
SliverPostList(
query: PostListQuery(realm: slug, pinned: false),
),
],
),
), ),
); );
} }
@@ -391,135 +368,125 @@ class _RealmActionMenu extends HookConsumerWidget {
return PopupMenuButton( return PopupMenuButton(
icon: Icon(Icons.more_vert, shadows: [iconShadow]), icon: Icon(Icons.more_vert, shadows: [iconShadow]),
itemBuilder: itemBuilder: (context) => [
(context) => [ if (isModerator)
if (isModerator) PopupMenuItem(
PopupMenuItem( onTap: () {
onTap: () { context.pushReplacementNamed(
context.pushReplacementNamed( 'realmEdit',
'realmEdit', pathParameters: {'slug': realmSlug},
pathParameters: {'slug': realmSlug}, );
); },
}, child: Row(
child: Row( children: [
children: [ Icon(
Icon( Icons.edit,
Icons.edit, color: Theme.of(context).colorScheme.onSecondaryContainer,
color: Theme.of(context).colorScheme.onSecondaryContainer,
),
const Gap(12),
const Text('editRealm').tr(),
],
), ),
), const Gap(12),
realmIdentity.when( const Text('editRealm').tr(),
data: ],
(identity) => ),
(identity?.role ?? 0) >= 100 ),
? PopupMenuItem( realmIdentity.when(
child: Row( data: (identity) => (identity?.role ?? 0) >= 100
children: [ ? PopupMenuItem(
const Icon(Icons.delete, color: Colors.red), child: Row(
const Gap(12), children: [
const Text( const Icon(Icons.delete, color: Colors.red),
'deleteRealm', const Gap(12),
style: TextStyle(color: Colors.red), const Text(
).tr(), 'deleteRealm',
], style: TextStyle(color: Colors.red),
), ).tr(),
onTap: () { ],
showConfirmAlert(
'deleteRealmHint'.tr(),
'deleteRealm'.tr(),
isDanger: true,
).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/pass/realms/$realmSlug');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
)
: PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
),
loading:
() => const PopupMenuItem(
enabled: false,
child: Center(child: CircularProgressIndicator()),
), ),
error: onTap: () {
(_, _) => PopupMenuItem( showConfirmAlert(
child: Row( 'deleteRealmHint'.tr(),
children: [ 'deleteRealm'.tr(),
Icon( isDanger: true,
Icons.exit_to_app, ).then((confirm) {
if (confirm) {
final client = ref.watch(apiClientProvider);
client.delete('/pass/realms/$realmSlug');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
)
: PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
), ),
const Gap(12), ).tr(),
Text( ],
'leaveRealm',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
).tr(),
],
),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
), ),
onTap: () {
showConfirmAlert(
'leaveRealmHint'.tr(),
'leaveRealm'.tr(),
).then((confirm) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete(
'/pass/realms/$realmSlug/members/me',
);
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
),
loading: () => const PopupMenuItem(
enabled: false,
child: Center(child: CircularProgressIndicator()),
),
error: (_, _) => PopupMenuItem(
child: Row(
children: [
Icon(
Icons.exit_to_app,
color: Theme.of(context).colorScheme.error,
),
const Gap(12),
Text(
'leaveRealm',
style: TextStyle(color: Theme.of(context).colorScheme.error),
).tr(),
],
), ),
], onTap: () {
showConfirmAlert('leaveRealmHint'.tr(), 'leaveRealm'.tr()).then((
confirm,
) async {
if (confirm) {
final client = ref.watch(apiClientProvider);
await client.delete('/pass/realms/$realmSlug/members/me');
ref.invalidate(realmsJoinedProvider);
if (context.mounted) {
context.pop(true);
}
}
});
},
),
),
],
); );
} }
} }
@@ -684,11 +651,10 @@ class _RealmMemberListSheet extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
isScrollControlled: true, isScrollControlled: true,
context: context, context: context,
builder: builder: (context) => _RealmMemberRoleSheet(
(context) => _RealmMemberRoleSheet( realmSlug: realmSlug,
realmSlug: realmSlug, member: member,
member: member, ),
),
).then((value) { ).then((value) {
if (value != null) { if (value != null) {
// Refresh the provider // Refresh the provider
@@ -809,23 +775,19 @@ class _RealmMemberRoleSheet extends HookConsumerWidget {
onSelected: (int selection) { onSelected: (int selection) {
roleController.text = selection.toString(); roleController.text = selection.toString();
}, },
fieldViewBuilder: ( fieldViewBuilder:
context, (context, controller, focusNode, onFieldSubmitted) {
controller, return TextField(
focusNode, controller: controller,
onFieldSubmitted, focusNode: focusNode,
) { keyboardType: TextInputType.number,
return TextField( decoration: InputDecoration(
controller: controller, labelText: 'memberRole'.tr(),
focusNode: focusNode, helperText: 'memberRoleHint'.tr(),
keyboardType: TextInputType.number, ),
decoration: InputDecoration( onTapOutside: (event) => focusNode.unfocus(),
labelText: 'memberRole'.tr(), );
helperText: 'memberRoleHint'.tr(), },
),
onTapOutside: (event) => focusNode.unfocus(),
);
},
), ),
const Gap(16), const Gap(16),
FilledButton.icon( FilledButton.icon(

View File

@@ -21,7 +21,7 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:island/pods/config.dart'; import 'package:island/pods/config.dart';
import 'package:island/pods/file_pool.dart'; import 'package:island/pods/drive/file_pool.dart';
class SettingsScreen extends HookConsumerWidget { class SettingsScreen extends HookConsumerWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -249,10 +249,9 @@ class SettingsScreen extends HookConsumerWidget {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
Color selectedColor = Color selectedColor = settings.appColorScheme != null
settings.appColorScheme != null ? Color(settings.appColorScheme!)
? Color(settings.appColorScheme!) : Colors.indigo;
: Colors.indigo;
return AlertDialog( return AlertDialog(
title: Text('Seed Color').tr(), title: Text('Seed Color').tr(),
@@ -292,10 +291,9 @@ class SettingsScreen extends HookConsumerWidget {
height: 24, height: 24,
margin: EdgeInsets.symmetric(horizontal: 2, vertical: 8), margin: EdgeInsets.symmetric(horizontal: 2, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: settings.appColorScheme != null
settings.appColorScheme != null ? Color(settings.appColorScheme!)
? Color(settings.appColorScheme!) : Colors.indigo,
: Colors.indigo,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all( border: Border.all(
color: Theme.of( color: Theme.of(
@@ -310,19 +308,17 @@ class SettingsScreen extends HookConsumerWidget {
// Custom colors section // Custom colors section
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: child: Text(
Text( 'Custom Colors',
'Custom Colors', style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium, ).bold(),
).bold(),
), ),
// Primary color // Primary color
_ColorPickerTile( _ColorPickerTile(
title: 'Primary', title: 'Primary',
color: color: settings.customColors?.primary != null
settings.customColors?.primary != null ? Color(settings.customColors!.primary!)
? Color(settings.customColors!.primary!) : null,
: null,
onColorChanged: (color) { onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors(); final current = settings.customColors ?? ThemeColors();
ref ref
@@ -333,10 +329,9 @@ class SettingsScreen extends HookConsumerWidget {
// Secondary // Secondary
_ColorPickerTile( _ColorPickerTile(
title: 'Secondary', title: 'Secondary',
color: color: settings.customColors?.secondary != null
settings.customColors?.secondary != null ? Color(settings.customColors!.secondary!)
? Color(settings.customColors!.secondary!) : null,
: null,
onColorChanged: (color) { onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors(); final current = settings.customColors ?? ThemeColors();
ref ref
@@ -347,10 +342,9 @@ class SettingsScreen extends HookConsumerWidget {
// Tertiary // Tertiary
_ColorPickerTile( _ColorPickerTile(
title: 'Tertiary', title: 'Tertiary',
color: color: settings.customColors?.tertiary != null
settings.customColors?.tertiary != null ? Color(settings.customColors!.tertiary!)
? Color(settings.customColors!.tertiary!) : null,
: null,
onColorChanged: (color) { onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors(); final current = settings.customColors ?? ThemeColors();
ref ref
@@ -361,10 +355,9 @@ class SettingsScreen extends HookConsumerWidget {
// Surface // Surface
_ColorPickerTile( _ColorPickerTile(
title: 'Surface', title: 'Surface',
color: color: settings.customColors?.surface != null
settings.customColors?.surface != null ? Color(settings.customColors!.surface!)
? Color(settings.customColors!.surface!) : null,
: null,
onColorChanged: (color) { onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors(); final current = settings.customColors ?? ThemeColors();
ref ref
@@ -375,10 +368,9 @@ class SettingsScreen extends HookConsumerWidget {
// Background // Background
_ColorPickerTile( _ColorPickerTile(
title: 'Background', title: 'Background',
color: color: settings.customColors?.background != null
settings.customColors?.background != null ? Color(settings.customColors!.background!)
? Color(settings.customColors!.background!) : null,
: null,
onColorChanged: (color) { onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors(); final current = settings.customColors ?? ThemeColors();
ref ref
@@ -391,10 +383,9 @@ class SettingsScreen extends HookConsumerWidget {
// Error // Error
_ColorPickerTile( _ColorPickerTile(
title: 'Error', title: 'Error',
color: color: settings.customColors?.error != null
settings.customColors?.error != null ? Color(settings.customColors!.error!)
? Color(settings.customColors!.error!) : null,
: null,
onColorChanged: (color) { onColorChanged: (color) {
final current = settings.customColors ?? ThemeColors(); final current = settings.customColors ?? ThemeColors();
ref ref
@@ -509,8 +500,9 @@ class SettingsScreen extends HookConsumerWidget {
// Background image enabled // Background image enabled
if (!kIsWeb && docBasepath.value != null) if (!kIsWeb && docBasepath.value != null)
FutureBuilder<bool>( FutureBuilder<bool>(
future: future: File(
File('${docBasepath.value}/$kAppBackgroundImagePath').exists(), '${docBasepath.value}/$kAppBackgroundImagePath',
).exists(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) { if (!snapshot.hasData || !snapshot.data!) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -536,8 +528,9 @@ class SettingsScreen extends HookConsumerWidget {
// Clear background image option // Clear background image option
if (!kIsWeb && docBasepath.value != null) if (!kIsWeb && docBasepath.value != null)
FutureBuilder<bool>( FutureBuilder<bool>(
future: future: File(
File('${docBasepath.value}/$kAppBackgroundImagePath').exists(), '${docBasepath.value}/$kAppBackgroundImagePath',
).exists(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) { if (!snapshot.hasData || !snapshot.data!) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -565,8 +558,9 @@ class SettingsScreen extends HookConsumerWidget {
if (!kIsWeb && docBasepath.value != null) if (!kIsWeb && docBasepath.value != null)
FutureBuilder( FutureBuilder(
future: future: File(
File('${docBasepath.value}/$kAppBackgroundImagePath').exists(), '${docBasepath.value}/$kAppBackgroundImagePath',
).exists(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (!snapshot.hasData || !snapshot.data!) { if (!snapshot.hasData || !snapshot.data!) {
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -598,8 +592,8 @@ class SettingsScreen extends HookConsumerWidget {
); );
final color = final color =
MediaQuery.of(context).platformBrightness == Brightness.dark MediaQuery.of(context).platformBrightness == Brightness.dark
? colorScheme.primary ? colorScheme.primary
: colorScheme.primary; : colorScheme.primary;
ref ref
.read(appSettingsProvider.notifier) .read(appSettingsProvider.notifier)
.setAppColorScheme(color.value); .setAppColorScheme(color.value);
@@ -674,20 +668,19 @@ class SettingsScreen extends HookConsumerWidget {
trailing: DropdownButtonHideUnderline( trailing: DropdownButtonHideUnderline(
child: DropdownButton2<String>( child: DropdownButton2<String>(
isExpanded: true, isExpanded: true,
items: items: validPools.map((p) {
validPools.map((p) { return DropdownMenuItem<String>(
return DropdownMenuItem<String>( value: p.id,
value: p.id, child: Tooltip(
child: Tooltip( message: p.name,
message: p.name, child: Text(
child: Text( p.name,
p.name, maxLines: 1,
maxLines: 1, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, ).fontSize(14),
).fontSize(14), ),
), );
); }).toList(),
}).toList(),
value: currentPoolId, value: currentPoolId,
onChanged: (value) { onChanged: (value) {
ref ref
@@ -705,19 +698,17 @@ class SettingsScreen extends HookConsumerWidget {
), ),
); );
}, },
loading: loading: () => const ListTile(
() => const ListTile( minLeadingWidth: 48,
minLeadingWidth: 48, title: Text('Loading pools...'),
title: Text('Loading pools...'), leading: CircularProgressIndicator(),
leading: CircularProgressIndicator(), ),
), error: (err, st) => ListTile(
error: minLeadingWidth: 48,
(err, st) => ListTile( title: Text('settingsDefaultPool').tr(),
minLeadingWidth: 48, subtitle: Text('Error: $err'),
title: Text('settingsDefaultPool').tr(), leading: const Icon(Icons.error, color: Colors.red),
subtitle: Text('Error: $err'), ),
leading: const Icon(Icons.error, color: Colors.red),
),
), ),
]; ];
@@ -767,10 +758,9 @@ class SettingsScreen extends HookConsumerWidget {
ListTile( ListTile(
minLeadingWidth: 48, minLeadingWidth: 48,
title: Text('settingsEnterToSend').tr(), title: Text('settingsEnterToSend').tr(),
subtitle: subtitle: isDesktop
isDesktop ? Text('settingsEnterToSendDesktopHint').tr().fontSize(12)
? Text('settingsEnterToSendDesktopHint').tr().fontSize(12) : null,
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 17), contentPadding: const EdgeInsets.only(left: 24, right: 17),
leading: const Icon(Symbols.send), leading: const Icon(Symbols.send),
trailing: Switch( trailing: Switch(
@@ -823,33 +813,32 @@ class SettingsScreen extends HookConsumerWidget {
]; ];
// Desktop-specific settings // Desktop-specific settings
final desktopSettings = final desktopSettings = !isDesktop
!isDesktop ? <Widget>[]
? <Widget>[] : [
: [ ListTile(
ListTile( minLeadingWidth: 48,
minLeadingWidth: 48, title: Text('settingsWindowOpacity').tr(),
title: Text('settingsWindowOpacity').tr(), contentPadding: const EdgeInsets.only(left: 24, right: 17),
contentPadding: const EdgeInsets.only(left: 24, right: 17), leading: const Icon(Symbols.opacity),
leading: const Icon(Symbols.opacity), subtitle: Padding(
subtitle: Padding( padding: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.only(top: 8), child: Slider(
child: Slider( value: settings.windowOpacity,
value: settings.windowOpacity, min: 0.1,
min: 0.1, max: 1.0,
max: 1.0, year2023: true,
year2023: true, padding: EdgeInsets.only(right: 24),
padding: EdgeInsets.only(right: 24), label: '${(settings.windowOpacity * 100).round()}%',
label: '${(settings.windowOpacity * 100).round()}%', onChanged: (value) {
onChanged: (value) { ref
ref .read(appSettingsProvider.notifier)
.read(appSettingsProvider.notifier) .setWindowOpacity(value);
.setWindowOpacity(value); },
},
),
), ),
), ),
]; ),
];
// Create a responsive layout based on screen width // Create a responsive layout based on screen width
Widget buildSettingsList() { Widget buildSettingsList() {

View File

@@ -127,102 +127,88 @@ class MarketplaceStickerPackDetailScreen extends HookConsumerWidget {
// Stickers grid // Stickers grid
Expanded( Expanded(
child: packContent.when( child: packContent.when(
data: data: (stickers) => RefreshIndicator(
(stickers) => RefreshIndicator( onRefresh: () => ref.refresh(
onRefresh: marketplaceStickerPackContentProvider(packId: id).future,
() => ref.refresh( ),
marketplaceStickerPackContentProvider( child: GridView.builder(
packId: id, padding: const EdgeInsets.symmetric(
).future, horizontal: 24,
), vertical: 20,
child: GridView.builder( ),
padding: const EdgeInsets.symmetric( gridDelegate:
horizontal: 24, const SliverGridDelegateWithMaxCrossAxisExtent(
vertical: 20, maxCrossAxisExtent: 96,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
), ),
gridDelegate: itemCount: stickers.length,
const SliverGridDelegateWithMaxCrossAxisExtent( itemBuilder: (context, index) {
maxCrossAxisExtent: 96, final sticker = stickers[index];
mainAxisSpacing: 12, return Tooltip(
crossAxisSpacing: 12, message: ':${p?.prefix ?? ''}${sticker.slug}:',
), child: ClipRRect(
itemCount: stickers.length, borderRadius: const BorderRadius.all(
itemBuilder: (context, index) { Radius.circular(8),
final sticker = stickers[index]; ),
return Tooltip( child: Container(
message: ':${p?.prefix ?? ''}${sticker.slug}:', decoration: BoxDecoration(
child: ClipRRect( color: Theme.of(
context,
).colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all( borderRadius: const BorderRadius.all(
Radius.circular(8), Radius.circular(8),
), ),
child: Container( ),
decoration: BoxDecoration( child: AspectRatio(
color: aspectRatio: 1,
Theme.of( child: CloudImageWidget(
context, fileId: sticker.image.id,
).colorScheme.surfaceContainer, fit: BoxFit.contain,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: AspectRatio(
aspectRatio: 1,
child: CloudImageWidget(
fileId: sticker.image.id,
fit: BoxFit.contain,
),
),
), ),
), ),
); ),
}, ),
), );
), },
error: ),
(err, _) => ),
Text( error: (err, _) => Text(
'Error: $err', 'Error: $err',
).textAlignment(TextAlign.center).center(), ).textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(), loading: () => const CircularProgressIndicator().center(),
), ),
), ),
Padding( Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: owned.when( child: owned.when(
data: data: (isOwned) => FilledButton.icon(
(isOwned) => FilledButton.icon( onPressed: isOwned
onPressed: ? removePackFromMyCollection
isOwned : addPackToMyCollection,
? removePackFromMyCollection icon: Icon(
: addPackToMyCollection, isOwned ? Symbols.remove_circle : Symbols.add_circle,
icon: Icon( ),
isOwned ? Symbols.remove_circle : Symbols.add_circle, label: Text(isOwned ? 'removePack'.tr() : 'addPack'.tr()),
), ),
label: Text( loading: () => const SizedBox(
isOwned ? 'removePack'.tr() : 'addPack'.tr(), height: 32,
), width: 32,
), child: CircularProgressIndicator(strokeWidth: 2),
loading: ).center(),
() => const SizedBox( error: (_, _) => OutlinedButton.icon(
height: 32, onPressed: addPackToMyCollection,
width: 32, icon: const Icon(Symbols.add_circle),
child: CircularProgressIndicator(strokeWidth: 2), label: Text('addPack').tr(),
), ),
error:
(_, _) => OutlinedButton.icon(
onPressed: addPackToMyCollection,
icon: const Icon(Symbols.add_circle),
label: Text('addPack').tr(),
),
), ),
), ),
Gap(MediaQuery.of(context).padding.bottom + 16), Gap(MediaQuery.of(context).padding.bottom + 16),
], ],
); );
}, },
error: error: (err, _) =>
(err, _) => Text('Error: $err').textAlignment(TextAlign.center).center(),
Text('Error: $err').textAlignment(TextAlign.center).center(),
loading: () => const CircularProgressIndicator().center(), loading: () => const CircularProgressIndicator().center(),
), ),
); );

View File

@@ -28,9 +28,8 @@ sealed class MarketplaceStickerQuery with _$MarketplaceStickerQuery {
}) = _MarketplaceStickerQuery; }) = _MarketplaceStickerQuery;
} }
final marketplaceStickerPacksNotifierProvider = AsyncNotifierProvider( final marketplaceStickerPacksNotifierProvider =
MarketplaceStickerPacksNotifier.new, AsyncNotifierProvider.autoDispose(MarketplaceStickerPacksNotifier.new);
);
class MarketplaceStickerPacksNotifier extends AsyncNotifier<List<SnStickerPack>> class MarketplaceStickerPacksNotifier extends AsyncNotifier<List<SnStickerPack>>
with with
@@ -60,11 +59,10 @@ class MarketplaceStickerPacksNotifier extends AsyncNotifier<List<SnStickerPack>>
); );
totalCount = int.parse(response.headers.value('X-Total') ?? '0'); totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final stickers = final stickers = response.data
response.data .map((e) => SnStickerPack.fromJson(e))
.map((e) => SnStickerPack.fromJson(e)) .cast<SnStickerPack>()
.cast<SnStickerPack>() .toList();
.toList();
return stickers; return stickers;
} }
@@ -112,14 +110,12 @@ class MarketplaceStickersScreen extends HookConsumerWidget {
onPressed: () { onPressed: () {
query.value = query.value.copyWith(byUsage: !query.value.byUsage); query.value = query.value.copyWith(byUsage: !query.value.byUsage);
}, },
icon: icon: query.value.byUsage
query.value.byUsage ? const Icon(Symbols.local_fire_department)
? const Icon(Symbols.local_fire_department) : const Icon(Symbols.access_time),
: const Icon(Symbols.access_time), tooltip: query.value.byUsage
tooltip: ? 'orderByPopularity'.tr()
query.value.byUsage : 'orderByReleaseDate'.tr(),
? 'orderByPopularity'.tr()
: 'orderByReleaseDate'.tr(),
), ),
const Gap(8), const Gap(8),
], ],
@@ -137,8 +133,8 @@ class MarketplaceStickersScreen extends HookConsumerWidget {
padding: WidgetStateProperty.all( padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 24), const EdgeInsets.symmetric(horizontal: 24),
), ),
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
trailing: [ trailing: [
if (query.value.query != null && query.value.query!.isNotEmpty) if (query.value.query != null && query.value.query!.isNotEmpty)
IconButton( IconButton(
@@ -171,26 +167,53 @@ class MarketplaceStickersScreen extends HookConsumerWidget {
padding: EdgeInsets.only(top: 8), padding: EdgeInsets.only(top: 8),
provider: marketplaceStickerPacksNotifierProvider, provider: marketplaceStickerPacksNotifierProvider,
notifier: marketplaceStickerPacksNotifierProvider.notifier, notifier: marketplaceStickerPacksNotifierProvider.notifier,
itemBuilder: itemBuilder: (context, idx, pack) => Card(
(context, idx, pack) => Card( margin: EdgeInsets.symmetric(horizontal: 12, vertical: 4),
margin: EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Column(
child: Column( children: [
children: [ if (pack.stickers.isNotEmpty)
Container( Container(
color: color: Theme.of(context).colorScheme.secondaryContainer,
Theme.of(context).colorScheme.secondaryContainer, child: Padding(
child: Padding( padding: const EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric( horizontal: 20,
horizontal: 20, vertical: 20,
vertical: 20, ),
), child: Column(
child: Column( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
math.min(pack.stickers.length, 4),
(index) => Padding(
padding: EdgeInsets.only(
right: index < 3 ? 8 : 0,
),
child: Container(
constraints: const BoxConstraints(
maxWidth: 80,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: Theme.of(
context,
).colorScheme.tertiaryContainer,
),
child: CloudImageWidget(
file: pack.stickers[index].image,
),
).clipRRect(all: 8),
),
),
),
if (pack.stickers.length > 4)
const SizedBox(height: 8),
if (pack.stickers.length > 4)
Row( Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: List.generate( children: List.generate(
math.min(pack.stickers.length, 4), math.min(pack.stickers.length - 4, 4),
(index) => Padding( (index) => Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: index < 3 ? 8 : 0, right: index < 3 ? 8 : 0,
@@ -203,89 +226,55 @@ class MarketplaceStickersScreen extends HookConsumerWidget {
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
8, 8,
), ),
color: color: Theme.of(
Theme.of( context,
context, ).colorScheme.tertiaryContainer,
).colorScheme.tertiaryContainer,
), ),
child: CloudImageWidget( child: CloudImageWidget(
file: pack.stickers[index].image, file: pack.stickers[index + 4].image,
), ),
).clipRRect(all: 8), ).clipRRect(all: 8),
), ),
), ),
), ),
if (pack.stickers.length > 4) ],
const SizedBox(height: 8),
if (pack.stickers.length > 4)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(
math.min(pack.stickers.length - 4, 4),
(index) => Padding(
padding: EdgeInsets.only(
right: index < 3 ? 8 : 0,
),
child: Container(
constraints: const BoxConstraints(
maxWidth: 80,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
8,
),
color:
Theme.of(
context,
).colorScheme.tertiaryContainer,
),
child: CloudImageWidget(
file:
pack.stickers[index + 4].image,
),
).clipRRect(all: 8),
),
),
),
],
),
), ),
).clipRRect(topLeft: 8, topRight: 8),
ListTile(
leading: Container(
decoration: BoxDecoration(
color:
Theme.of(
context,
).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudImageWidget(
file: pack.icon ?? pack.stickers.first.image,
),
).width(40).height(40).clipRRect(all: 8),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
title: Text(pack.name),
subtitle: Text(pack.description),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
// Navigate to user-facing sticker pack detail page.
// Adjust the route name/parameters if your app uses different ones.
context.pushNamed(
'stickerPackDetail',
pathParameters: {'packId': pack.id},
);
},
), ),
], ).clipRRect(topLeft: 8, topRight: 8),
ListTile(
leading: Container(
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.tertiaryContainer,
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
child: CloudImageWidget(
file: pack.icon ?? pack.stickers.firstOrNull?.image,
),
).width(40).height(40).clipRRect(all: 8),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(8),
),
),
title: Text(pack.name),
subtitle: Text(pack.description),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
// Navigate to user-facing sticker pack detail page.
// Adjust the route name/parameters if your app uses different ones.
context.pushNamed(
'stickerPackDetail',
pathParameters: {'packId': pack.id},
);
},
), ),
), ],
),
),
), ),
), ),
], ],

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/upload_tasks.dart'; import 'package:island/pods/drive/upload_tasks.dart';
import 'package:mime/mime.dart'; import 'package:mime/mime.dart';
import 'package:native_exif/native_exif.dart'; import 'package:native_exif/native_exif.dart';
import 'package:path/path.dart' show extension; import 'package:path/path.dart' show extension;
@@ -211,8 +211,9 @@ class FileUploader {
// Use old way for Uint8List // Use old way for Uint8List
final chunks = <Uint8List>[]; final chunks = <Uint8List>[];
for (int i = 0; i < fileData.length; i += chunkSize) { for (int i = 0; i < fileData.length; i += chunkSize) {
final end = final end = i + chunkSize > fileData.length
i + chunkSize > fileData.length ? fileData.length : i + chunkSize; ? fileData.length
: i + chunkSize;
chunks.add(Uint8List.fromList(fileData.sublist(i, end))); chunks.add(Uint8List.fromList(fileData.sublist(i, end)));
} }

View File

@@ -1 +0,0 @@

View File

@@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart'; import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_pool.dart'; import 'package:island/pods/drive/file_pool.dart';
import 'package:island/widgets/content/attachment_preview.dart'; import 'package:island/widgets/content/attachment_preview.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
@@ -79,13 +79,12 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
children: [ children: [
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: selectedPoolId, value: selectedPoolId,
items: items: pools.map((pool) {
pools.map((pool) { return DropdownMenuItem<String>(
return DropdownMenuItem<String>( value: pool.id,
value: pool.id, child: Text(pool.name),
child: Text(pool.name), );
); }).toList(),
}).toList(),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
selectedPoolId = value; selectedPoolId = value;
@@ -140,10 +139,9 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color: Theme.of(
Theme.of( context,
context, ).colorScheme.errorContainer,
).colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Column( child: Column(
@@ -155,23 +153,22 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
Icon( Icon(
Symbols.warning, Symbols.warning,
size: 18, size: 18,
color: color: Theme.of(
Theme.of( context,
context, ).colorScheme.error,
).colorScheme.error,
), ),
const Gap(8), const Gap(8),
Text( Text(
'uploadConstraints'.tr(), 'uploadConstraints'.tr(),
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.bodyMedium?.copyWith( .bodyMedium
color: ?.copyWith(
Theme.of( color: Theme.of(
context, context,
).colorScheme.error, ).colorScheme.error,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
], ],
), ),
@@ -183,28 +180,28 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
_formatFileSize(maxFileSize), _formatFileSize(maxFileSize),
], ],
), ),
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.bodySmall?.copyWith( .bodySmall
color: ?.copyWith(
Theme.of( color: Theme.of(
context, context,
).colorScheme.error, ).colorScheme.error,
), ),
), ),
], ],
if (!typeAccepted) ...[ if (!typeAccepted) ...[
const Gap(4), const Gap(4),
Text( Text(
'fileTypeNotAccepted'.tr(), 'fileTypeNotAccepted'.tr(),
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.bodySmall?.copyWith( .bodySmall
color: ?.copyWith(
Theme.of( color: Theme.of(
context, context,
).colorScheme.error, ).colorScheme.error,
), ),
), ),
], ],
], ],
@@ -229,10 +226,9 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
), ),
], ],
), ),
style: style: Theme.of(
Theme.of( context,
context, ).textTheme.bodyMedium,
).textTheme.bodyMedium,
).fontSize(13), ).fontSize(13),
), ),
], ],
@@ -300,8 +296,8 @@ class _AttachmentUploaderSheetState extends State<AttachmentUploaderSheet> {
final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?; final maxFileSize = selectedPool.policyConfig?['max_file_size'] as int?;
final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize; final fileSizeExceeded = maxFileSize != null && fileSize > maxFileSize;
final acceptTypes = final acceptTypes = (selectedPool.policyConfig?['accept_types'] as List?)
(selectedPool.policyConfig?['accept_types'] as List?)?.cast<String>(); ?.cast<String>();
final mimeType = final mimeType =
attachment.data.mimeType ?? attachment.data.mimeType ??
ComposeLogic.getMimeTypeFromFileType(attachment.type); ComposeLogic.getMimeTypeFromFileType(attachment.type);

View File

@@ -12,8 +12,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file_list_item.dart'; import 'package:island/models/file_list_item.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/file_pool.dart'; import 'package:island/models/file_pool.dart';
import 'package:island/pods/file_list.dart'; import 'package:island/pods/drive/file_list.dart';
import 'package:island/pods/file_pool.dart'; import 'package:island/pods/drive/file_pool.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/services/file_uploader.dart'; import 'package:island/services/file_uploader.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
@@ -123,45 +123,39 @@ class FileListView extends HookConsumerWidget {
notifier: unindexedFileListProvider.notifier, notifier: unindexedFileListProvider.notifier,
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
contentBuilder: contentBuilder: (data, footer) => data.isEmpty
(data, footer) => ? SliverToBoxAdapter(child: _buildEmptyUnindexedFilesHint(ref))
data.isEmpty : _buildUnindexedFileListContent(
? SliverToBoxAdapter( data,
child: _buildEmptyUnindexedFilesHint(ref), ref,
) context,
: _buildUnindexedFileListContent( viewMode,
data, isSelectionMode,
ref, selectedFileIds,
context, currentVisibleItems,
viewMode, footer,
isSelectionMode, ),
selectedFileIds,
currentVisibleItems,
footer,
),
), ),
_ => PaginationWidget( _ => PaginationWidget(
provider: indexedCloudFileListProvider, provider: indexedCloudFileListProvider,
notifier: indexedCloudFileListProvider.notifier, notifier: indexedCloudFileListProvider.notifier,
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
contentBuilder: contentBuilder: (data, footer) => data.isEmpty
(data, footer) => ? SliverToBoxAdapter(
data.isEmpty child: _buildEmptyDirectoryHint(ref, currentPath),
? SliverToBoxAdapter( )
child: _buildEmptyDirectoryHint(ref, currentPath), : _buildFileListContent(
) data,
: _buildFileListContent( ref,
data, context,
ref, currentPath,
context, viewMode,
currentPath, isSelectionMode,
viewMode, selectedFileIds,
isSelectionMode, currentVisibleItems,
selectedFileIds, footer,
currentVisibleItems, ),
footer,
),
), ),
}; };
@@ -177,11 +171,10 @@ class FileListView extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
); );
} else { } else {
final pathParts = final pathParts = currentPath.value
currentPath.value .split('/')
.split('/') .where((part) => part.isNotEmpty)
.where((part) => part.isNotEmpty) .toList();
.toList();
final breadcrumbs = <Widget>[]; final breadcrumbs = <Widget>[];
// Add root // Add root
@@ -266,10 +259,9 @@ class FileListView extends HookConsumerWidget {
dragging.value = false; dragging.value = false;
}, },
child: Container( child: Container(
color: color: dragging.value
dragging.value ? Theme.of(context).primaryColor.withOpacity(0.1)
? Theme.of(context).primaryColor.withOpacity(0.1) : null,
: null,
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -302,28 +294,25 @@ class FileListView extends HookConsumerWidget {
? Symbols.arrow_back ? Symbols.arrow_back
: Symbols.folder, : Symbols.folder,
), ),
onPressed: onPressed: isRefreshing
isRefreshing ? null
? null : () {
: () { if (mode.value == FileListMode.unindexed) {
if (mode.value == FileListMode.unindexed) { mode.value = FileListMode.normal;
mode.value = FileListMode.normal; currentPath.value = '/';
currentPath.value = '/'; } else {
} else { final pathParts = currentPath.value
final pathParts = .split('/')
currentPath.value .where((part) => part.isNotEmpty)
.split('/') .toList();
.where((part) => part.isNotEmpty) if (pathParts.isNotEmpty) {
.toList(); pathParts.removeLast();
if (pathParts.isNotEmpty) { currentPath.value = pathParts.isEmpty
pathParts.removeLast(); ? '/'
currentPath.value = : '/${pathParts.join('/')}';
pathParts.isEmpty
? '/'
: '/${pathParts.join('/')}';
}
} }
}, }
},
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
horizontal: -4, horizontal: -4,
vertical: -4, vertical: -4,
@@ -342,16 +331,13 @@ class FileListView extends HookConsumerWidget {
? Symbols.view_module ? Symbols.view_module
: Symbols.list, : Symbols.list,
), ),
onPressed: onPressed: () => viewMode.value =
() =>
viewMode.value =
viewMode.value == FileListViewMode.list
? FileListViewMode.waterfall
: FileListViewMode.list,
tooltip:
viewMode.value == FileListViewMode.list viewMode.value == FileListViewMode.list
? 'Switch to Waterfall View' ? FileListViewMode.waterfall
: 'Switch to List View', : FileListViewMode.list,
tooltip: viewMode.value == FileListViewMode.list
? 'Switch to Waterfall View'
: 'Switch to List View',
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
horizontal: -4, horizontal: -4,
vertical: -4, vertical: -4,
@@ -363,12 +349,11 @@ class FileListView extends HookConsumerWidget {
? Symbols.close ? Symbols.close
: Symbols.select_check_box, : Symbols.select_check_box,
), ),
onPressed: onPressed: () =>
() => isSelectionMode.value = !isSelectionMode.value, isSelectionMode.value = !isSelectionMode.value,
tooltip: tooltip: isSelectionMode.value
isSelectionMode.value ? 'Exit Selection Mode'
? 'Exit Selection Mode' : 'Enter Selection Mode',
: 'Enter Selection Mode',
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
horizontal: -4, horizontal: -4,
vertical: -4, vertical: -4,
@@ -377,9 +362,8 @@ class FileListView extends HookConsumerWidget {
if (mode.value == FileListMode.normal) if (mode.value == FileListMode.normal)
IconButton( IconButton(
icon: const Icon(Symbols.create_new_folder), icon: const Icon(Symbols.create_new_folder),
onPressed: onPressed: () =>
() => onShowCreateDirectory(ref.context, currentPath),
onShowCreateDirectory(ref.context, currentPath),
tooltip: 'Create Directory', tooltip: 'Create Directory',
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
horizontal: -4, horizontal: -4,
@@ -397,10 +381,9 @@ class FileListView extends HookConsumerWidget {
recycled.value = !recycled.value; recycled.value = !recycled.value;
unindexedNotifier.setRecycled(recycled.value); unindexedNotifier.setRecycled(recycled.value);
}, },
tooltip: tooltip: recycled.value
recycled.value ? 'Show Active Files'
? 'Show Active Files' : 'Show Recycle Bin',
: 'Show Recycle Bin',
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
horizontal: -4, horizontal: -4,
vertical: -4, vertical: -4,
@@ -429,12 +412,14 @@ class FileListView extends HookConsumerWidget {
if (mode.value == FileListMode.normal && currentPath.value == '/') if (mode.value == FileListMode.normal && currentPath.value == '/')
_buildUnindexedFilesEntry(ref).padding(bottom: 12), _buildUnindexedFilesEntry(ref).padding(bottom: 12),
Expanded( Expanded(
child: CustomScrollView( child:
slivers: [bodyWidget, const SliverGap(12)], CustomScrollView(
).padding( slivers: [bodyWidget, const SliverGap(12)],
horizontal: ).padding(
viewMode.value == FileListViewMode.waterfall ? 12 : null, horizontal: viewMode.value == FileListViewMode.waterfall
), ? 12
: null,
),
), ),
if (isSelectionMode.value) if (isSelectionMode.value)
Material( Material(
@@ -457,16 +442,15 @@ class FileListView extends HookConsumerWidget {
const Gap(12), const Gap(12),
OutlinedButton( OutlinedButton(
onPressed: () { onPressed: () {
final allIds = final allIds = currentVisibleItems.value
currentVisibleItems.value .expand(
.expand( (item) => item.maybeMap(
(item) => item.maybeMap( file: (f) => [f.fileIndex.id],
file: (f) => [f.fileIndex.id], unindexedFile: (u) => [u.file.id],
unindexedFile: (u) => [u.file.id], orElse: () => <String>[],
orElse: () => <String>[], ),
), )
) .toSet();
.toSet();
if (allIds if (allIds
.difference(selectedFileIds.value) .difference(selectedFileIds.value)
@@ -482,16 +466,16 @@ class FileListView extends HookConsumerWidget {
currentVisibleItems.value.isEmpty currentVisibleItems.value.isEmpty
? 'Select All' ? 'Select All'
: currentVisibleItems.value : currentVisibleItems.value
.expand( .expand(
(item) => item.maybeMap( (item) => item.maybeMap(
file: (f) => [f.fileIndex.id], file: (f) => [f.fileIndex.id],
unindexedFile: (u) => [u.file.id], unindexedFile: (u) => [u.file.id],
orElse: () => <String>[], orElse: () => <String>[],
), ),
) )
.toSet() .toSet()
.difference(selectedFileIds.value) .difference(selectedFileIds.value)
.isEmpty .isEmpty
? 'Deselect All' ? 'Deselect All'
: 'Select All', : 'Select All',
), ),
@@ -502,47 +486,46 @@ class FileListView extends HookConsumerWidget {
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Symbols.delete), icon: const Icon(Symbols.delete),
label: const Text('Delete'), label: const Text('Delete'),
onPressed: onPressed: selectedFileIds.value.isNotEmpty
selectedFileIds.value.isNotEmpty ? () async {
? () async { final confirmed = await showConfirmAlert(
final confirmed = await showConfirmAlert( 'Are you sure you want to delete the selected files?',
'Are you sure you want to delete the selected files?', 'Delete Selected Files',
'Delete Selected Files', isDanger: true,
isDanger: true, );
if (!confirmed) return;
if (context.mounted) {
showLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
final resp = await client.post(
'/drive/files/batches/delete',
data: {
'file_ids': selectedFileIds.value
.toList(),
},
); );
if (!confirmed) return; final count = resp.data['count'] as int;
selectedFileIds.value.clear();
isSelectionMode.value = false;
ref.invalidate(
mode.value == FileListMode.normal
? indexedCloudFileListProvider
: unindexedFileListProvider,
);
showSnackBar('Deleted $count files.');
} catch (e) {
showSnackBar(
'Failed to delete selected files.',
);
} finally {
if (context.mounted) { if (context.mounted) {
showLoadingModal(context); hideLoadingModal(context);
}
try {
final client = ref.read(apiClientProvider);
final resp = await client.post(
'/drive/files/batches/delete',
data: {
'file_ids':
selectedFileIds.value.toList(),
},
);
final count = resp.data['count'] as int;
selectedFileIds.value.clear();
isSelectionMode.value = false;
ref.invalidate(
mode.value == FileListMode.normal
? indexedCloudFileListProvider
: unindexedFileListProvider,
);
showSnackBar('Deleted $count files.');
} catch (e) {
showSnackBar(
'Failed to delete selected files.',
);
} finally {
if (context.mounted) {
hideLoadingModal(context);
}
} }
} }
: null, }
: null,
), ),
], ],
), ),
@@ -584,26 +567,24 @@ class FileListView extends HookConsumerWidget {
final item = items[index]; final item = items[index];
return item.map( return item.map(
file: file: (fileItem) => _buildWaterfallFileTile(
(fileItem) => _buildWaterfallFileTile( fileItem,
fileItem, ref,
ref, context,
context, isSelectionMode.value,
isSelectionMode.value, selectedFileIds.value.contains(fileItem.fileIndex.id),
selectedFileIds.value.contains(fileItem.fileIndex.id), () {
() { if (selectedFileIds.value.contains(fileItem.fileIndex.id)) {
if (selectedFileIds.value.contains(fileItem.fileIndex.id)) { selectedFileIds.value = Set.from(selectedFileIds.value)
selectedFileIds.value = Set.from(selectedFileIds.value) ..remove(fileItem.fileIndex.id);
..remove(fileItem.fileIndex.id); } else {
} else { selectedFileIds.value = Set.from(selectedFileIds.value)
selectedFileIds.value = Set.from(selectedFileIds.value) ..add(fileItem.fileIndex.id);
..add(fileItem.fileIndex.id); }
} },
}, ),
), folder: (folderItem) =>
folder: _buildWaterfallFolderTile(folderItem, currentPath, context),
(folderItem) =>
_buildWaterfallFolderTile(folderItem, currentPath, context),
unindexedFile: (unindexedFileItem) { unindexedFile: (unindexedFileItem) {
// Should not happen // Should not happen
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -620,47 +601,44 @@ class FileListView extends HookConsumerWidget {
} }
final item = items[index]; final item = items[index];
return item.map( return item.map(
file: file: (fileItem) => _buildIndexedListTile(
(fileItem) => _buildIndexedListTile( fileItem,
fileItem, ref,
ref, context,
context, isSelectionMode.value,
isSelectionMode.value, selectedFileIds.value.contains(fileItem.fileIndex.id),
selectedFileIds.value.contains(fileItem.fileIndex.id), () {
() { if (selectedFileIds.value.contains(fileItem.fileIndex.id)) {
if (selectedFileIds.value.contains(fileItem.fileIndex.id)) { selectedFileIds.value = Set.from(selectedFileIds.value)
selectedFileIds.value = Set.from(selectedFileIds.value) ..remove(fileItem.fileIndex.id);
..remove(fileItem.fileIndex.id); } else {
} else { selectedFileIds.value = Set.from(selectedFileIds.value)
selectedFileIds.value = Set.from(selectedFileIds.value) ..add(fileItem.fileIndex.id);
..add(fileItem.fileIndex.id); }
} },
}, ),
), folder: (folderItem) => ListTile(
folder: leading: ClipRRect(
(folderItem) => ListTile( borderRadius: const BorderRadius.all(Radius.circular(8)),
leading: ClipRRect( child: SizedBox(
borderRadius: const BorderRadius.all(Radius.circular(8)), height: 48,
child: SizedBox( width: 48,
height: 48, child: const Icon(Symbols.folder, fill: 1).center(),
width: 48,
child: const Icon(Symbols.folder, fill: 1).center(),
),
),
title: Text(
folderItem.folderName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: const Text('folder').tr(),
onTap: () {
final newPath =
currentPath.value == '/'
? '/${folderItem.folderName}'
: '${currentPath.value}/${folderItem.folderName}';
currentPath.value = newPath;
},
), ),
),
title: Text(
folderItem.folderName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: const Text('folder').tr(),
onTap: () {
final newPath = currentPath.value == '/'
? '/${folderItem.folderName}'
: '${currentPath.value}/${folderItem.folderName}';
currentPath.value = newPath;
},
),
unindexedFile: (unindexedFileItem) { unindexedFile: (unindexedFileItem) {
// Should not happen in normal mode // Should not happen in normal mode
return const SizedBox.shrink(); return const SizedBox.shrink();
@@ -705,10 +683,9 @@ class FileListView extends HookConsumerWidget {
ValueNotifier<String> currentPath, ValueNotifier<String> currentPath,
) { ) {
return Card( return Card(
margin: margin: viewMode.value == FileListViewMode.waterfall
viewMode.value == FileListViewMode.waterfall ? const EdgeInsets.fromLTRB(0, 0, 0, 16)
? const EdgeInsets.fromLTRB(0, 0, 0, 16) : const EdgeInsets.fromLTRB(12, 0, 12, 16),
: const EdgeInsets.fromLTRB(12, 0, 12, 16),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Column( child: Column(
@@ -748,8 +725,8 @@ class FileListView extends HookConsumerWidget {
), ),
const Gap(12), const Gap(12),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onPressed: () =>
() => onShowCreateDirectory(ref.context, currentPath), onShowCreateDirectory(ref.context, currentPath),
icon: const Icon(Symbols.create_new_folder), icon: const Icon(Symbols.create_new_folder),
label: const Text('Create Directory'), label: const Text('Create Directory'),
), ),
@@ -822,8 +799,9 @@ class FileListView extends HookConsumerWidget {
VoidCallback? toggleSelection, VoidCallback? toggleSelection,
) { ) {
final meta = file.fileMeta is Map ? (file.fileMeta as Map) : const {}; final meta = file.fileMeta is Map ? (file.fileMeta as Map) : const {};
final ratio = final ratio = meta['ratio'] is num
meta['ratio'] is num ? (meta['ratio'] as num).toDouble() : 1.0; ? (meta['ratio'] as num).toDouble()
: 1.0;
final itemType = file.mimeType?.split('/').first; final itemType = file.mimeType?.split('/').first;
final uri = final uri =
'${ref.read(apiClientProvider).options.baseUrl}/drive/files/${file.id}'; '${ref.read(apiClientProvider).options.baseUrl}/drive/files/${file.id}';
@@ -851,22 +829,20 @@ class FileListView extends HookConsumerWidget {
.read(apiClientProvider) .read(apiClientProvider)
.get(uri) .get(uri)
.then((response) => response.data as String), .then((response) => response.data as String),
builder: builder: (context, snapshot) => snapshot.hasData
(context, snapshot) => ? SingleChildScrollView(
snapshot.hasData padding: EdgeInsets.all(24),
? SingleChildScrollView( child: Text(
padding: EdgeInsets.all(24), snapshot.data!,
child: Text( style: const TextStyle(
snapshot.data!, fontSize: 9,
style: const TextStyle( fontFamily: 'monospace',
fontSize: 9, ),
fontFamily: 'monospace', maxLines: 20,
), overflow: TextOverflow.ellipsis,
maxLines: 20, ),
overflow: TextOverflow.ellipsis, )
), : const Center(child: CircularProgressIndicator()),
)
: const Center(child: CircularProgressIndicator()),
), ),
); );
break; break;
@@ -961,10 +937,9 @@ class FileListView extends HookConsumerWidget {
return InkWell( return InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () { onTap: () {
final newPath = final newPath = currentPath.value == '/'
currentPath.value == '/' ? '/${folderItem.folderName}'
? '/${folderItem.folderName}' : '${currentPath.value}/${folderItem.folderName}';
: '${currentPath.value}/${folderItem.folderName}';
currentPath.value = newPath; currentPath.value = newPath;
}, },
child: Container( child: Container(
@@ -1038,8 +1013,8 @@ class FileListView extends HookConsumerWidget {
// Should not happen in unindexed mode // Should not happen in unindexed mode
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
unindexedFile: unindexedFile: (unindexedFileItem) =>
(unindexedFileItem) => _buildWaterfallUnindexedFileTile( _buildWaterfallUnindexedFileTile(
unindexedFileItem, unindexedFileItem,
ref, ref,
context, context,
@@ -1077,25 +1052,22 @@ class FileListView extends HookConsumerWidget {
// Should not happen in unindexed mode // Should not happen in unindexed mode
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
unindexedFile: unindexedFile: (unindexedFileItem) => _buildUnindexedListTile(
(unindexedFileItem) => _buildUnindexedListTile( unindexedFileItem,
unindexedFileItem, ref,
ref, context,
context, isSelectionMode.value,
isSelectionMode.value, selectedFileIds.value.contains(unindexedFileItem.file.id),
selectedFileIds.value.contains(unindexedFileItem.file.id), () {
() { if (selectedFileIds.value.contains(unindexedFileItem.file.id)) {
if (selectedFileIds.value.contains( selectedFileIds.value = Set.from(selectedFileIds.value)
unindexedFileItem.file.id, ..remove(unindexedFileItem.file.id);
)) { } else {
selectedFileIds.value = Set.from(selectedFileIds.value) selectedFileIds.value = Set.from(selectedFileIds.value)
..remove(unindexedFileItem.file.id); ..add(unindexedFileItem.file.id);
} else { }
selectedFileIds.value = Set.from(selectedFileIds.value) },
..add(unindexedFileItem.file.id); ),
}
},
),
); );
}, },
), ),
@@ -1130,10 +1102,9 @@ class FileListView extends HookConsumerWidget {
), ),
], ],
), ),
title: title: file.name.isEmpty
file.name.isEmpty ? Text('untitled').tr().italic()
? Text('untitled').tr().italic() : Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
: Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(formatFileSize(file.size)), subtitle: Text(formatFileSize(file.size)),
onTap: () { onTap: () {
if (isSelectionMode) { if (isSelectionMode) {
@@ -1199,10 +1170,9 @@ class FileListView extends HookConsumerWidget {
), ),
], ],
), ),
title: title: file.name.isEmpty
file.name.isEmpty ? Text('untitled').tr().italic()
? Text('untitled').tr().italic() : Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
: Text(file.name, maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(formatFileSize(file.size)), subtitle: Text(formatFileSize(file.size)),
onTap: () { onTap: () {
if (isSelectionMode) { if (isSelectionMode) {
@@ -1289,10 +1259,9 @@ class FileListView extends HookConsumerWidget {
Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) { Widget _buildEmptyUnindexedFilesHint(WidgetRef ref) {
return Card( return Card(
margin: margin: viewMode.value == FileListViewMode.waterfall
viewMode.value == FileListViewMode.waterfall ? EdgeInsets.zero
? EdgeInsets.zero : const EdgeInsets.fromLTRB(12, 0, 12, 0),
: const EdgeInsets.fromLTRB(12, 0, 12, 0),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
child: Column( child: Column(
@@ -1395,19 +1364,18 @@ class FileListView extends HookConsumerWidget {
ObjectRef<Timer?> queryDebounceTimer, ObjectRef<Timer?> queryDebounceTimer,
) { ) {
final poolDropdownItems = poolsAsync.when( final poolDropdownItems = poolsAsync.when(
data: data: (pools) => [
(pools) => [ const DropdownMenuItem<SnFilePool>(
const DropdownMenuItem<SnFilePool>( value: null,
value: null, child: Text('All Pools', style: TextStyle(fontSize: 14)),
child: Text('All Pools', style: TextStyle(fontSize: 14)), ),
), ...pools.map(
...pools.map( (p) => DropdownMenuItem<SnFilePool>(
(p) => DropdownMenuItem<SnFilePool>( value: p,
value: p, child: Text(p.name, style: const TextStyle(fontSize: 14)),
child: Text(p.name, style: const TextStyle(fontSize: 14)), ),
), ),
), ],
],
loading: () => const <DropdownMenuItem<SnFilePool>>[], loading: () => const <DropdownMenuItem<SnFilePool>>[],
error: (err, stack) => const <DropdownMenuItem<SnFilePool>>[], error: (err, stack) => const <DropdownMenuItem<SnFilePool>>[],
); );
@@ -1416,17 +1384,16 @@ class FileListView extends HookConsumerWidget {
child: DropdownButton2<SnFilePool>( child: DropdownButton2<SnFilePool>(
value: selectedPool.value, value: selectedPool.value,
items: poolDropdownItems, items: poolDropdownItems,
onChanged: onChanged: isRefreshing
isRefreshing ? null
? null : (value) {
: (value) { selectedPool.value = value;
selectedPool.value = value; if (mode.value == FileListMode.unindexed) {
if (mode.value == FileListMode.unindexed) { unindexedNotifier.setPool(value?.id);
unindexedNotifier.setPool(value?.id); } else {
} else { cloudNotifier.setPool(value?.id);
cloudNotifier.setPool(value?.id); }
} },
},
customButton: Container( customButton: Container(
height: 28, height: 28,
width: 200, width: 200,
@@ -1493,19 +1460,17 @@ class FileListView extends HookConsumerWidget {
final orderDropdown = DropdownButtonHideUnderline( final orderDropdown = DropdownButtonHideUnderline(
child: DropdownButton2<String>( child: DropdownButton2<String>(
value: order.value, value: order.value,
items: items: ['date', 'size', 'name']
['date', 'size', 'name'] .map(
.map( (e) => DropdownMenuItem(
(e) => DropdownMenuItem( value: e,
value: e, child: Text(
child: e == 'date' ? e : 'file${e.capitalizeEachWord()}',
Text( style: const TextStyle(fontSize: 14),
e == 'date' ? e : 'file${e.capitalizeEachWord()}', ).tr(),
style: const TextStyle(fontSize: 14), ),
).tr(), )
), .toList(),
)
.toList(),
onChanged: (value) => order.value = value, onChanged: (value) => order.value = value,
customButton: Container( customButton: Container(
height: 28, height: 28,
@@ -1517,13 +1482,12 @@ class FileListView extends HookConsumerWidget {
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Center( child: Center(
child: child: Text(
Text( (order.value ?? 'date') == 'date'
(order.value ?? 'date') == 'date' ? (order.value ?? 'date')
? (order.value ?? 'date') : 'file${order.value?.capitalizeEachWord()}',
: 'file${order.value?.capitalizeEachWord()}', style: const TextStyle(fontSize: 12),
style: const TextStyle(fontSize: 12), ).tr(),
).tr(),
), ),
), ),
buttonStyleData: const ButtonStyleData( buttonStyleData: const ButtonStyleData(

View File

@@ -1,12 +1,15 @@
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/misc.dart'; import 'package:flutter_riverpod/misc.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/response.dart'; import 'package:island/widgets/response.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:super_sliver_list/super_sliver_list.dart'; import 'package:super_sliver_list/super_sliver_list.dart';
import 'package:visibility_detector/visibility_detector.dart'; import 'package:visibility_detector/visibility_detector.dart';
@@ -15,19 +18,27 @@ class PaginationList<T> extends HookConsumerWidget {
final ProviderListenable<AsyncValue<List<T>>> provider; final ProviderListenable<AsyncValue<List<T>>> provider;
final Refreshable<PaginationController<T>> notifier; final Refreshable<PaginationController<T>> notifier;
final Widget? Function(BuildContext, int, T) itemBuilder; final Widget? Function(BuildContext, int, T) itemBuilder;
final Widget? Function(BuildContext, int, T)? seperatorBuilder;
final double? spacing;
final bool isRefreshable; final bool isRefreshable;
final bool isSliver; final bool isSliver;
final bool showDefaultWidgets; final bool showDefaultWidgets;
final EdgeInsets? padding; final EdgeInsets? padding;
final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationList({ const PaginationList({
super.key, super.key,
required this.provider, required this.provider,
required this.notifier, required this.notifier,
required this.itemBuilder, required this.itemBuilder,
this.seperatorBuilder,
this.spacing,
this.isRefreshable = true, this.isRefreshable = true,
this.isSliver = false, this.isSliver = false,
this.showDefaultWidgets = true, this.showDefaultWidgets = true,
this.padding, this.padding,
this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
}); });
@override @override
@@ -35,48 +46,149 @@ class PaginationList<T> extends HookConsumerWidget {
final data = ref.watch(provider); final data = ref.watch(provider);
final noti = ref.watch(notifier); final noti = ref.watch(notifier);
if (data.isLoading && data.value?.isEmpty == true) { // For sliver cases, avoid animation to prevent complex sliver issues
final content = ResponseLoadingWidget(); if (isSliver) {
return isSliver ? SliverFillRemaining(child: content) : content; if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
} final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SliverList.list(children: content);
}
if (data.hasError) { if (data.hasError) {
final content = ResponseErrorWidget( final content = ResponseErrorWidget(
error: data.error, error: data.error,
onRetry: noti.refresh, onRetry: noti.refresh,
); );
return isSliver ? SliverFillRemaining(child: content) : content; return SliverFillRemaining(child: content);
} }
final listView = final listView = SuperSliverList.separated(
isSliver itemCount: (data.value?.length ?? 0) + 1,
? SuperSliverList.builder( itemBuilder: (context, idx) {
itemCount: (data.value?.length ?? 0) + 1, if (idx == data.value?.length) {
itemBuilder: (context, idx) { return PaginationListFooter(
if (idx == data.value?.length) { noti: noti,
return PaginationListFooter(noti: noti, data: data); data: data,
} skeletonChild: footerSkeletonChild,
final entry = data.value?[idx]; skeletonMaxWidth: footerSkeletonMaxWidth,
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
)
: SuperListView.builder(
padding: padding,
itemCount: (data.value?.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.length) {
return PaginationListFooter(noti: noti, data: data);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
); );
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
separatorBuilder: (context, index) {
if (seperatorBuilder != null) {
final entry = data.value?[index];
if (entry != null) {
return seperatorBuilder!(context, index, entry) ??
const SizedBox();
}
return const SizedBox();
}
if (spacing != null && spacing! > 0) {
return Gap(spacing!);
}
return const SizedBox();
},
);
return isRefreshable return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView) ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView; : listView;
}
// For non-sliver cases, use AnimatedSwitcher for smooth transitions
Widget buildContent() {
if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SizedBox(
key: const ValueKey('loading'),
child: ListView(children: content),
);
}
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SizedBox(key: const ValueKey('error'), child: content);
}
final listView = SuperListView.separated(
padding: padding,
itemCount: (data.value?.length ?? 0) + 1,
itemBuilder: (context, idx) {
if (idx == data.value?.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
separatorBuilder: (context, index) {
if (seperatorBuilder != null) {
final entry = data.value?[index];
if (entry != null) {
return seperatorBuilder!(context, index, entry) ??
const SizedBox();
}
return const SizedBox();
}
if (spacing != null && spacing! > 0) {
return Gap(spacing!);
}
return const SizedBox();
},
);
return SizedBox(
key: const ValueKey('data'),
child: isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: buildContent(),
);
} }
} }
@@ -87,6 +199,8 @@ class PaginationWidget<T> extends HookConsumerWidget {
final bool isRefreshable; final bool isRefreshable;
final bool isSliver; final bool isSliver;
final bool showDefaultWidgets; final bool showDefaultWidgets;
final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationWidget({ const PaginationWidget({
super.key, super.key,
required this.provider, required this.provider,
@@ -95,6 +209,8 @@ class PaginationWidget<T> extends HookConsumerWidget {
this.isRefreshable = true, this.isRefreshable = true,
this.isSliver = false, this.isSliver = false,
this.showDefaultWidgets = true, this.showDefaultWidgets = true,
this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
}); });
@override @override
@@ -102,62 +218,151 @@ class PaginationWidget<T> extends HookConsumerWidget {
final data = ref.watch(provider); final data = ref.watch(provider);
final noti = ref.watch(notifier); final noti = ref.watch(notifier);
if (data.isLoading && data.value?.isEmpty == true) { // For sliver cases, avoid animation to prevent complex sliver issues
final content = ResponseLoadingWidget(); if (isSliver) {
return isSliver ? SliverFillRemaining(child: content) : content; if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
} final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SliverList.list(children: content);
}
if (data.hasError) { if (data.hasError) {
final content = ResponseErrorWidget( final content = ResponseErrorWidget(
error: data.error, error: data.error,
onRetry: noti.refresh, onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
); );
return isSliver ? SliverFillRemaining(child: content) : content; final content = contentBuilder(data.value ?? [], footer);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content;
} }
final footer = PaginationListFooter(noti: noti, data: data); // For non-sliver cases, use AnimatedSwitcher for smooth transitions
final content = contentBuilder(data.value ?? [], footer); Widget buildContent() {
if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
final content = List<Widget>.generate(
10,
(_) => Skeletonizer(
enabled: true,
effect: ShimmerEffect(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SizedBox(
key: const ValueKey('loading'),
child: ListView(children: content),
);
}
return isRefreshable if (data.hasError) {
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content) final content = ResponseErrorWidget(
: content; error: data.error,
onRetry: noti.refresh,
);
return SizedBox(key: const ValueKey('error'), child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
final content = contentBuilder(data.value ?? [], footer);
return SizedBox(
key: const ValueKey('data'),
child: isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content,
);
}
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: buildContent(),
);
} }
} }
class PaginationListFooter<T> extends StatelessWidget { class PaginationListFooter<T> extends HookConsumerWidget {
final PaginationController<T> noti; final PaginationController<T> noti;
final AsyncValue<List<T>> data; final AsyncValue<List<T>> data;
final Widget? skeletonChild;
final double? skeletonMaxWidth;
final bool isSliver; final bool isSliver;
const PaginationListFooter({ const PaginationListFooter({
super.key, super.key,
required this.noti, required this.noti,
required this.data, required this.data,
this.skeletonChild,
this.skeletonMaxWidth,
this.isSliver = false, this.isSliver = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final child = SizedBox( final hasBeenVisible = useState(false);
height: 64,
child: Center( final placeholder = Skeletonizer(
child: enabled: true,
data.isLoading effect: ShimmerEffect(
? CircularProgressIndicator() baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
: Row( highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: skeletonChild ?? _DefaultSkeletonChild(maxWidth: skeletonMaxWidth),
);
final child = hasBeenVisible.value
? data.isLoading
? placeholder
: Row(
spacing: 8, spacing: 8,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Symbols.close, size: 16), const Icon(Symbols.close, size: 16),
Text('noFurtherData').tr().fontSize(13), Text('noFurtherData').tr().fontSize(13),
], ],
).opacity(0.9), ).opacity(0.9).height(64).center()
).padding(all: 8), : placeholder;
);
return VisibilityDetector( return VisibilityDetector(
key: Key("pagination-list-${noti.hashCode}"), key: Key("pagination-list-${noti.hashCode}"),
onVisibilityChanged: (VisibilityInfo info) { onVisibilityChanged: (VisibilityInfo info) {
hasBeenVisible.value = true;
if (!noti.fetchedAll && !data.isLoading && !data.hasError) { if (!noti.fetchedAll && !data.isLoading && !data.hasError) {
noti.fetchFurther(); noti.fetchFurther();
} }
@@ -166,3 +371,26 @@ class PaginationListFooter<T> extends StatelessWidget {
); );
} }
} }
class _DefaultSkeletonChild extends StatelessWidget {
final double? maxWidth;
const _DefaultSkeletonChild({this.maxWidth});
@override
Widget build(BuildContext context) {
final content = ListTile(
title: Text('Some data'),
subtitle: const Text('Subtitle here'),
trailing: const Icon(Icons.ac_unit),
);
if (maxWidth != null) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth!),
child: content,
),
);
}
return content;
}
}

View File

@@ -9,33 +9,14 @@ import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart'; import 'package:island/models/post_tag.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/post/post_categories.dart';
import 'package:island/screens/realm/realms.dart'; import 'package:island/screens/realm/realms.dart';
import 'package:island/widgets/content/cloud_files.dart'; import 'package:island/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
part 'compose_settings_sheet.g.dart';
@riverpod
Future<List<SnPostCategory>> postCategories(Ref ref) async {
final apiClient = ref.watch(apiClientProvider);
final resp = await apiClient.get('/sphere/posts/categories');
final categories =
resp.data
.map((e) => SnPostCategory.fromJson(e))
.cast<SnPostCategory>()
.toList();
// Remove duplicates based on id
final uniqueCategories = <String, SnPostCategory>{};
for (final category in categories) {
uniqueCategories[category.id] = category;
}
return uniqueCategories.values.toList();
}
class ComposeSettingsSheet extends HookConsumerWidget { class ComposeSettingsSheet extends HookConsumerWidget {
final ComposeState state; final ComposeState state;
@@ -121,39 +102,38 @@ class ComposeSettingsSheet extends HookConsumerWidget {
void showVisibilitySheet() { void showVisibilitySheet() {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
builder: builder: (context) => SheetScaffold(
(context) => SheetScaffold( titleText: 'postVisibility'.tr(),
titleText: 'postVisibility'.tr(), child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ buildVisibilityOption(
buildVisibilityOption( context,
context, 0,
0, Symbols.public,
Symbols.public, 'postVisibilityPublic',
'postVisibilityPublic',
),
buildVisibilityOption(
context,
1,
Symbols.group,
'postVisibilityFriends',
),
buildVisibilityOption(
context,
2,
Symbols.link_off,
'postVisibilityUnlisted',
),
buildVisibilityOption(
context,
3,
Symbols.lock,
'postVisibilityPrivate',
),
],
), ),
), buildVisibilityOption(
context,
1,
Symbols.group,
'postVisibilityFriends',
),
buildVisibilityOption(
context,
2,
Symbols.link_off,
'postVisibilityUnlisted',
),
buildVisibilityOption(
context,
3,
Symbols.lock,
'postVisibilityPrivate',
),
],
),
),
); );
} }
@@ -182,8 +162,8 @@ class ComposeSettingsSheet extends HookConsumerWidget {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
), ),
onTapOutside: onTapOutside: (_) =>
(_) => FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
), ),
// Tags field // Tags field
@@ -209,51 +189,48 @@ class ComposeSettingsSheet extends HookConsumerWidget {
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: children: currentTags.map((tag) {
currentTags.map((tag) { return Container(
return Container( decoration: BoxDecoration(
decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary,
color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(16), ),
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'#$tag',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onPrimary,
fontSize: 14,
),
), ),
padding: const EdgeInsets.symmetric( const Gap(4),
horizontal: 12, InkWell(
vertical: 6, onTap: () {
final newTags = List<String>.from(
state.tags.value,
)..remove(tag);
state.tags.value = newTags;
},
child: Icon(
Icons.close,
size: 16,
color: Theme.of(
context,
).colorScheme.onPrimary,
),
), ),
child: Row( ],
mainAxisSize: MainAxisSize.min, ),
children: [ );
Text( }).toList(),
'#$tag',
style: TextStyle(
color:
Theme.of(
context,
).colorScheme.onPrimary,
fontSize: 14,
),
),
const Gap(4),
InkWell(
onTap: () {
final newTags = List<String>.from(
state.tags.value,
)..remove(tag);
state.tags.value = newTags;
},
child: Icon(
Icons.close,
size: 16,
color:
Theme.of(
context,
).colorScheme.onPrimary,
),
),
],
),
);
}).toList(),
), ),
// Tag input with autocomplete // Tag input with autocomplete
TypeAheadField<SnPostTag>( TypeAheadField<SnPostTag>(
@@ -274,8 +251,8 @@ class ComposeSettingsSheet extends HookConsumerWidget {
}, },
); );
}, },
suggestionsCallback: suggestionsCallback: (pattern) =>
(pattern) => _fetchTagSuggestions(pattern, ref), _fetchTagSuggestions(pattern, ref),
itemBuilder: (context, suggestion) { itemBuilder: (context, suggestion) {
return ListTile( return ListTile(
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
@@ -314,55 +291,49 @@ class ComposeSettingsSheet extends HookConsumerWidget {
), ),
), ),
hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)), hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)),
items: items: (postCategories.value ?? <SnPostCategory>[]).map((item) {
(postCategories.value ?? <SnPostCategory>[]).map((item) { return DropdownMenuItem(
return DropdownMenuItem( value: item,
value: item, enabled: false,
enabled: false, child: StatefulBuilder(
child: StatefulBuilder( builder: (context, menuSetState) {
builder: (context, menuSetState) { final isSelected = state.categories.value.contains(item);
final isSelected = state.categories.value.contains( return InkWell(
item, onTap: () {
); isSelected
return InkWell( ? state.categories.value = state.categories.value
onTap: () { .where((e) => e != item)
isSelected .toList()
? state.categories.value = : state.categories.value = [
state.categories.value ...state.categories.value,
.where((e) => e != item) item,
.toList() ];
: state.categories.value = [ menuSetState(() {});
...state.categories.value,
item,
];
menuSetState(() {});
},
child: Container(
height: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Row(
children: [
if (isSelected)
const Icon(Icons.check_box_outlined)
else
const Icon(Icons.check_box_outline_blank),
const SizedBox(width: 16),
Expanded(
child: Text(
item.categoryDisplayTitle,
style: const TextStyle(fontSize: 14),
),
),
],
),
),
);
}, },
), child: Container(
); height: double.infinity,
}).toList(), padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
if (isSelected)
const Icon(Icons.check_box_outlined)
else
const Icon(Icons.check_box_outline_blank),
const SizedBox(width: 16),
Expanded(
child: Text(
item.categoryDisplayTitle,
style: const TextStyle(fontSize: 14),
),
),
],
),
),
);
},
),
);
}).toList(),
value: currentCategories.isEmpty ? null : currentCategories.last, value: currentCategories.isEmpty ? null : currentCategories.last,
onChanged: (_) {}, onChanged: (_) {},
selectedItemBuilder: (context) { selectedItemBuilder: (context) {

View File

@@ -1,51 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'compose_settings_sheet.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
@ProviderFor(postCategories)
const postCategoriesProvider = PostCategoriesProvider._();
final class PostCategoriesProvider
extends
$FunctionalProvider<
AsyncValue<List<SnPostCategory>>,
List<SnPostCategory>,
FutureOr<List<SnPostCategory>>
>
with
$FutureModifier<List<SnPostCategory>>,
$FutureProvider<List<SnPostCategory>> {
const PostCategoriesProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'postCategoriesProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$postCategoriesHash();
@$internal
@override
$FutureProviderElement<List<SnPostCategory>> $createElement(
$ProviderPointer pointer,
) => $FutureProviderElement(pointer);
@override
FutureOr<List<SnPostCategory>> create(Ref ref) {
return postCategories(ref);
}
}
String _$postCategoriesHash() => r'8799c10eb91cf8c8c7ea72eff3475e1eaa7b9a2b';

View File

@@ -24,7 +24,7 @@ import 'package:island/widgets/post/compose_link_attachments.dart';
import 'package:island/widgets/post/compose_poll.dart'; import 'package:island/widgets/post/compose_poll.dart';
import 'package:island/widgets/post/compose_fund.dart'; import 'package:island/widgets/post/compose_fund.dart';
import 'package:island/widgets/post/compose_recorder.dart'; import 'package:island/widgets/post/compose_recorder.dart';
import 'package:island/pods/file_pool.dart'; import 'package:island/pods/drive/file_pool.dart';
import 'package:pasteboard/pasteboard.dart'; import 'package:pasteboard/pasteboard.dart';
import 'package:island/talker.dart'; import 'package:island/talker.dart';
@@ -108,8 +108,8 @@ class ComposeLogic {
String? pollId; String? pollId;
String? fundId; String? fundId;
if (originalPost?.meta?['embeds'] is List) { if (originalPost?.meta?['embeds'] is List) {
final embeds = final embeds = (originalPost!.meta!['embeds'] as List)
(originalPost!.meta!['embeds'] as List).cast<Map<String, dynamic>>(); .cast<Map<String, dynamic>>();
try { try {
final pollEmbed = embeds.firstWhere((e) => e['type'] == 'poll'); final pollEmbed = embeds.firstWhere((e) => e['type'] == 'poll');
pollId = pollEmbed['id']; pollId = pollEmbed['id'];
@@ -202,11 +202,10 @@ class ComposeLogic {
final attachment = state.attachments.value[i]; final attachment = state.attachments.value[i];
if (attachment.data is! SnCloudFile) { if (attachment.data is! SnCloudFile) {
try { try {
final cloudFile = final cloudFile = await FileUploader.createCloudFile(
await FileUploader.createCloudFile( ref: ref,
ref: ref, fileData: attachment,
fileData: attachment, ).future;
).future;
if (cloudFile != null) { if (cloudFile != null) {
// Update attachments list with cloud file // Update attachments list with cloud file
final clone = List.of(state.attachments.value); final clone = List.of(state.attachments.value);
@@ -242,11 +241,10 @@ class ComposeLogic {
repliedPost: null, repliedPost: null,
forwardedPostId: null, forwardedPostId: null,
forwardedPost: null, forwardedPost: null,
attachments: attachments: state.attachments.value
state.attachments.value .map((e) => e.data)
.map((e) => e.data) .whereType<SnCloudFile>()
.whereType<SnCloudFile>() .toList(),
.toList(),
publisher: SnPublisher( publisher: SnPublisher(
id: '', id: '',
type: 0, type: 0,
@@ -315,11 +313,10 @@ class ComposeLogic {
repliedPost: null, repliedPost: null,
forwardedPostId: null, forwardedPostId: null,
forwardedPost: null, forwardedPost: null,
attachments: attachments: state.attachments.value
state.attachments.value .map((e) => e.data)
.map((e) => e.data) .whereType<SnCloudFile>()
.whereType<SnCloudFile>() .toList(),
.toList(),
publisher: SnPublisher( publisher: SnPublisher(
id: '', id: '',
type: 0, type: 0,
@@ -501,11 +498,10 @@ class ComposeLogic {
UniversalFile value, UniversalFile value,
int index, int index,
) { ) {
state.attachments.value = state.attachments.value = state.attachments.value.mapIndexed((idx, ele) {
state.attachments.value.mapIndexed((idx, ele) { if (idx == index) return value;
if (idx == index) return value; return ele;
return ele; }).toList();
}).toList();
} }
static Future<void> uploadAttachment( static Future<void> uploadAttachment(
@@ -528,22 +524,20 @@ class ComposeLogic {
final pools = await ref.read(poolsProvider.future); final pools = await ref.read(poolsProvider.future);
final selectedPoolId = resolveDefaultPoolId(ref, pools); final selectedPoolId = resolveDefaultPoolId(ref, pools);
cloudFile = cloudFile = await FileUploader.createCloudFile(
await FileUploader.createCloudFile( ref: ref,
ref: ref, fileData: attachment,
fileData: attachment, poolId: poolId ?? selectedPoolId,
poolId: poolId ?? selectedPoolId, mode: attachment.type == UniversalFileType.file
mode: ? FileUploadMode.generic
attachment.type == UniversalFileType.file : FileUploadMode.mediaSafe,
? FileUploadMode.generic onProgress: (progress, _) {
: FileUploadMode.mediaSafe, state.attachmentProgress.value = {
onProgress: (progress, _) { ...state.attachmentProgress.value,
state.attachmentProgress.value = { index: progress ?? 0.0,
...state.attachmentProgress.value, };
index: progress ?? 0.0, },
}; ).future;
},
).future;
if (cloudFile == null) { if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...'); throw ArgumentError('Failed to upload the file...');
@@ -713,11 +707,10 @@ class ComposeLogic {
if (state.slugController.text.isNotEmpty) if (state.slugController.text.isNotEmpty)
'slug': state.slugController.text, 'slug': state.slugController.text,
'visibility': state.visibility.value, 'visibility': state.visibility.value,
'attachments': 'attachments': state.attachments.value
state.attachments.value .where((e) => e.isOnCloud)
.where((e) => e.isOnCloud) .map((e) => e.data.id)
.map((e) => e.data.id) .toList(),
.toList(),
'type': state.postType, 'type': state.postType,
if (repliedPost != null) 'replied_post_id': repliedPost.id, if (repliedPost != null) 'replied_post_id': repliedPost.id,
if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id, if (forwardedPost != null) 'forwarded_post_id': forwardedPost.id,

View File

@@ -1,12 +1,17 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/file.dart'; import 'package:island/models/file.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/screens/posts/compose.dart'; import 'package:island/screens/posts/compose.dart';
import 'package:island/screens/posts/post_detail.dart'; import 'package:island/screens/posts/post_detail.dart';
import 'package:island/services/compose_storage_db.dart'; import 'package:island/services/compose_storage_db.dart';
import 'package:island/services/responsive.dart';
import 'package:island/widgets/content/sheet.dart'; import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_card.dart'; import 'package:island/widgets/post/compose_card.dart';
import 'package:island/widgets/post/compose_shared.dart'; import 'package:island/widgets/post/compose_shared.dart';
@@ -32,16 +37,21 @@ class PostComposeSheet extends HookConsumerWidget {
SnPost? originalPost, SnPost? originalPost,
PostComposeInitialState? initialState, PostComposeInitialState? initialState,
}) { }) {
// Check if editing an article
if (originalPost != null && originalPost.type == 1) {
context.pushNamed('articleEdit', pathParameters: {'id': originalPost.id});
return Future.value(true);
}
return showModalBottomSheet<bool>( return showModalBottomSheet<bool>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
useRootNavigator: true, useRootNavigator: true,
builder: builder: (context) => PostComposeSheet(
(context) => PostComposeSheet( originalPost: originalPost,
originalPost: originalPost, initialState: initialState,
initialState: initialState, isBottomSheet: true,
isBottomSheet: true, ),
),
); );
} }
@@ -52,10 +62,9 @@ class PostComposeSheet extends HookConsumerWidget {
final prompted = useState(false); final prompted = useState(false);
// Fetch full post data if we're editing a post // Fetch full post data if we're editing a post
final fullPostData = final fullPostData = originalPost != null
originalPost != null ? ref.watch(postProvider(originalPost!.id))
? ref.watch(postProvider(originalPost!.id)) : const AsyncValue.data(null);
: const AsyncValue.data(null);
// Use the full post data if available, otherwise fall back to originalPost // Use the full post data if available, otherwise fall back to originalPost
final effectiveOriginalPost = fullPostData.when( final effectiveOriginalPost = fullPostData.when(
@@ -115,7 +124,11 @@ class PostComposeSheet extends HookConsumerWidget {
}, [drafts, prompted.value]); }, [drafts, prompted.value]);
// Dispose state when widget is disposed // Dispose state when widget is disposed
useEffect(() => () => ComposeLogic.dispose(state), []); useEffect(
() =>
() => ComposeLogic.dispose(state),
[],
);
// Helper methods for actions // Helper methods for actions
void showSettingsSheet() { void showSettingsSheet() {
@@ -145,26 +158,31 @@ class PostComposeSheet extends HookConsumerWidget {
IconButton( IconButton(
onPressed: onPressed:
(state.submitting.value || state.currentPublisher.value == null) (state.submitting.value || state.currentPublisher.value == null)
? null ? null
: performSubmit, : performSubmit,
icon: icon: state.submitting.value
state.submitting.value ? SizedBox(
? SizedBox( width: 24,
width: 24, height: 24,
height: 24, child: const CircularProgressIndicator(strokeWidth: 2),
child: const CircularProgressIndicator(strokeWidth: 2), )
) : Icon(
: Icon( effectiveOriginalPost != null ? Symbols.edit : Symbols.upload,
effectiveOriginalPost != null ? Symbols.edit : Symbols.upload, ),
), tooltip: effectiveOriginalPost != null
tooltip: ? 'postUpdate'.tr()
effectiveOriginalPost != null : 'postPublish'.tr(),
? 'postUpdate'.tr()
: 'postPublish'.tr(),
), ),
]; ];
// Tablet will show a virtual keyboard, so we adjust the height factor accordingly
final isTablet =
isWideScreen(context) &&
!kIsWeb &&
(Platform.isAndroid || Platform.isAndroid);
return SheetScaffold( return SheetScaffold(
heightFactor: isTablet ? 0.95 : 0.8,
titleText: 'postCompose'.tr(), titleText: 'postCompose'.tr(),
actions: actions, actions: actions,
child: PostComposeCard( child: PostComposeCard(
@@ -192,29 +210,28 @@ class PostComposeSheet extends HookConsumerWidget {
final restore = await showDialog<bool>( final restore = await showDialog<bool>(
context: ref.context, context: ref.context,
useRootNavigator: true, useRootNavigator: true,
builder: builder: (context) => AlertDialog(
(context) => AlertDialog( title: Text('restoreDraftTitle'.tr()),
title: Text('restoreDraftTitle'.tr()), content: Column(
content: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Text('restoreDraftMessage'.tr()),
Text('restoreDraftMessage'.tr()), const SizedBox(height: 16),
const SizedBox(height: 16), _buildCompactDraftPreview(context, latestDraft),
_buildCompactDraftPreview(context, latestDraft), ],
], ),
), actions: [
actions: [ TextButton(
TextButton( onPressed: () => Navigator.of(context).pop(false),
onPressed: () => Navigator.of(context).pop(false), child: Text('no'.tr()),
child: Text('no'.tr()),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('yes'.tr()),
),
],
), ),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('yes'.tr()),
),
],
),
); );
if (restore == true) { if (restore == true) {
// Delete the old draft // Delete the old draft
@@ -226,10 +243,9 @@ class PostComposeSheet extends HookConsumerWidget {
description: latestDraft.description, description: latestDraft.description,
content: latestDraft.content, content: latestDraft.content,
visibility: latestDraft.visibility, visibility: latestDraft.visibility,
attachments: attachments: latestDraft.attachments
latestDraft.attachments .map((e) => UniversalFile.fromAttachment(e))
.map((e) => UniversalFile.fromAttachment(e)) .toList(),
.toList(),
); );
} }
} }

View File

@@ -0,0 +1,437 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:styled_widget/styled_widget.dart';
class PostItemSkeleton extends StatelessWidget {
final EdgeInsets? padding;
final bool isFullPost;
final bool isShowReference;
final bool isEmbedReply;
final bool isCompact;
final double? borderRadius;
const PostItemSkeleton({
super.key,
this.padding,
this.isFullPost = false,
this.isShowReference = false,
this.isEmbedReply = false,
this.isCompact = false,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
final renderingPadding =
padding ?? const EdgeInsets.symmetric(horizontal: 8, vertical: 8);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: 640),
child: Card(
margin: EdgeInsets.only(bottom: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Gap(renderingPadding.vertical),
_PostHeaderSkeleton(
isFullPost: isFullPost,
isCompact: isCompact,
renderingPadding: renderingPadding,
),
_PostBodySkeleton(
isFullPost: isFullPost,
renderingPadding: renderingPadding,
),
if (isShowReference)
_ReferencedPostWidgetSkeleton(
renderingPadding: renderingPadding,
),
if (isEmbedReply)
_PostReplyPreviewSkeleton(
renderingPadding: renderingPadding,
).padding(horizontal: renderingPadding.horizontal, top: 8),
Gap(renderingPadding.vertical),
],
),
),
),
);
}
}
class _PostHeaderSkeleton extends StatelessWidget {
final bool isFullPost;
final bool isCompact;
final EdgeInsets renderingPadding;
const _PostHeaderSkeleton({
required this.isFullPost,
required this.isCompact,
required this.renderingPadding,
});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 12,
children: [
// Profile picture skeleton
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
shape: BoxShape.circle,
),
),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
spacing: 4,
children: [
// Name skeleton
Container(
height: 16,
width: 120,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
if (!isCompact)
Container(
height: 12,
width: 80,
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
],
),
const Gap(4),
// Timestamp skeleton
Container(
height: 12,
width: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
// Reaction button skeleton
if (!isCompact)
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(18),
),
),
],
),
],
).padding(horizontal: renderingPadding.horizontal, bottom: 4),
);
}
}
class _PostBodySkeleton extends StatelessWidget {
final bool isFullPost;
final EdgeInsets renderingPadding;
const _PostBodySkeleton({
required this.isFullPost,
required this.renderingPadding,
});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title skeleton (if applicable)
if (isFullPost)
Container(
height: 20,
width: 200,
margin: EdgeInsets.only(
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
bottom: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
// Content skeleton
Container(
height: 16,
margin: EdgeInsets.only(
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
bottom: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
Container(
height: 16,
width: 250,
margin: EdgeInsets.only(
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
bottom: 4,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
Container(
height: 16,
width: 180,
margin: EdgeInsets.only(
left: renderingPadding.horizontal,
right: renderingPadding.horizontal,
bottom: 8,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
// Metadata skeleton
Row(
spacing: 8,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
),
Container(
height: 12,
width: 80,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
],
).padding(horizontal: renderingPadding.horizontal + 4, top: 4),
],
),
);
}
}
class _ReferencedPostWidgetSkeleton extends StatelessWidget {
final EdgeInsets renderingPadding;
const _ReferencedPostWidgetSkeleton({required this.renderingPadding});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: renderingPadding.horizontal,
vertical: 8,
),
margin: EdgeInsets.only(
top: 8,
left: renderingPadding.vertical,
right: renderingPadding.vertical,
),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.5),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(8),
),
),
const SizedBox(width: 6),
Container(
height: 12,
width: 60,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
],
),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 14,
width: 100,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
height: 12,
width: 150,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
height: 12,
width: 120,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
],
),
),
);
}
}
class _PostReplyPreviewSkeleton extends StatelessWidget {
final EdgeInsets renderingPadding;
const _PostReplyPreviewSkeleton({required this.renderingPadding});
@override
Widget build(BuildContext context) {
return Skeletonizer(
enabled: true,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 4,
children: [
Container(
height: 14,
width: 80,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
const Gap(8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
shape: BoxShape.circle,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 150,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
height: 10,
width: 100,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(4),
),
),
],
),
),
],
),
],
),
),
);
}
}

View File

@@ -1,79 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/post.dart'; import 'package:island/models/post.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/post/post_list.dart';
import 'package:island/pods/paging.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_creator.dart'; import 'package:island/widgets/post/post_item_creator.dart';
import 'package:island/widgets/post/post_item_skeleton.dart';
part 'post_list.freezed.dart';
@freezed
sealed class PostListQuery with _$PostListQuery {
const factory PostListQuery({
String? pubName,
String? realm,
int? type,
List<String>? categories,
List<String>? tags,
bool? pinned,
@Default(false) bool shuffle,
bool? includeReplies,
bool? mediaOnly,
String? queryTerm,
String? order,
int? periodStart,
int? periodEnd,
@Default(true) bool orderDesc,
}) = _PostListQuery;
}
final postListNotifierProvider = AsyncNotifierProvider.autoDispose
.family<PostListNotifier, List<SnPost>, PostListQuery>(
PostListNotifier.new,
);
class PostListNotifier extends AsyncNotifier<List<SnPost>>
with AsyncPaginationController<SnPost> {
final PostListQuery arg;
PostListNotifier(this.arg);
static const int pageSize = 20;
@override
Future<List<SnPost>> fetch() async {
final client = ref.read(apiClientProvider);
final queryParams = {
'offset': fetchedCount,
'take': pageSize,
'replies': arg.includeReplies,
'orderDesc': arg.orderDesc,
if (arg.shuffle) 'shuffle': arg.shuffle,
if (arg.pubName != null) 'pub': arg.pubName,
if (arg.realm != null) 'realm': arg.realm,
if (arg.type != null) 'type': arg.type,
if (arg.tags != null) 'tags': arg.tags,
if (arg.categories != null) 'categories': arg.categories,
if (arg.pinned != null) 'pinned': arg.pinned,
if (arg.order != null) 'order': arg.order,
if (arg.periodStart != null) 'periodStart': arg.periodStart,
if (arg.periodEnd != null) 'periodEnd': arg.periodEnd,
if (arg.queryTerm != null) 'query': arg.queryTerm,
if (arg.mediaOnly != null) 'media': arg.mediaOnly,
};
final response = await client.get(
'/sphere/posts',
queryParameters: queryParams,
);
totalCount = int.parse(response.headers.value('X-Total') ?? '0');
final List<dynamic> data = response.data;
return data.map((json) => SnPost.fromJson(json)).toList();
}
}
/// Defines which post item widget to use in the list /// Defines which post item widget to use in the list
enum PostItemType { enum PostItemType {
@@ -85,21 +18,7 @@ enum PostItemType {
} }
class SliverPostList extends HookConsumerWidget { class SliverPostList extends HookConsumerWidget {
final String? pubName; final PostListQuery? query;
final String? realm;
final int? type;
final List<String>? categories;
final List<String>? tags;
final bool shuffle;
final bool? pinned;
final bool? includeReplies;
final bool? mediaOnly;
final String? queryTerm;
// Can be "populaurity", other value will be treated as "date"
final String? order;
final int? periodStart;
final int? periodEnd;
final bool? orderDesc;
final PostItemType itemType; final PostItemType itemType;
final Color? backgroundColor; final Color? backgroundColor;
final EdgeInsets? padding; final EdgeInsets? padding;
@@ -107,23 +26,11 @@ class SliverPostList extends HookConsumerWidget {
final Function? onRefresh; final Function? onRefresh;
final Function(SnPost)? onUpdate; final Function(SnPost)? onUpdate;
final double? maxWidth; final double? maxWidth;
final String? queryKey;
const SliverPostList({ const SliverPostList({
super.key, super.key,
this.pubName, this.query,
this.realm,
this.type,
this.categories,
this.tags,
this.shuffle = false,
this.pinned,
this.includeReplies,
this.mediaOnly,
this.queryTerm,
this.order,
this.orderDesc = true,
this.periodStart,
this.periodEnd,
this.itemType = PostItemType.regular, this.itemType = PostItemType.regular,
this.backgroundColor, this.backgroundColor,
this.padding, this.padding,
@@ -131,34 +38,37 @@ class SliverPostList extends HookConsumerWidget {
this.onRefresh, this.onRefresh,
this.onUpdate, this.onUpdate,
this.maxWidth, this.maxWidth,
this.queryKey,
}); });
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final params = PostListQuery( final provider = postListProvider(
pubName: pubName, PostListQueryConfig(
realm: realm, id: queryKey,
type: type, initialFilter: query ?? PostListQuery(),
categories: categories, ),
tags: tags,
shuffle: shuffle,
pinned: pinned,
includeReplies: includeReplies,
mediaOnly: mediaOnly,
queryTerm: queryTerm,
order: order,
periodStart: periodStart,
periodEnd: periodEnd,
orderDesc: orderDesc ?? true,
); );
final provider = postListNotifierProvider(params); final notifier = ref.watch(provider.notifier);
final notifier = provider.notifier;
final currentFilter = useState(query ?? PostListQuery());
useEffect(() {
if (currentFilter.value != query) {
notifier.applyFilter(query ?? PostListQuery());
}
return null;
}, [query, queryKey]);
return PaginationList( return PaginationList(
provider: provider, provider: provider,
notifier: notifier, notifier: provider.notifier,
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) { itemBuilder: (context, index, post) {
if (maxWidth != null) { if (maxWidth != null) {
return Center( return Center(

View File

@@ -113,10 +113,7 @@ return $default(_that);case _:
final _that = this; final _that = this;
switch (_that) { switch (_that) {
case _ReactionListQuery(): case _ReactionListQuery():
return $default(_that);case _: return $default(_that);}
throw StateError('Unexpected subclass');
}
} }
/// A variant of `map` that fallback to returning `null`. /// A variant of `map` that fallback to returning `null`.
/// ///
@@ -175,10 +172,7 @@ return $default(_that.symbol,_that.postId);case _:
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String symbol, String postId) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String symbol, String postId) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _ReactionListQuery(): case _ReactionListQuery():
return $default(_that.symbol,_that.postId);case _: return $default(_that.symbol,_that.postId);}
throw StateError('Unexpected subclass');
}
} }
/// A variant of `when` that fallback to returning `null` /// A variant of `when` that fallback to returning `null`
/// ///

View File

@@ -5,6 +5,7 @@ import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_skeleton.dart';
final postRepliesProvider = AsyncNotifierProvider.autoDispose.family( final postRepliesProvider = AsyncNotifierProvider.autoDispose.family(
PostRepliesNotifier.new, PostRepliesNotifier.new,
@@ -47,11 +48,24 @@ class PostRepliesList extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final provider = postRepliesProvider(postId); final provider = postRepliesProvider(postId);
final skeletonItem = Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: const PostItemSkeleton(),
);
return PaginationList( return PaginationList(
provider: provider, provider: provider,
notifier: provider.notifier, notifier: provider.notifier,
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
footerSkeletonChild: maxWidth == null
? skeletonItem
: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth!),
child: skeletonItem,
),
),
itemBuilder: (context, index, item) { itemBuilder: (context, index, item) {
final contentWidget = Card( final contentWidget = Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4), margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),

View File

@@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -66,6 +68,7 @@ class PostReplyPreview extends HookConsumerWidget {
final bool isOpenable; final bool isOpenable;
final bool isCompact; final bool isCompact;
final bool isAutoload; final bool isAutoload;
final double? itemMaxWidth;
final VoidCallback? onOpen; final VoidCallback? onOpen;
const PostReplyPreview({ const PostReplyPreview({
super.key, super.key,
@@ -73,6 +76,7 @@ class PostReplyPreview extends HookConsumerWidget {
this.isOpenable = false, this.isOpenable = false,
this.isCompact = false, this.isCompact = false,
this.isAutoload = true, this.isAutoload = true,
this.itemMaxWidth,
this.onOpen, this.onOpen,
}); });
@@ -114,39 +118,49 @@ class PostReplyPreview extends HookConsumerWidget {
return null; return null;
}, [parent]); }, [parent]);
final featuredReply = final featuredReply = isOpenable
isOpenable ? null : ref.watch(postFeaturedReplyProvider(parent.id)); ? null
: ref.watch(postFeaturedReplyProvider(parent.id));
final itemWidget = Widget itemBuilder(double maxWidth) {
isOpenable return isOpenable
? Column( ? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final post in posts.value) for (final post in posts.value)
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
InkWell( InkWell(
child: Row( child: ConstrainedBox(
crossAxisAlignment: CrossAxisAlignment.start, constraints: BoxConstraints(maxWidth: maxWidth),
spacing: 8, child: Row(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
ProfilePictureWidget( spacing: 8,
file: post.publisher.picture, children: [
radius: 12, ProfilePictureWidget(
).padding(top: 4), file: post.publisher.picture,
if (post.content?.isNotEmpty ?? false) radius: 12,
Expanded( ).padding(top: 4),
child: MarkdownTextContent( if (post.content?.isNotEmpty ?? false)
content: post.content!, Expanded(
attachments: post.attachments, child: MarkdownTextContent(
).padding(top: 2), content: post.content!,
) attachments: post.attachments,
else ).padding(top: 2),
Expanded( )
child: Text( else
'postHasAttachments', Expanded(
).plural(post.attachments.length), child:
), Text(
], 'postHasAttachments',
style: TextStyle(height: 2),
)
.plural(post.attachments.length)
.padding(top: 2),
),
],
),
), ),
onTap: () { onTap: () {
onOpen?.call(); onOpen?.call();
@@ -162,12 +176,14 @@ class PostReplyPreview extends HookConsumerWidget {
isOpenable: true, isOpenable: true,
isCompact: true, isCompact: true,
isAutoload: false, isAutoload: false,
itemMaxWidth: math.max(maxWidth - 24, 200),
onOpen: onOpen, onOpen: onOpen,
).padding(left: 24), ).padding(left: 24),
], ],
), ),
if (loading.value) if (loading.value)
Row( Row(
mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
SizedBox( SizedBox(
@@ -179,8 +195,9 @@ class PostReplyPreview extends HookConsumerWidget {
], ],
) )
else if (posts.value.length < parent.repliesCount) else if (posts.value.length < parent.repliesCount)
InkWell( GestureDetector(
child: Row( child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8, spacing: 8,
children: [ children: [
const Icon(Symbols.keyboard_arrow_down, size: 20), const Icon(Symbols.keyboard_arrow_down, size: 20),
@@ -193,81 +210,88 @@ class PostReplyPreview extends HookConsumerWidget {
), ),
], ],
) )
: (featuredReply!).map( : (featuredReply!).map(
data: data: (data) => ConstrainedBox(
(data) => Row( constraints: BoxConstraints(maxWidth: maxWidth),
crossAxisAlignment: CrossAxisAlignment.start, child: Row(
spacing: 8, crossAxisAlignment: CrossAxisAlignment.start,
children: [ spacing: 8,
ProfilePictureWidget( children: [
file: data.value?.publisher.picture, ProfilePictureWidget(
radius: 12, file: data.value?.publisher.picture,
).padding(top: 4), radius: 12,
if (data.value?.content?.isNotEmpty ?? false) ).padding(top: 4),
Expanded( if (data.value?.content?.isNotEmpty ?? false)
child: MarkdownTextContent( Expanded(
content: data.value!.content!, child: MarkdownTextContent(
attachments: data.value!.attachments, content: data.value!.content!,
), attachments: data.value!.attachments,
)
else
Expanded(
child: Text(
'postHasAttachments',
).plural(data.value?.attachments.length ?? 0),
), ),
], )
), else
error: Expanded(
(e) => Row( child: Text(
spacing: 8, 'postHasAttachments',
children: [ ).plural(data.value?.attachments.length ?? 0),
const Icon(Symbols.close, size: 18),
Text(e.error.toString()),
],
),
loading:
(_) => Row(
spacing: 8,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(),
), ),
Text('loading').tr(), ],
],
),
);
final contentWidget =
isCompact
? itemWidget
: Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
), ),
borderRadius: BorderRadius.all(Radius.circular(8)),
), ),
child: Column( error: (e) => Row(
crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 8,
spacing: 4,
children: [ children: [
Text('repliesCount') const Icon(Symbols.close, size: 18),
.plural(parent.repliesCount) Text(e.error.toString()),
.fontSize(15) ],
.bold() ),
.padding(horizontal: 5), loading: (_) => Row(
itemWidget, spacing: 8,
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(),
),
Text('loading').tr(),
], ],
), ),
); );
}
return InkWell( final contentWidget = isCompact
borderRadius: const BorderRadius.all(Radius.circular(8)), ? itemBuilder(itemMaxWidth ?? MediaQuery.of(context).size.width)
: Container(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
border: Border.all(
color: Theme.of(context).dividerColor.withOpacity(0.5),
),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
width: double.infinity,
child: LayoutBuilder(
builder: (context, constraints) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text('repliesCount')
.plural(parent.repliesCount)
.fontSize(15)
.bold()
.padding(horizontal: 5),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: itemBuilder(constraints.maxWidth),
),
],
);
},
),
);
return GestureDetector(
onTap: () { onTap: () {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
@@ -479,8 +503,9 @@ class ReferencedPostWidget extends StatelessWidget {
referencePost.description!, referencePost.description!,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: color: Theme.of(
Theme.of(context).colorScheme.onSurfaceVariant, context,
).colorScheme.onSurfaceVariant,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -490,10 +515,9 @@ class ReferencedPostWidget extends StatelessWidget {
content: referencePost.content!, content: referencePost.content!,
textStyle: const TextStyle(fontSize: 14), textStyle: const TextStyle(fontSize: 14),
isSelectable: false, isSelectable: false,
linesMargin: linesMargin: referencePost.type == 0
referencePost.type == 0 ? const EdgeInsets.only(bottom: 4)
? const EdgeInsets.only(bottom: 4) : null,
: null,
attachments: item.attachments, attachments: item.attachments,
).padding(bottom: 4), ).padding(bottom: 4),
if (referencePost.isTruncated) if (referencePost.isTruncated)
@@ -537,11 +561,10 @@ class ReferencedPostWidget extends StatelessWidget {
} }
return content.gestures( return content.gestures(
onTap: onTap: () => context.pushNamed(
() => context.pushNamed( 'postDetail',
'postDetail', pathParameters: {'id': referencePost!.id},
pathParameters: {'id': referencePost!.id}, ),
),
); );
} }
} }
@@ -577,15 +600,14 @@ class PostHeader extends StatelessWidget {
spacing: 12, spacing: 12,
children: [ children: [
GestureDetector( GestureDetector(
onTap: onTap: isInteractive
isInteractive ? () {
? () { context.pushNamed(
context.pushNamed( 'publisherProfile',
'publisherProfile', pathParameters: {'name': item.publisher.name},
pathParameters: {'name': item.publisher.name}, );
); }
} : null,
: null,
child: ProfilePictureWidget( child: ProfilePictureWidget(
file: file:
item.publisher.picture ?? item.publisher.picture ??
@@ -606,19 +628,19 @@ class PostHeader extends StatelessWidget {
Flexible( Flexible(
child: child:
(item.publisher.account != null && (item.publisher.account != null &&
item.publisher.type == 0) item.publisher.type == 0)
? AccountName( ? AccountName(
hideOverlay: hideOverlay, hideOverlay: hideOverlay,
account: item.publisher.account!, account: item.publisher.account!,
textOverride: item.publisher.nick, textOverride: item.publisher.nick,
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontWeight: FontWeight.bold),
hideVerificationMark: true, hideVerificationMark: true,
) )
: Text( : Text(
item.publisher.nick, item.publisher.nick,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
).bold(), ).bold(),
), ),
if (item.publisher.verification != null) if (item.publisher.verification != null)
VerificationMark( VerificationMark(
@@ -627,14 +649,13 @@ class PostHeader extends StatelessWidget {
), ),
if (item.realm == null) if (item.realm == null)
Flexible( Flexible(
child: child: isCompact
isCompact ? const SizedBox.shrink()
? const SizedBox.shrink() : Text(
: Text( '@${item.publisher.name}',
'@${item.publisher.name}', maxLines: 1,
maxLines: 1, overflow: TextOverflow.ellipsis,
overflow: TextOverflow.ellipsis, ).fontSize(11),
).fontSize(11),
) )
else else
...([ ...([
@@ -673,8 +694,8 @@ class PostHeader extends StatelessWidget {
Text( Text(
!isFullPost && isRelativeTime !isFullPost && isRelativeTime
? (item.publishedAt ?? item.createdAt)!.formatRelative( ? (item.publishedAt ?? item.createdAt)!.formatRelative(
context, context,
) )
: (item.publishedAt ?? item.createdAt)!.formatSystem(), : (item.publishedAt ?? item.createdAt)!.formatSystem(),
).fontSize(10), ).fontSize(10),
], ],
@@ -734,15 +755,14 @@ class PostBody extends ConsumerWidget {
const Icon(Symbols.label, size: 16).padding(top: 2), const Icon(Symbols.label, size: 16).padding(top: 2),
for (final tag in isFullPost ? item.tags : item.tags.take(3)) for (final tag in isFullPost ? item.tags : item.tags.take(3))
InkWell( InkWell(
onTap: onTap: isInteractive
isInteractive ? () {
? () { GoRouter.of(context).pushNamed(
GoRouter.of(context).pushNamed( 'postTagDetail',
'postTagDetail', pathParameters: {'slug': tag.slug},
pathParameters: {'slug': tag.slug}, );
); }
} : null,
: null,
child: Text('#${tag.name ?? tag.slug}'), child: Text('#${tag.name ?? tag.slug}'),
), ),
if (!isFullPost && item.tags.length > 3) if (!isFullPost && item.tags.length > 3)
@@ -761,15 +781,14 @@ class PostBody extends ConsumerWidget {
for (final category for (final category
in isFullPost ? item.categories : item.categories.take(2)) in isFullPost ? item.categories : item.categories.take(2))
InkWell( InkWell(
onTap: onTap: isInteractive
isInteractive ? () {
? () { GoRouter.of(context).pushNamed(
GoRouter.of(context).pushNamed( 'postCategoryDetail',
'postCategoryDetail', pathParameters: {'slug': category.slug},
pathParameters: {'slug': category.slug}, );
); }
} : null,
: null,
child: Text(category.categoryDisplayTitle), child: Text(category.categoryDisplayTitle),
), ),
if (!isFullPost && item.categories.length > 2) if (!isFullPost && item.categories.length > 2)
@@ -798,12 +817,11 @@ class PostBody extends ConsumerWidget {
hideOverlay hideOverlay
? text ? text
: Tooltip( : Tooltip(
message: message: !isFullPost && isRelativeTime
!isFullPost && isRelativeTime ? item.editedAt!.formatSystem()
? item.editedAt!.formatSystem() : item.editedAt!.formatRelative(context),
: item.editedAt!.formatRelative(context), child: text,
child: text, ),
),
], ],
), ),
); );
@@ -936,10 +954,9 @@ class PostBody extends ConsumerWidget {
], ],
).padding(bottom: 4), ).padding(bottom: 4),
MarkdownTextContent( MarkdownTextContent(
content: content: item.isTruncated
item.isTruncated ? '${item.content!}...'
? '${item.content!}...' : item.content ?? '',
: item.content ?? '',
isSelectable: isTextSelectable, isSelectable: isTextSelectable,
attachments: item.attachments, attachments: item.attachments,
), ),

View File

@@ -3,22 +3,26 @@ import 'package:flutter/material.dart';
import 'package:flutter_card_swiper/flutter_card_swiper.dart'; import 'package:flutter_card_swiper/flutter_card_swiper.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
const kShufflePostListId = 'shuffle';
class PostShuffleScreen extends HookConsumerWidget { class PostShuffleScreen extends HookConsumerWidget {
const PostShuffleScreen({super.key}); const PostShuffleScreen({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
const params = PostListQuery(shuffle: true); const query = PostListQuery(shuffle: true);
final postListState = ref.watch(postListNotifierProvider(params)); final cfg = PostListQueryConfig(
final postListNotifier = ref.watch( id: kShufflePostListId,
postListNotifierProvider(params).notifier, initialFilter: query,
); );
final postListState = ref.watch(postListProvider(cfg));
final postListNotifier = ref.watch(postListProvider(cfg).notifier);
final cardSwiperController = useMemoized(() => CardSwiperController(), []); final cardSwiperController = useMemoized(() => CardSwiperController(), []);
@@ -46,29 +50,32 @@ class PostShuffleScreen extends HookConsumerWidget {
controller: cardSwiperController, controller: cardSwiperController,
cardsCount: items.length, cardsCount: items.length,
isLoop: false, isLoop: false,
cardBuilder: ( cardBuilder:
context, (
index, context,
horizontalOffsetPercentage, index,
verticalOffsetPercentage, horizontalOffsetPercentage,
) { verticalOffsetPercentage,
return Center( ) {
child: ConstrainedBox( return Center(
constraints: BoxConstraints(maxWidth: 540), child: ConstrainedBox(
child: SingleChildScrollView( constraints: BoxConstraints(maxWidth: 540),
child: Card( child: SingleChildScrollView(
margin: EdgeInsets.zero, child: Card(
child: ClipRRect( margin: EdgeInsets.zero,
borderRadius: const BorderRadius.all( child: ClipRRect(
Radius.circular(8), borderRadius: const BorderRadius.all(
Radius.circular(8),
),
child: PostActionableItem(
item: items[index],
),
),
), ),
child: PostActionableItem(item: items[index]),
), ),
), ),
), );
), },
);
},
onEnd: () async { onEnd: () async {
if (!postListNotifier.fetchedAll) { if (!postListNotifier.fetchedAll) {
postListNotifier.fetchFurther(); postListNotifier.fetchFurther();
@@ -91,24 +98,23 @@ class PostShuffleScreen extends HookConsumerWidget {
bottom: MediaQuery.of(context).padding.bottom, bottom: MediaQuery.of(context).padding.bottom,
), ),
height: kBottomControlHeight, height: kBottomControlHeight,
child: child: Row(
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ IconButton(
IconButton( onPressed: () {
onPressed: () { cardSwiperController.undo();
cardSwiperController.undo(); },
}, icon: const Icon(Symbols.arrow_left_alt),
icon: const Icon(Symbols.arrow_left_alt), ),
), IconButton(
IconButton( onPressed: () {
onPressed: () { cardSwiperController.swipe(CardSwiperDirection.right);
cardSwiperController.swipe(CardSwiperDirection.right); },
}, icon: const Icon(Symbols.arrow_right_alt),
icon: const Icon(Symbols.arrow_right_alt), ),
), ],
], ).padding(all: 8).center(),
).padding(all: 8).center(),
), ),
), ),
], ],

View File

@@ -0,0 +1,348 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/pods/post/post_list.dart';
import 'package:material_symbols_icons/symbols.dart';
class PostFilterWidget extends StatefulWidget {
final TabController categoryTabController;
final PostListQuery initialQuery;
final ValueChanged<PostListQuery> onQueryChanged;
final bool hideSearch;
const PostFilterWidget({
super.key,
required this.categoryTabController,
required this.initialQuery,
required this.onQueryChanged,
this.hideSearch = false,
});
@override
State<PostFilterWidget> createState() => _PostFilterWidgetState();
}
class _PostFilterWidgetState extends State<PostFilterWidget> {
late bool? _includeReplies;
late bool _mediaOnly;
late String? _queryTerm;
late String? _order;
late bool _orderDesc;
late int? _periodStart;
late int? _periodEnd;
late int? _type;
late bool _showAdvancedFilters;
late TextEditingController _searchController;
@override
void initState() {
super.initState();
_includeReplies = widget.initialQuery.includeReplies;
_mediaOnly = widget.initialQuery.mediaOnly ?? false;
_queryTerm = widget.initialQuery.queryTerm;
_order = widget.initialQuery.order;
_orderDesc = widget.initialQuery.orderDesc;
_periodStart = widget.initialQuery.periodStart;
_periodEnd = widget.initialQuery.periodEnd;
_type = widget.initialQuery.type;
_showAdvancedFilters = false;
_searchController = TextEditingController(text: _queryTerm);
widget.categoryTabController.addListener(_onTabChanged);
}
@override
void dispose() {
widget.categoryTabController.removeListener(_onTabChanged);
_searchController.dispose();
super.dispose();
}
void _onTabChanged() {
final tabIndex = widget.categoryTabController.index;
setState(() {
_type = switch (tabIndex) {
1 => 0,
2 => 1,
_ => null,
};
});
_updateQuery();
}
void _updateQuery() {
final newQuery = widget.initialQuery.copyWith(
includeReplies: _includeReplies,
mediaOnly: _mediaOnly,
queryTerm: _queryTerm,
order: _order,
periodStart: _periodStart,
periodEnd: _periodEnd,
orderDesc: _orderDesc,
type: _type,
);
widget.onQueryChanged(newQuery);
}
@override
Widget build(BuildContext context) {
return Card(
margin: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
children: [
TabBar(
controller: widget.categoryTabController,
dividerColor: Colors.transparent,
splashBorderRadius: const BorderRadius.all(Radius.circular(8)),
tabs: [
Tab(text: 'all'.tr()),
Tab(text: 'postTypePost'.tr()),
Tab(text: 'postArticle'.tr()),
],
),
const Divider(height: 1),
Column(
children: [
Row(
children: [
Expanded(
child: CheckboxListTile(
title: Text('reply'.tr()),
value: _includeReplies,
tristate: true,
onChanged: (value) {
// Cycle through: null -> false -> true -> null
setState(() {
if (_includeReplies == null) {
_includeReplies = false;
} else if (_includeReplies == false) {
_includeReplies = true;
} else {
_includeReplies = null;
}
});
_updateQuery();
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.reply),
),
),
Expanded(
child: CheckboxListTile(
title: Text('attachments'.tr()),
value: _mediaOnly,
onChanged: (value) {
setState(() {
if (value != null) {
_mediaOnly = value;
}
});
_updateQuery();
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.attachment),
),
),
],
),
CheckboxListTile(
title: Text('descendingOrder'.tr()),
value: _orderDesc,
onChanged: (value) {
setState(() {
if (value != null) {
_orderDesc = value;
}
});
_updateQuery();
},
dense: true,
controlAffinity: ListTileControlAffinity.leading,
secondary: const Icon(Symbols.sort),
),
],
),
const Divider(height: 1),
ListTile(
title: Text('advancedFilters'.tr()),
leading: const Icon(Symbols.filter_list),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(const Radius.circular(8)),
),
trailing: Icon(
_showAdvancedFilters ? Symbols.expand_less : Symbols.expand_more,
),
onTap: () {
setState(() {
_showAdvancedFilters = !_showAdvancedFilters;
});
},
),
if (_showAdvancedFilters) ...[
const Divider(height: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!widget.hideSearch)
TextField(
controller: _searchController,
decoration: InputDecoration(
labelText: 'search'.tr(),
hintText: 'searchPosts'.tr(),
prefixIcon: const Icon(Symbols.search),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
onChanged: (value) {
setState(() {
_queryTerm = value.isEmpty ? null : value;
});
_updateQuery();
},
),
if (!widget.hideSearch) const Gap(12),
DropdownButtonFormField<String>(
decoration: InputDecoration(
labelText: 'sortBy'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
value: _order,
items: [
DropdownMenuItem(value: 'date', child: Text('date'.tr())),
DropdownMenuItem(
value: 'popularity',
child: Text('popularity'.tr()),
),
],
onChanged: (value) {
setState(() {
_order = value;
});
_updateQuery();
},
),
const Gap(12),
Row(
children: [
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _periodStart != null
? DateTime.fromMillisecondsSinceEpoch(
_periodStart! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
setState(() {
_periodStart =
pickedDate.millisecondsSinceEpoch ~/ 1000;
});
_updateQuery();
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'fromDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
_periodStart != null
? DateTime.fromMillisecondsSinceEpoch(
_periodStart! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
const Gap(8),
Expanded(
child: InkWell(
onTap: () async {
final pickedDate = await showDatePicker(
context: context,
initialDate: _periodEnd != null
? DateTime.fromMillisecondsSinceEpoch(
_periodEnd! * 1000,
)
: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime.now().add(
const Duration(days: 365),
),
);
if (pickedDate != null) {
setState(() {
_periodEnd =
pickedDate.millisecondsSinceEpoch ~/ 1000;
});
_updateQuery();
}
},
child: InputDecorator(
decoration: InputDecoration(
labelText: 'toDate'.tr(),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(
Radius.circular(12),
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
suffixIcon: const Icon(Symbols.calendar_today),
),
child: Text(
_periodEnd != null
? DateTime.fromMillisecondsSinceEpoch(
_periodEnd! * 1000,
).toString().split(' ')[0]
: 'selectDate'.tr(),
),
),
),
),
],
),
],
),
),
],
],
),
);
}
}

View File

@@ -1,11 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/realm.dart'; import 'package:island/models/realm.dart';
import 'package:island/pods/network.dart'; import 'package:island/pods/network.dart';
import 'package:island/pods/paging.dart'; import 'package:island/pods/paging.dart';
import 'package:island/widgets/paging/pagination_list.dart'; import 'package:island/widgets/paging/pagination_list.dart';
import 'package:island/widgets/realm/realm_card.dart'; import 'package:island/widgets/realm/realm_list_tile.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
final realmListNotifierProvider = AsyncNotifierProvider.autoDispose final realmListNotifierProvider = AsyncNotifierProvider.autoDispose
@@ -51,25 +50,12 @@ class SliverRealmList extends HookConsumerWidget {
notifier: provider.notifier, notifier: provider.notifier,
isSliver: true, isSliver: true,
isRefreshable: false, isRefreshable: false,
spacing: 8,
itemBuilder: (context, index, realm) { itemBuilder: (context, index, realm) {
return Column( return ConstrainedBox(
children: [ constraints: const BoxConstraints(maxWidth: 540),
Padding( child: RealmListTile(realm: realm),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ).center();
child:
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 540),
child: RealmCard(realm: realm),
).center(),
),
if (index <
(ref.read(provider).value?.length ?? 0) -
1) // Add gap except for last item? Actually PaginationList handles loading indicator which might look like last item.
// Wait, ref.read(provider).value?.length might change.
// Simpler to just add bottom padding to all, or Gap.
const Gap(8),
],
);
}, },
); );
} }

View File

@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
@@ -10,6 +11,7 @@ import 'package:island/pods/site_files.dart';
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/sites/file_upload_dialog.dart'; import 'package:island/widgets/sites/file_upload_dialog.dart';
import 'package:island/widgets/sites/file_item.dart'; import 'package:island/widgets/sites/file_item.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -53,6 +55,9 @@ class FileManagementSection extends HookConsumerWidget {
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Symbols.upload), icon: const Icon(Symbols.upload),
onSelected: (String choice) async { onSelected: (String choice) async {
if (!kIsWeb) {
await Permission.storage.request();
}
List<File> files = []; List<File> files = [];
List<Map<String, dynamic>>? results; List<Map<String, dynamic>>? results;
if (choice == 'files') { if (choice == 'files') {
@@ -65,17 +70,17 @@ class FileManagementSection extends HookConsumerWidget {
selectedFiles.files.isEmpty) { selectedFiles.files.isEmpty) {
return; // User canceled return; // User canceled
} }
files = files = selectedFiles.files
selectedFiles.files .map((f) => File(f.path!))
.map((f) => File(f.path!)) .toList();
.toList();
} else if (choice == 'folder') { } else if (choice == 'folder') {
final dirPath = final dirPath = await FilePicker.platform
await FilePicker.platform.getDirectoryPath(); .getDirectoryPath();
if (dirPath == null) return; if (dirPath == null) return;
results = await _getFilesRecursive(dirPath); results = await _getFilesRecursive(dirPath);
files = files = results
results.map((m) => m['file'] as File).toList(); .map((m) => m['file'] as File)
.toList();
if (files.isEmpty) { if (files.isEmpty) {
showSnackBar('noFilesFoundInFolder'.tr()); showSnackBar('noFilesFoundInFolder'.tr());
return; return;
@@ -88,51 +93,46 @@ class FileManagementSection extends HookConsumerWidget {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) => FileUploadDialog(
(context) => FileUploadDialog( selectedFiles: files,
selectedFiles: files, site: site,
site: site, relativePaths: results
relativePaths: ?.map((m) => m['relativePath'] as String)
results .toList(),
?.map( onUploadComplete: () {
(m) => m['relativePath'] as String, // Refresh file list
) ref.invalidate(
.toList(), siteFilesProvider(
onUploadComplete: () { siteId: site.id,
// Refresh file list path: currentPath.value,
ref.invalidate( ),
siteFilesProvider( );
siteId: site.id, },
path: currentPath.value, ),
),
);
},
),
); );
}, },
itemBuilder: itemBuilder: (BuildContext context) => [
(BuildContext context) => [ PopupMenuItem<String>(
PopupMenuItem<String>( value: 'files',
value: 'files', child: Row(
child: Row( children: [
children: [ Icon(Symbols.file_copy),
Icon(Symbols.file_copy), Gap(12),
Gap(12), Text('siteFiles'.tr()),
Text('siteFiles'.tr()), ],
], ),
), ),
), PopupMenuItem<String>(
PopupMenuItem<String>( value: 'folder',
value: 'folder', child: Row(
child: Row( children: [
children: [ Icon(Symbols.folder),
Icon(Symbols.folder), Gap(12),
Gap(12), Text('siteFolder'.tr()),
Text('siteFolder'.tr()), ],
], ),
), ),
), ],
],
style: ButtonStyle( style: ButtonStyle(
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
horizontal: -4, horizontal: -4,
@@ -156,19 +156,17 @@ class FileManagementSection extends HookConsumerWidget {
IconButton( IconButton(
icon: Icon(Symbols.arrow_back), icon: Icon(Symbols.arrow_back),
onPressed: () { onPressed: () {
final pathParts = final pathParts = currentPath.value!
currentPath.value! .split('/')
.split('/') .where((part) => part.isNotEmpty)
.where((part) => part.isNotEmpty) .toList();
.toList();
if (pathParts.isEmpty) { if (pathParts.isEmpty) {
currentPath.value = null; currentPath.value = null;
} else { } else {
pathParts.removeLast(); pathParts.removeLast();
currentPath.value = currentPath.value = pathParts.isEmpty
pathParts.isEmpty ? null
? null : pathParts.join('/');
: pathParts.join('/');
} }
}, },
visualDensity: const VisualDensity( visualDensity: const VisualDensity(
@@ -185,11 +183,10 @@ class FileManagementSection extends HookConsumerWidget {
child: Text('siteRoot'.tr()), child: Text('siteRoot'.tr()),
), ),
...() { ...() {
final parts = final parts = currentPath.value!
currentPath.value! .split('/')
.split('/') .where((part) => part.isNotEmpty)
.where((part) => part.isNotEmpty) .toList();
.toList();
final widgets = <Widget>[]; final widgets = <Widget>[];
String currentBuilder = ''; String currentBuilder = '';
for (final part in parts) { for (final part in parts) {
@@ -200,8 +197,8 @@ class FileManagementSection extends HookConsumerWidget {
widgets.addAll([ widgets.addAll([
const Text(' / '), const Text(' / '),
InkWell( InkWell(
onTap: onTap: () =>
() => currentPath.value = pathToSet, currentPath.value = pathToSet,
child: Text(part), child: Text(part),
), ),
]); ]);
@@ -253,33 +250,31 @@ class FileManagementSection extends HookConsumerWidget {
return FileItem( return FileItem(
file: file, file: file,
site: site, site: site,
onNavigateDirectory: onNavigateDirectory: (path) =>
(path) => currentPath.value = path, currentPath.value = path,
); );
}, },
); );
}, },
loading: loading: () =>
() => const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
error: error: (error, stack) => Center(
(error, stack) => Center( child: Column(
child: Column( children: [
children: [ Text('failedToLoadFiles'.tr()),
Text('failedToLoadFiles'.tr()), const Gap(8),
const Gap(8), ElevatedButton(
ElevatedButton( onPressed: () => ref.invalidate(
onPressed: siteFilesProvider(
() => ref.invalidate( siteId: site.id,
siteFilesProvider( path: currentPath.value,
siteId: site.id,
path: currentPath.value,
),
),
child: Text('retry'.tr()),
), ),
], ),
child: Text('retry'.tr()),
), ),
), ],
),
),
), ),
], ],
), ),

View File

@@ -19,64 +19,50 @@ class SiteActionMenu extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return PopupMenuButton<String>( return PopupMenuButton<String>(
itemBuilder: itemBuilder: (context) => [
(context) => [ PopupMenuItem(
PopupMenuItem( value: 'edit',
value: 'edit', child: Row(
child: Row( children: [
children: [ Icon(
Icon( Symbols.edit,
Symbols.edit, color: Theme.of(context).colorScheme.onSurface,
color: Theme.of(context).colorScheme.onSurface,
),
const Gap(16),
Text('edit'.tr()),
],
), ),
), const Gap(16),
const PopupMenuDivider(), Text('edit'.tr()),
PopupMenuItem( ],
value: 'delete', ),
child: Row( ),
children: [ const PopupMenuDivider(),
const Icon(Symbols.delete, color: Colors.red), PopupMenuItem(
const Gap(16), value: 'delete',
Text('delete'.tr()).textColor(Colors.red), child: Row(
], children: [
), const Icon(Symbols.delete, color: Colors.red),
), const Gap(16),
], Text('delete'.tr()).textColor(Colors.red),
],
),
),
],
onSelected: (value) async { onSelected: (value) async {
switch (value) { switch (value) {
case 'edit': case 'edit':
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: builder: (context) =>
(context) => SiteForm(pubName: pubName, siteSlug: site.slug), SiteForm(pubName: pubName, siteSlug: site.slug),
).then((_) { ).then((_) {
// Refresh site data after potential edit // Refresh site data after potential edit
ref.invalidate(publicationSiteDetailProvider(pubName, site.slug)); ref.invalidate(publicationSiteDetailProvider(pubName, site.slug));
}); });
break; break;
case 'delete': case 'delete':
final confirmed = await showDialog<bool>( final confirmed = await showConfirmAlert(
context: context, 'publicationSiteDeleteConfirm'.tr(),
builder: 'deleteSite'.tr(),
(context) => AlertDialog( isDanger: true,
title: Text('deleteSite'.tr()),
content: Text('publicationSiteDeleteConfirm'.tr()),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text('delete'.tr()),
),
],
),
); );
if (confirmed == true) { if (confirmed == true) {

View File

@@ -5,7 +5,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/models/drive_task.dart'; import 'package:island/models/drive_task.dart';
import 'package:island/pods/upload_tasks.dart'; import 'package:island/pods/drive/upload_tasks.dart';
import 'package:island/services/responsive.dart'; import 'package:island/services/responsive.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@@ -223,14 +223,14 @@ class _UploadOverlayContent extends HookConsumerWidget {
if (!isExpanded && activeTasks.isNotEmpty) if (!isExpanded && activeTasks.isNotEmpty)
Text( Text(
_getOverallProgressText(activeTasks), _getOverallProgressText(activeTasks),
style: Theme.of( style: Theme.of(context)
context, .textTheme
).textTheme.bodySmall?.copyWith( .bodySmall
color: ?.copyWith(
Theme.of( color: Theme.of(
context, context,
).colorScheme.onSurfaceVariant, ).colorScheme.onSurfaceVariant,
), ),
), ),
], ],
), ),
@@ -244,10 +244,9 @@ class _UploadOverlayContent extends HookConsumerWidget {
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: _getOverallProgress(activeTasks), value: _getOverallProgress(activeTasks),
strokeWidth: 3, strokeWidth: 3,
backgroundColor: backgroundColor: Theme.of(
Theme.of( context,
context, ).colorScheme.surfaceContainerHighest,
).colorScheme.surfaceContainerHighest,
), ),
), ),
@@ -263,8 +262,8 @@ class _UploadOverlayContent extends HookConsumerWidget {
size: 20, size: 20,
), ),
), ),
onPressed: onPressed: () =>
() => onExpansionChanged?.call(!isExpanded), onExpansionChanged?.call(!isExpanded),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
), ),
@@ -297,20 +296,18 @@ class _UploadOverlayContent extends HookConsumerWidget {
leading: Icon( leading: Icon(
Symbols.clear_all, Symbols.clear_all,
size: 18, size: 18,
color: color: Theme.of(
Theme.of( context,
context, ).colorScheme.onSurfaceVariant,
).colorScheme.onSurfaceVariant,
), ),
onTap: () { onTap: () {
taskNotifier.clearCompletedTasks(); taskNotifier.clearCompletedTasks();
onExpansionChanged?.call(false); onExpansionChanged?.call(false);
}, },
tileColor: tileColor: Theme.of(
Theme.of( context,
context, ).colorScheme.surfaceContainerHighest,
).colorScheme.surfaceContainerHighest,
), ),
), ),
@@ -326,17 +323,17 @@ class _UploadOverlayContent extends HookConsumerWidget {
leading: Icon( leading: Icon(
Symbols.clear_all, Symbols.clear_all,
size: 18, size: 18,
color: color: Theme.of(
Theme.of(context).colorScheme.error, context,
).colorScheme.error,
), ),
onTap: () { onTap: () {
taskNotifier.clearAllTasks(); taskNotifier.clearAllTasks();
onExpansionChanged?.call(false); onExpansionChanged?.call(false);
}, },
tileColor: tileColor: Theme.of(
Theme.of( context,
context, ).colorScheme.surfaceContainerHighest,
).colorScheme.surfaceContainerHighest,
), ),
), ),
@@ -556,8 +553,9 @@ class _UploadTaskTileState extends State<UploadTaskTile>
child: CircularProgressIndicator( child: CircularProgressIndicator(
value: widget.task.progress, value: widget.task.progress,
strokeWidth: 2.5, strokeWidth: 2.5,
backgroundColor: backgroundColor: Theme.of(
Theme.of(context).colorScheme.surfaceContainerHighest, context,
).colorScheme.surfaceContainerHighest,
), ),
), ),
), ),
@@ -604,10 +602,9 @@ class _UploadTaskTileState extends State<UploadTaskTile>
color = Theme.of(context).colorScheme.secondary; color = Theme.of(context).colorScheme.secondary;
break; break;
case DriveTaskStatus.inProgress: case DriveTaskStatus.inProgress:
icon = icon = widget.task.type == 'FileDownload'
widget.task.type == 'FileDownload' ? Symbols.download
? Symbols.download : Symbols.upload;
: Symbols.upload;
color = Theme.of(context).colorScheme.primary; color = Theme.of(context).colorScheme.primary;
break; break;
case DriveTaskStatus.paused: case DriveTaskStatus.paused:

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:island/models/webfeed.dart'; import 'package:island/models/webfeed.dart';
import 'package:island/services/time.dart'; import 'package:island/services/time.dart';
import 'package:material_symbols_icons/symbols.dart';
class WebArticleCard extends StatelessWidget { class WebArticleCard extends StatelessWidget {
final SnWebArticle article; final SnWebArticle article;
@@ -22,9 +23,6 @@ class WebArticleCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return ConstrainedBox( return ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity), constraints: BoxConstraints(maxWidth: maxWidth ?? double.infinity),
child: Card( child: Card(
@@ -32,108 +30,41 @@ class WebArticleCard extends StatelessWidget {
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
onTap: () => _onTap(context), onTap: () => _onTap(context),
child: AspectRatio( child: Column(
aspectRatio: 16 / 9, children: [
child: Stack( if (article.preview?.imageUrl != null)
fit: StackFit.expand, AspectRatio(
children: [ aspectRatio: 16 / 9,
// Image or fallback child: CachedNetworkImage(
article.preview?.imageUrl != null imageUrl: article.preview!.imageUrl!,
? CachedNetworkImage( fit: BoxFit.cover,
imageUrl: article.preview!.imageUrl!, width: double.infinity,
fit: BoxFit.cover, height: double.infinity,
width: double.infinity,
height: double.infinity,
)
: ColoredBox(
color: colorScheme.secondaryContainer,
child: const Center(
child: Icon(
Icons.article_outlined,
size: 48,
color: Colors.white,
),
),
),
// Gradient overlay
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withOpacity(0.7),
],
),
), ),
), ),
// Title ListTile(
Align( isThreeLine: true,
alignment: Alignment.bottomLeft, contentPadding: const EdgeInsets.symmetric(
child: Container( horizontal: 20,
padding: const EdgeInsets.only( vertical: 4,
left: 12,
right: 12,
bottom: 8,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
if (showDetails)
const SizedBox(height: 8)
else
Spacer(),
Text(
article.title,
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
height: 1.3,
),
maxLines: showDetails ? 3 : 1,
overflow: TextOverflow.ellipsis,
),
if (showDetails &&
article.author?.isNotEmpty == true) ...[
const SizedBox(height: 4),
Text(
article.author!,
style: TextStyle(
fontSize: 10,
color: Colors.white.withOpacity(0.9),
fontWeight: FontWeight.w500,
),
),
],
if (showDetails) const Spacer(),
if (showDetails && article.publishedAt != null) ...[
Text(
'${article.publishedAt!.formatSystem()} · ${article.publishedAt!.formatRelative(context)}',
style: const TextStyle(
fontSize: 9,
color: Colors.white70,
),
),
const SizedBox(height: 2),
],
Text(
article.feed?.title ?? 'Unknown Source',
style: const TextStyle(
fontSize: 9,
color: Colors.white70,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
), ),
], trailing: const Icon(Symbols.chevron_right),
), title: Text(article.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${article.createdAt.formatSystem()} · ${article.createdAt.formatRelative(context)}',
),
Text(
article.feed?.title ?? 'Unknown Source',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
), ),
), ),
), ),

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