Compare commits

...

17 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
51 changed files with 3136 additions and 3112 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

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

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

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

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

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

@@ -102,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;
}, []); }, []);
@@ -183,25 +183,13 @@ class ExploreScreen extends HookConsumerWidget {
children: [ children: [
const Icon(Symbols.category), const Icon(Symbols.category),
const Gap(12), const Gap(12),
Text('categories').tr(), Text('categoriesAndTags').tr(),
], ],
), ),
onTap: () { onTap: () {
context.pushNamed('postCategories'); context.pushNamed('postCategories');
}, },
), ),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.label),
const Gap(12),
Text('tags').tr(),
],
),
onTap: () {
context.pushNamed('postTags');
},
),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [
@@ -490,25 +478,13 @@ class ExploreScreen extends HookConsumerWidget {
children: [ children: [
const Icon(Symbols.category), const Icon(Symbols.category),
const Gap(12), const Gap(12),
Text('categories').tr(), Text('categoriesAndTags').tr(),
], ],
), ),
onTap: () { onTap: () {
context.pushNamed('postCategories'); context.pushNamed('postCategories');
}, },
), ),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.label),
const Gap(12),
Text('tags').tr(),
],
),
onTap: () {
context.pushNamed('postTags');
},
),
PopupMenuItem( PopupMenuItem(
child: Row( child: Row(
children: [ children: [

View File

@@ -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,
); );
@@ -182,6 +263,7 @@ class NotificationSheet extends HookConsumerWidget {
child: PaginationList( child: PaginationList(
provider: notificationListProvider, provider: notificationListProvider,
notifier: notificationListProvider.notifier, notifier: notificationListProvider.notifier,
footerSkeletonChild: const SkeletonNotificationTile(),
itemBuilder: (context, index, notification) { itemBuilder: (context, index, notification) {
final pfp = notification.meta['pfp'] as String?; final pfp = notification.meta['pfp'] as String?;
final images = notification.meta['images'] as List?; final images = notification.meta['images'] as List?;

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

@@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/extended_refresh_indicator.dart'; import 'package:island/widgets/extended_refresh_indicator.dart';
import 'package:island/widgets/post/post_item.dart'; import 'package:island/widgets/post/post_item.dart';
import 'package:island/widgets/post/post_item_skeleton.dart';
import 'package:island/widgets/posts/post_filter.dart'; import 'package:island/widgets/posts/post_filter.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:island/pods/post/post_list.dart'; import 'package:island/pods/post/post_list.dart';
@@ -79,43 +80,21 @@ class PostSearchScreen extends HookConsumerWidget {
return AppScaffold( return AppScaffold(
isNoBackground: false, isNoBackground: false,
appBar: isWideScreen(context) appBar: AppBar(
? null title: Text('searchPosts'.tr()),
: AppBar( actions: [
title: Row( if (!isWideScreen(context))
children: [ IconButton(
Expanded( icon: Icon(
child: TextField( showFilters.value
controller: searchController, ? Icons.filter_alt
decoration: InputDecoration( : Icons.filter_alt_outlined,
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) {
onSearchChanged(value, skipDebounce: true);
},
autofocus: true,
),
),
IconButton(
icon: Icon(
showFilters.value
? Icons.filter_alt
: Icons.filter_alt_outlined,
),
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
),
],
), ),
onPressed: toggleFilterDisplay,
tooltip: 'toggleFilters'.tr(),
), ),
],
),
body: Consumer( body: Consumer(
builder: (context, ref, child) { builder: (context, ref, child) {
final searchState = ref.watch( final searchState = ref.watch(
@@ -152,10 +131,7 @@ class PostSearchScreen extends HookConsumerWidget {
), ),
), ),
), ),
const SliverGap(16), const SliverGap(12),
if (showFilters.value && !isWideScreen(context))
SliverToBoxAdapter(child: buildFilterPanel()),
// Use PaginationList with isSliver=true
PaginationList( PaginationList(
provider: postListProvider( provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId), PostListQueryConfig(id: kSearchPostListId),
@@ -165,6 +141,12 @@ class PostSearchScreen extends HookConsumerWidget {
).notifier, ).notifier,
isSliver: true, isSliver: true,
isRefreshable: false, isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) { itemBuilder: (context, index, post) {
return Card( return Card(
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
@@ -246,6 +228,28 @@ class PostSearchScreen extends HookConsumerWidget {
) )
: CustomScrollView( : CustomScrollView(
slivers: [ 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) if (showFilters.value)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Center( child: Center(
@@ -255,7 +259,6 @@ class PostSearchScreen extends HookConsumerWidget {
), ),
), ),
), ),
// Use PaginationList with isSliver=true
PaginationList( PaginationList(
provider: postListProvider( provider: postListProvider(
PostListQueryConfig(id: kSearchPostListId), PostListQueryConfig(id: kSearchPostListId),
@@ -265,6 +268,10 @@ class PostSearchScreen extends HookConsumerWidget {
).notifier, ).notifier,
isSliver: true, isSliver: true,
isRefreshable: false, isRefreshable: false,
footerSkeletonChild: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: const PostItemSkeleton(),
),
itemBuilder: (context, index, post) { itemBuilder: (context, index, post) {
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(

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

@@ -1 +0,0 @@

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: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';
@@ -17,21 +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 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.footerSkeletonChild,
this.footerSkeletonMaxWidth,
}); });
@override @override
@@ -39,70 +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 || noti.isLoading) && data.value?.isEmpty == true) { // For sliver cases, avoid animation to prevent complex sliver issues
final content = List<Widget>.generate( if (isSliver) {
10, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
(_) => Skeletonizer( final content = List<Widget>.generate(
enabled: true, 10,
effect: ShimmerEffect( (_) => Skeletonizer(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh, enabled: true,
highlightColor: Theme.of( effect: ShimmerEffect(
context, baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
).colorScheme.surfaceContainerHighest, highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
), ),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow, );
child: footerSkeletonChild ?? const _DefaultSkeletonChild(), return SliverList.list(children: content);
), }
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final listView = SuperSliverList.separated(
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 isSliver
? SliverList.list(children: content) return isRefreshable
: ListView(children: content); ? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView;
} }
if (data.hasError) { // For non-sliver cases, use AnimatedSwitcher for smooth transitions
final content = ResponseErrorWidget( Widget buildContent() {
error: data.error, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
onRetry: noti.refresh, 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 isSliver ? SliverFillRemaining(child: content) : content;
} }
final listView = isSliver return AnimatedSwitcher(
? SuperSliverList.builder( duration: const Duration(milliseconds: 300),
itemCount: (data.value?.length ?? 0) + 1, child: buildContent(),
itemBuilder: (context, idx) { );
if (idx == data.value?.length) {
return PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
);
}
final entry = data.value?[idx];
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,
skeletonChild: footerSkeletonChild,
);
}
final entry = data.value?[idx];
if (entry != null) return itemBuilder(context, idx, entry);
return null;
},
);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: listView)
: listView;
} }
} }
@@ -114,6 +200,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
final bool isSliver; final bool isSliver;
final bool showDefaultWidgets; final bool showDefaultWidgets;
final Widget? footerSkeletonChild; final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationWidget({ const PaginationWidget({
super.key, super.key,
required this.provider, required this.provider,
@@ -123,6 +210,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
this.isSliver = false, this.isSliver = false,
this.showDefaultWidgets = true, this.showDefaultWidgets = true,
this.footerSkeletonChild, this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
}); });
@override @override
@@ -130,44 +218,102 @@ 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 || noti.isLoading) && data.value?.isEmpty == true) { // For sliver cases, avoid animation to prevent complex sliver issues
final content = List<Widget>.generate( if (isSliver) {
10, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
(_) => Skeletonizer( final content = List<Widget>.generate(
enabled: true, 10,
effect: ShimmerEffect( (_) => Skeletonizer(
baseColor: Theme.of(context).colorScheme.surfaceContainerHigh, enabled: true,
highlightColor: Theme.of( effect: ShimmerEffect(
context, baseColor: Theme.of(context).colorScheme.surfaceContainerHigh,
).colorScheme.surfaceContainerHighest, highlightColor: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
), ),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow, );
child: footerSkeletonChild ?? const _DefaultSkeletonChild(), return SliverList.list(children: content);
), }
if (data.hasError) {
final content = ResponseErrorWidget(
error: data.error,
onRetry: noti.refresh,
);
return SliverFillRemaining(child: content);
}
final footer = PaginationListFooter(
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
); );
return isSliver final content = contentBuilder(data.value ?? [], footer);
? SliverList.list(children: content)
: ListView(children: content); return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content;
} }
if (data.hasError) { // For non-sliver cases, use AnimatedSwitcher for smooth transitions
final content = ResponseErrorWidget( Widget buildContent() {
error: data.error, if ((data.isLoading || noti.isLoading) && data.value?.isEmpty == true) {
onRetry: noti.refresh, 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 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 isSliver ? SliverFillRemaining(child: content) : content;
} }
final footer = PaginationListFooter( return AnimatedSwitcher(
noti: noti, duration: const Duration(milliseconds: 300),
data: data, child: buildContent(),
skeletonChild: footerSkeletonChild,
); );
final content = contentBuilder(data.value ?? [], footer);
return isRefreshable
? ExtendedRefreshIndicator(onRefresh: noti.refresh, child: content)
: content;
} }
} }
@@ -175,6 +321,7 @@ 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 Widget? skeletonChild;
final double? skeletonMaxWidth;
final bool isSliver; final bool isSliver;
const PaginationListFooter({ const PaginationListFooter({
@@ -182,6 +329,7 @@ class PaginationListFooter<T> extends HookConsumerWidget {
required this.noti, required this.noti,
required this.data, required this.data,
this.skeletonChild, this.skeletonChild,
this.skeletonMaxWidth,
this.isSliver = false, this.isSliver = false,
}); });
@@ -196,7 +344,7 @@ class PaginationListFooter<T> extends HookConsumerWidget {
highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest, highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest,
), ),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow, containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: skeletonChild ?? _DefaultSkeletonChild(), child: skeletonChild ?? _DefaultSkeletonChild(maxWidth: skeletonMaxWidth),
); );
final child = hasBeenVisible.value final child = hasBeenVisible.value
? data.isLoading ? data.isLoading
@@ -225,14 +373,24 @@ class PaginationListFooter<T> extends HookConsumerWidget {
} }
class _DefaultSkeletonChild extends StatelessWidget { class _DefaultSkeletonChild extends StatelessWidget {
const _DefaultSkeletonChild(); final double? maxWidth;
const _DefaultSkeletonChild({this.maxWidth});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListTile( final content = ListTile(
title: Text('Some data'), title: Text('Some data'),
subtitle: const Text('Subtitle here'), subtitle: const Text('Subtitle here'),
trailing: const Icon(Icons.ac_unit), 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

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

@@ -65,7 +65,10 @@ class SliverPostList extends HookConsumerWidget {
notifier: provider.notifier, notifier: provider.notifier,
isRefreshable: false, isRefreshable: false,
isSliver: true, isSliver: true,
footerSkeletonChild: const PostItemSkeleton(), 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

@@ -48,12 +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: const PostItemSkeleton(), 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,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

@@ -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,
),
],
),
),
],
), ),
), ),
), ),

