💄 Redesign the post tags and categories page

This commit is contained in:
2025-12-06 20:40:28 +08:00
parent 36b0f55a47
commit 3ef2f13dd3
8 changed files with 348 additions and 392 deletions

View File

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

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

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

View File

@@ -102,7 +102,7 @@ class ExploreScreen extends HookConsumerWidget {
// Listen for post creation events to refresh activities
useEffect(() {
final subscription = eventBus.on<PostCreatedEvent>().listen((event) {
ref.invalidate(activityListProvider);
ref.read(activityListProvider.notifier).refresh();
});
return subscription.cancel;
}, []);
@@ -183,25 +183,13 @@ class ExploreScreen extends HookConsumerWidget {
children: [
const Icon(Symbols.category),
const Gap(12),
Text('categories').tr(),
Text('categoriesAndTags').tr(),
],
),
onTap: () {
context.pushNamed('postCategories');
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.label),
const Gap(12),
Text('tags').tr(),
],
),
onTap: () {
context.pushNamed('postTags');
},
),
PopupMenuItem(
child: Row(
children: [
@@ -490,25 +478,13 @@ class ExploreScreen extends HookConsumerWidget {
children: [
const Icon(Symbols.category),
const Gap(12),
Text('categories').tr(),
Text('categoriesAndTags').tr(),
],
),
onTap: () {
context.pushNamed('postCategories');
},
),
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.label),
const Gap(12),
Text('tags').tr(),
],
),
onTap: () {
context.pushNamed('postTags');
},
),
PopupMenuItem(
child: Row(
children: [

View File

@@ -2,73 +2,64 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
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';
import 'package:island/pods/post/post_categories.dart';
import 'package:island/widgets/app_scaffold.dart';
import 'package:island/widgets/paging/pagination_list.dart';
import 'package:material_symbols_icons/symbols.dart';
// Post Categories Notifier
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 {
class PostCategoriesListScreen extends HookConsumerWidget {
const PostCategoriesListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
appBar: AppBar(title: const Text('categories').tr()),
body: PaginationList(
return DefaultTabController(
length: 2,
child: AppScaffold(
isNoBackground: false,
appBar: AppBar(
title: const Text('categoriesAndTags').tr(),
bottom: TabBar(
tabs: [
Tab(
child: Text(
'categories'.tr(),
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
),
Tab(
child: Text(
'tags'.tr(),
style: TextStyle(
color: Theme.of(context).appBarTheme.foregroundColor,
),
),
),
],
),
),
body: const TabBarView(children: [_CategoriesTab(), _TagsTab()]),
),
);
}
}
class _CategoriesTab extends ConsumerWidget {
const _CategoriesTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return PaginationList(
provider: postCategoriesProvider,
notifier: postCategoriesProvider.notifier,
footerSkeletonMaxWidth: 640,
padding: EdgeInsets.zero,
itemBuilder: (context, index, category) {
return ListTile(
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 640),
child: ListTile(
leading: const Icon(Symbols.category),
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
trailing: const Icon(Symbols.chevron_right),
@@ -80,26 +71,29 @@ class PostCategoriesListScreen extends ConsumerWidget {
pathParameters: {'slug': category.slug},
);
},
),
),
);
},
),
);
}
}
class PostTagsListScreen extends ConsumerWidget {
const PostTagsListScreen({super.key});
class _TagsTab extends ConsumerWidget {
const _TagsTab();
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppScaffold(
appBar: AppBar(title: const Text('tags').tr()),
body: PaginationList(
return PaginationList(
provider: postTagsProvider,
notifier: postTagsProvider.notifier,
footerSkeletonMaxWidth: 640,
padding: EdgeInsets.zero,
itemBuilder: (context, index, tag) {
return ListTile(
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),
@@ -111,9 +105,10 @@ class PostTagsListScreen extends ConsumerWidget {
pathParameters: {'slug': tag.slug},
);
},
);
},
),
),
);
},
);
}
}

View File