View File

@@ -493,26 +493,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift name: drift
sha256: "83290a32ae006a7535c5ecf300722cb77177250d9df4ee2becc5fa8a36095114" sha256: "3669e1b68d7bffb60192ac6ba9fd2c0306804d7a00e5879f6364c69ecde53a7f"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.29.0" version: "2.30.0"
drift_dev: drift_dev:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: drift_dev name: drift_dev
sha256: "6019f827544e77524ffd5134ae0cb75dfd92ef5ef3e269872af92840c929cd43" sha256: afe4d1d2cfce6606c86f11a6196e974a2ddbfaa992956ce61e054c9b1899c769
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.29.0" version: "2.30.0"
drift_flutter: drift_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: drift_flutter name: drift_flutter
sha256: b7534bf320aac5213259aac120670ba67b63a1fd010505babc436ff86083818f sha256: c07120854742a0cae2f7501a0da02493addde550db6641d284983c08762e60a7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.7" version: "0.2.8"
dropdown_button2: dropdown_button2:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1917,6 +1917,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.0+3" version: "3.1.0+3"
permission_handler:
dependency: "direct main"
description:
name: permission_handler
sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1
url: "https://pub.dev"
source: hosted
version: "12.0.1"
permission_handler_android:
dependency: transitive
description:
name: permission_handler_android
sha256: "1e3bc410ca1bf84662104b100eb126e066cb55791b7451307f9708d4007350e6"
url: "https://pub.dev"
source: hosted
version: "13.0.1"
permission_handler_apple:
dependency: transitive
description:
name: permission_handler_apple
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
name: permission_handler_html
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
url: "https://pub.dev"
source: hosted
version: "0.1.3+5"
permission_handler_platform_interface:
dependency: transitive
description:
name: permission_handler_platform_interface
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
url: "https://pub.dev"
source: hosted
version: "4.3.0"
permission_handler_windows:
dependency: transitive
description:
name: permission_handler_windows
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@@ -2607,10 +2655,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: sqlparser name: sqlparser
sha256: "54eea43e36dd3769274c3108625f9ea1a382f8d2ac8b16f3e4589d9bd9b0e16c" sha256: "162435ede92bcc793ea939fdc0452eef0a73d11f8ed053b58a89792fba749da5"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.42.0" version: "0.42.1"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:
@@ -2783,42 +2831,42 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker name: talker
sha256: "5ab800e29d91ce7728fa218c8a7d46f6c228202ac89af650c3f82cb938a64ba6" sha256: "1f94c986b282ccb4efdb3bf0b2549bf314aa20c9e35cbde2c8111d6dd31a7b9a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.3" version: "5.1.4"
talker_dio_logger: talker_dio_logger:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_dio_logger name: talker_dio_logger
sha256: "214a31d2ecc488ae6abf623ca9dac3831d34e66195f633bd1909a9d0c282ab8c" sha256: "775cad84e935f3c47b7cbfb07f1ab459850b2df28893eb798b1d537eea9feb34"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.3" version: "5.1.4"
talker_flutter: talker_flutter:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_flutter name: talker_flutter
sha256: "79158cf0fe3fd2bcdb1dc6f5a870cb623f18d0b59a4fd87414f53ce446d80a45" sha256: "60350aef6b2f5f66e9d1350dc6ab60f9800ab2c4904912f635899dae64f44229"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.3" version: "5.1.4"
talker_logger: talker_logger:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_logger name: talker_logger
sha256: bc75612ace4dbb82fbad36181ff27e95b1ee152c719c2fea6b8ac59c4f091cd2 sha256: c2d58daa0d99518c83b4941967e7527dc4d98328b145bbf07a638bbaf4f0e039
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.3" version: "5.1.4"
talker_riverpod_logger: talker_riverpod_logger:
dependency: "direct main" dependency: "direct main"
description: description:
name: talker_riverpod_logger name: talker_riverpod_logger
sha256: "7040a9e4efcd6b41a169b607bfd1db6cbe0a22182659006979531f63155a1426" sha256: "3b236ee086f5f2943da997b70f03e42ae4f9a0c155b35491f9e94a239a9908ec"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.3" version: "5.1.4"
term_glyph: term_glyph:
dependency: transitive dependency: transitive
description: description:

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 3.5.0+150 version: 3.5.0+151
environment: environment:
sdk: ^3.8.0 sdk: ^3.8.0
@@ -84,8 +84,8 @@ dependencies:
firebase_core: ^4.2.1 firebase_core: ^4.2.1
web_socket_channel: ^3.0.3 web_socket_channel: ^3.0.3
material_symbols_icons: ^4.2892.0 material_symbols_icons: ^4.2892.0
drift: ^2.29.0 drift: ^2.30.0
drift_flutter: ^0.2.7 drift_flutter: ^0.2.8
path: ^1.9.1 path: ^1.9.1
collection: ^1.19.1 collection: ^1.19.1
markdown_editor_plus: ^0.2.15 markdown_editor_plus: ^0.2.15
@@ -154,11 +154,11 @@ dependencies:
dart_ipc: ^1.0.1 dart_ipc: ^1.0.1
pretty_diff_text: ^2.1.0 pretty_diff_text: ^2.1.0
window_manager: ^0.5.1 window_manager: ^0.5.1
talker: ^5.1.3 talker: ^5.1.4
talker_flutter: ^5.1.3 talker_flutter: ^5.1.4
talker_logger: ^5.1.3 talker_logger: ^5.1.4
talker_dio_logger: ^5.1.3 talker_dio_logger: ^5.1.4
talker_riverpod_logger: ^5.1.3 talker_riverpod_logger: ^5.1.4
syncfusion_flutter_pdfviewer: ^31.1.21 syncfusion_flutter_pdfviewer: ^31.1.21
swipe_to: ^1.0.6 swipe_to: ^1.0.6
fl_heatmap: ^0.4.6 fl_heatmap: ^0.4.6
@@ -171,6 +171,7 @@ dependencies:
http_parser: ^4.1.2 http_parser: ^4.1.2
flutter_code_editor: ^0.3.5 flutter_code_editor: ^0.3.5
skeletonizer: ^2.1.1 skeletonizer: ^2.1.1
permission_handler: ^12.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -188,7 +189,7 @@ dev_dependencies:
riverpod_generator: ^3.0.3 riverpod_generator: ^3.0.3
custom_lint: ^0.8.1 custom_lint: ^0.8.1
riverpod_lint: ^3.0.3 riverpod_lint: ^3.0.3
drift_dev: ^2.29.0 drift_dev: ^2.30.0
flutter_launcher_icons: ^0.14.4 flutter_launcher_icons: ^0.14.4
msix: ^3.16.12 msix: ^3.16.12

View File

@@ -24,6 +24,7 @@
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h> #include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
#include <media_kit_video/media_kit_video_plugin_c_api.h> #include <media_kit_video/media_kit_video_plugin_c_api.h>
#include <pasteboard/pasteboard_plugin.h> #include <pasteboard/pasteboard_plugin.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <protocol_handler_windows/protocol_handler_windows_plugin_c_api.h> #include <protocol_handler_windows/protocol_handler_windows_plugin_c_api.h>
#include <record_windows/record_windows_plugin_c_api.h> #include <record_windows/record_windows_plugin_c_api.h>
#include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h> #include <screen_retriever_windows/screen_retriever_windows_plugin_c_api.h>
@@ -73,6 +74,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi")); registry->GetRegistrarForPlugin("MediaKitVideoPluginCApi"));
PasteboardPluginRegisterWithRegistrar( PasteboardPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PasteboardPlugin")); registry->GetRegistrarForPlugin("PasteboardPlugin"));
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
ProtocolHandlerWindowsPluginCApiRegisterWithRegistrar( ProtocolHandlerWindowsPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ProtocolHandlerWindowsPluginCApi")); registry->GetRegistrarForPlugin("ProtocolHandlerWindowsPluginCApi"));
RecordWindowsPluginCApiRegisterWithRegistrar( RecordWindowsPluginCApiRegisterWithRegistrar(

View File

@@ -21,6 +21,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
media_kit_libs_windows_video media_kit_libs_windows_video
media_kit_video media_kit_video
pasteboard pasteboard
permission_handler_windows
protocol_handler_windows protocol_handler_windows
record_windows record_windows
screen_retriever_windows screen_retriever_windows