@@ -22,6 +22,7 @@ class PaginationList<T> extends HookConsumerWidget {
final bool showDefaultWidgets;
final EdgeInsets? padding;
final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationList({
super.key,
required this.provider,
@@ -32,6 +33,7 @@ class PaginationList<T> extends HookConsumerWidget {
this.showDefaultWidgets = true,
this.padding,
this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
});
@override
@@ -53,7 +55,9 @@ class PaginationList<T> extends HookConsumerWidget {
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SliverList.list(children: content);
@@ -75,6 +79,7 @@ class PaginationList<T> extends HookConsumerWidget {
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
}
final entry = data.value?[idx];
@@ -102,7 +107,9 @@ class PaginationList<T> extends HookConsumerWidget {
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SizedBox(
@@ -128,6 +135,7 @@ class PaginationList<T> extends HookConsumerWidget {
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
}
final entry = data.value?[idx];
@@ -159,6 +167,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
final bool isSliver;
final bool showDefaultWidgets;
final Widget? footerSkeletonChild;
final double? footerSkeletonMaxWidth;
const PaginationWidget({
super.key,
required this.provider,
@@ -168,6 +177,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
this.isSliver = false,
this.showDefaultWidgets = true,
this.footerSkeletonChild,
this.footerSkeletonMaxWidth,
});
@override
@@ -189,7 +199,9 @@ class PaginationWidget<T> extends HookConsumerWidget {
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SliverList.list(children: content);
@@ -207,6 +219,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
final content = contentBuilder(data.value ?? [], footer);
@@ -229,7 +242,9 @@ class PaginationWidget<T> extends HookConsumerWidget {
).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: footerSkeletonChild ?? const _DefaultSkeletonChild(),
child:
footerSkeletonChild ??
_DefaultSkeletonChild(maxWidth: footerSkeletonMaxWidth),
),
);
return SizedBox(
@@ -250,6 +265,7 @@ class PaginationWidget<T> extends HookConsumerWidget {
noti: noti,
data: data,
skeletonChild: footerSkeletonChild,
skeletonMaxWidth: footerSkeletonMaxWidth,
);
final content = contentBuilder(data.value ?? [], footer);
@@ -272,6 +288,7 @@ class PaginationListFooter<T> extends HookConsumerWidget {
final PaginationController<T> noti;
final AsyncValue<List<T>> data;
final Widget? skeletonChild;
final double? skeletonMaxWidth;
final bool isSliver;
const PaginationListFooter({
@@ -279,6 +296,7 @@ class PaginationListFooter<T> extends HookConsumerWidget {
required this.noti,
required this.data,
this.skeletonChild,
this.skeletonMaxWidth,
this.isSliver = false,
});
@@ -293,7 +311,7 @@ class PaginationListFooter<T> extends HookConsumerWidget {
highlightColor: Theme.of(context).colorScheme.surfaceContainerHighest,
),
containersColor: Theme.of(context).colorScheme.surfaceContainerLow,
child: skeletonChild ?? _DefaultSkeletonChild(),
child: skeletonChild ?? _DefaultSkeletonChild(maxWidth: skeletonMaxWidth),
);
final child = hasBeenVisible.value
? data.isLoading
@@ -322,14 +340,24 @@ class PaginationListFooter<T> extends HookConsumerWidget {
}
class _DefaultSkeletonChild extends StatelessWidget {
const _DefaultSkeletonChild();
final double? maxWidth;
const _DefaultSkeletonChild({this.maxWidth});
@override
Widget build(BuildContext context) {
return ListTile(
final content = ListTile(
title: Text('Some data'),
subtitle: const Text('Subtitle here'),
trailing: const Icon(Icons.ac_unit),
);
if (maxWidth != null) {
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth!),
child: content,
),
);
}
return content;
}
}

View File

@@ -9,33 +9,14 @@ import 'package:island/models/post_category.dart';
import 'package:island/models/post_tag.dart';
import 'package:island/models/realm.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/widgets/content/cloud_files.dart';
import 'package:island/widgets/content/sheet.dart';
import 'package:island/widgets/post/compose_shared.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.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 {
final ComposeState state;
@@ -121,8 +102,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
void showVisibilitySheet() {
showModalBottomSheet(
context: context,
builder:
(context) => SheetScaffold(
builder: (context) => SheetScaffold(
titleText: 'postVisibility'.tr(),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -182,8 +162,8 @@ class ComposeSettingsSheet extends HookConsumerWidget {
borderRadius: BorderRadius.circular(12),
),
),
onTapOutside:
(_) => FocusManager.instance.primaryFocus?.unfocus(),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
// Tags field
@@ -209,8 +189,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
Wrap(
spacing: 8,
runSpacing: 8,
children:
currentTags.map((tag) {
children: currentTags.map((tag) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
@@ -226,8 +205,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
Text(
'#$tag',
style: TextStyle(
color:
Theme.of(
color: Theme.of(
context,
).colorScheme.onPrimary,
fontSize: 14,
@@ -244,8 +222,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
child: Icon(
Icons.close,
size: 16,
color:
Theme.of(
color: Theme.of(
context,
).colorScheme.onPrimary,
),
@@ -274,8 +251,8 @@ class ComposeSettingsSheet extends HookConsumerWidget {
},
);
},
suggestionsCallback:
(pattern) => _fetchTagSuggestions(pattern, ref),
suggestionsCallback: (pattern) =>
_fetchTagSuggestions(pattern, ref),
itemBuilder: (context, suggestion) {
return ListTile(
shape: const RoundedRectangleBorder(
@@ -314,21 +291,17 @@ class ComposeSettingsSheet extends HookConsumerWidget {
),
),
hint: Text('categories'.tr(), style: TextStyle(fontSize: 15)),
items:
(postCategories.value ?? <SnPostCategory>[]).map((item) {
items: (postCategories.value ?? <SnPostCategory>[]).map((item) {
return DropdownMenuItem(
value: item,
enabled: false,
child: StatefulBuilder(
builder: (context, menuSetState) {
final isSelected = state.categories.value.contains(
item,
);
final isSelected = state.categories.value.contains(item);
return InkWell(
onTap: () {
isSelected
? state.categories.value =
state.categories.value
? state.categories.value = state.categories.value
.where((e) => e != item)
.toList()
: state.categories.value = [
@@ -339,9 +312,7 @@ class ComposeSettingsSheet extends HookConsumerWidget {
},
child: Container(
height: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
if (isSelected)

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';