From c7f5b63fe57e9baa26d612bc2e1aaf3bd0bc7a4e Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 25 Aug 2025 16:15:51 +0800 Subject: [PATCH] :sparkles: Subscribe to category and tags --- assets/i18n/en-US.json | 2 +- assets/i18n/zh-CN.json | 2 +- lib/screens/posts/post_category_detail.dart | 240 ++++++++++++++---- lib/screens/posts/post_category_detail.g.dart | 141 ++++++++++ lib/widgets/post/post_list.dart | 11 + 5 files changed, 346 insertions(+), 50 deletions(-) diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 37d6dd53..b440f98a 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -867,7 +867,7 @@ "failedToLoadUserInfoNetwork": "It seems be network issue, you can tap the button below to try again.", "failedToLoadUserInfoUnauthorized": "It seems your session has been logged out or not available anymore, you can still try agian to fetch the user info if you want.", "okay": "Okay", - "postDetails": "Post Details", + "postDetail": "Post Detail", "postCount": { "zero": "No posts", "one": "{} post", diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 8b82c796..94f36d02 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -829,7 +829,7 @@ "failedToLoadUserInfoNetwork": "这看起来是个网络问题,你可以按下面的按钮来重试", "failedToLoadUserInfoUnauthorized": "看来您的会话已被注销或不再可用,如果您愿意,您仍然可以再次尝试获取用户信息。", "okay": "了解", - "postDetails": "帖子详情", + "postDetail": "帖子详情", "mimeType": "类型", "fileSize": "大小", "fileHash": "哈希", diff --git a/lib/screens/posts/post_category_detail.dart b/lib/screens/posts/post_category_detail.dart index 71004503..2a193e2a 100644 --- a/lib/screens/posts/post_category_detail.dart +++ b/lib/screens/posts/post_category_detail.dart @@ -8,6 +8,7 @@ import 'package:island/pods/network.dart'; import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/post/post_list.dart'; import 'package:island/widgets/response.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -27,6 +28,49 @@ Future postTag(Ref ref, String slug) async { return SnPostTag.fromJson(resp.data); } +@riverpod +Future postCategorySubscriptionStatus( + Ref ref, + String slug, + bool isCategory, +) async { + final apiClient = ref.watch(apiClientProvider); + try { + final resp = await apiClient.get( + '/sphere/posts/${isCategory ? 'categories' : 'tags'}/$slug/subscription', + ); + return resp.statusCode == 200; + } catch (_) { + return false; + } +} + +Future _subscribeToCategoryOrTag( + WidgetRef ref, { + required String slug, + required bool isCategory, +}) async { + final apiClient = ref.read(apiClientProvider); + await apiClient.post( + '/sphere/posts/${isCategory ? 'categories' : 'tags'}/$slug/subscribe', + ); + // Invalidate the subscription status to refresh it + ref.invalidate(postCategorySubscriptionStatusProvider(slug, isCategory)); +} + +Future _unsubscribeFromCategoryOrTag( + WidgetRef ref, { + required String slug, + required bool isCategory, +}) async { + final apiClient = ref.read(apiClientProvider); + await apiClient.post( + '/sphere/posts/${isCategory ? 'categories' : 'tags'}/$slug/unsubscribe', + ); + // Invalidate the subscription status to refresh it + ref.invalidate(postCategorySubscriptionStatusProvider(slug, isCategory)); +} + class PostCategoryDetailScreen extends HookConsumerWidget { final String slug; final bool isCategory; @@ -41,6 +85,9 @@ class PostCategoryDetailScreen extends HookConsumerWidget { final postCategory = isCategory ? ref.watch(postCategoryProvider(slug)) : null; final postTag = isCategory ? null : ref.watch(postTagProvider(slug)); + final subscriptionStatus = ref.watch( + postCategorySubscriptionStatusProvider(slug, isCategory), + ); final postFilterTitle = isCategory @@ -50,57 +97,154 @@ class PostCategoryDetailScreen extends HookConsumerWidget { return AppScaffold( isNoBackground: false, appBar: AppBar(title: Text(postFilterTitle).tr()), - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isCategory) - postCategory!.when( - data: - (category) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(category.categoryDisplayTitle).bold().fontSize(15), - Text('A category'), - ], - ).padding(horizontal: 24, vertical: 16), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: () => ref.invalidate(postCategoryProvider(slug)), + body: Expanded( + child: CustomScrollView( + slivers: [ + if (isCategory) + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 540), + child: Card( + margin: EdgeInsets.only(top: 8), + child: postCategory!.when( + data: + (category) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + category.categoryDisplayTitle, + ).bold().fontSize(15), + Text('A category'), + const Gap(8), + subscriptionStatus.when( + data: + (isSubscribed) => + isSubscribed + ? FilledButton.icon( + onPressed: () async { + await _unsubscribeFromCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon( + Symbols.remove_circle, + ), + label: Text('unsubscribe'.tr()), + ) + : FilledButton.icon( + onPressed: () async { + await _subscribeToCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon( + Symbols.add_circle, + ), + label: Text('subscribe'.tr()), + ), + error: + (error, _) => Text( + 'Error loading subscription status', + ), + loading: () => CircularProgressIndicator(), + ), + ], + ).padding(horizontal: 24, vertical: 16), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: + () => ref.invalidate( + postCategoryProvider(slug), + ), + ), + loading: () => ResponseLoadingWidget(), + ), + ), ), - loading: () => ResponseLoadingWidget(), - ) - else - postTag!.when( - data: - (tag) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(tag.name ?? '#${tag.slug}').bold().fontSize(15), - Text('A tag'), - ], - ).padding(horizontal: 24, vertical: 16), - error: - (error, _) => ResponseErrorWidget( - error: error, - onRetry: () => ref.invalidate(postTagProvider(slug)), - ), - loading: () => ResponseLoadingWidget(), - ), - const Divider(height: 1), - Expanded( - child: CustomScrollView( - slivers: [ - const SliverGap(4), - SliverPostList( - categories: isCategory ? [slug] : null, - tags: isCategory ? null : [slug], ), - SliverGap(MediaQuery.of(context).padding.bottom + 8), - ], + ) + else + SliverToBoxAdapter( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 540), + child: Card( + margin: EdgeInsets.only(top: 8), + child: postTag!.when( + data: + (tag) => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tag.name ?? '#${tag.slug}', + ).bold().fontSize(15), + Text('A tag'), + const Gap(8), + subscriptionStatus.when( + data: + (isSubscribed) => + isSubscribed + ? FilledButton.icon( + onPressed: () async { + await _unsubscribeFromCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon( + Symbols.add_circle, + ), + label: Text('unsubscribe'.tr()), + ) + : FilledButton.icon( + onPressed: () async { + await _subscribeToCategoryOrTag( + ref, + slug: slug, + isCategory: isCategory, + ); + }, + icon: const Icon( + Symbols.remove_circle, + ), + label: Text('subscribe'.tr()), + ), + error: + (error, _) => Text( + 'Error loading subscription status', + ), + loading: () => CircularProgressIndicator(), + ), + ], + ).padding(horizontal: 24, vertical: 16), + error: + (error, _) => ResponseErrorWidget( + error: error, + onRetry: + () => ref.invalidate(postTagProvider(slug)), + ), + loading: () => ResponseLoadingWidget(), + ), + ), + ), + ), + ), + const SliverGap(4), + SliverPostList( + categories: isCategory ? [slug] : null, + tags: isCategory ? null : [slug], + maxWidth: 540 + 16, ), - ), - ], + SliverGap(MediaQuery.of(context).padding.bottom + 8), + ], + ), ), ); } diff --git a/lib/screens/posts/post_category_detail.g.dart b/lib/screens/posts/post_category_detail.g.dart index 156b962e..91fa492b 100644 --- a/lib/screens/posts/post_category_detail.g.dart +++ b/lib/screens/posts/post_category_detail.g.dart @@ -266,5 +266,146 @@ class _PostTagProviderElement String get slug => (origin as PostTagProvider).slug; } +String _$postCategorySubscriptionStatusHash() => + r'407dc7fcaeffc461b591b4ee2418811aa4f0a63f'; + +/// See also [postCategorySubscriptionStatus]. +@ProviderFor(postCategorySubscriptionStatus) +const postCategorySubscriptionStatusProvider = + PostCategorySubscriptionStatusFamily(); + +/// See also [postCategorySubscriptionStatus]. +class PostCategorySubscriptionStatusFamily extends Family> { + /// See also [postCategorySubscriptionStatus]. + const PostCategorySubscriptionStatusFamily(); + + /// See also [postCategorySubscriptionStatus]. + PostCategorySubscriptionStatusProvider call(String slug, bool isCategory) { + return PostCategorySubscriptionStatusProvider(slug, isCategory); + } + + @override + PostCategorySubscriptionStatusProvider getProviderOverride( + covariant PostCategorySubscriptionStatusProvider provider, + ) { + return call(provider.slug, provider.isCategory); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'postCategorySubscriptionStatusProvider'; +} + +/// See also [postCategorySubscriptionStatus]. +class PostCategorySubscriptionStatusProvider + extends AutoDisposeFutureProvider { + /// See also [postCategorySubscriptionStatus]. + PostCategorySubscriptionStatusProvider(String slug, bool isCategory) + : this._internal( + (ref) => postCategorySubscriptionStatus( + ref as PostCategorySubscriptionStatusRef, + slug, + isCategory, + ), + from: postCategorySubscriptionStatusProvider, + name: r'postCategorySubscriptionStatusProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$postCategorySubscriptionStatusHash, + dependencies: PostCategorySubscriptionStatusFamily._dependencies, + allTransitiveDependencies: + PostCategorySubscriptionStatusFamily._allTransitiveDependencies, + slug: slug, + isCategory: isCategory, + ); + + PostCategorySubscriptionStatusProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.slug, + required this.isCategory, + }) : super.internal(); + + final String slug; + final bool isCategory; + + @override + Override overrideWith( + FutureOr Function(PostCategorySubscriptionStatusRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: PostCategorySubscriptionStatusProvider._internal( + (ref) => create(ref as PostCategorySubscriptionStatusRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + slug: slug, + isCategory: isCategory, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _PostCategorySubscriptionStatusProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is PostCategorySubscriptionStatusProvider && + other.slug == slug && + other.isCategory == isCategory; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, slug.hashCode); + hash = _SystemHash.combine(hash, isCategory.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin PostCategorySubscriptionStatusRef on AutoDisposeFutureProviderRef { + /// The parameter `slug` of this provider. + String get slug; + + /// The parameter `isCategory` of this provider. + bool get isCategory; +} + +class _PostCategorySubscriptionStatusProviderElement + extends AutoDisposeFutureProviderElement + with PostCategorySubscriptionStatusRef { + _PostCategorySubscriptionStatusProviderElement(super.provider); + + @override + String get slug => (origin as PostCategorySubscriptionStatusProvider).slug; + @override + bool get isCategory => + (origin as PostCategorySubscriptionStatusProvider).isCategory; +} + // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/widgets/post/post_list.dart b/lib/widgets/post/post_list.dart index f5de94e2..fe79503f 100644 --- a/lib/widgets/post/post_list.dart +++ b/lib/widgets/post/post_list.dart @@ -83,6 +83,7 @@ class SliverPostList extends HookConsumerWidget { final bool isOpenable; final Function? onRefresh; final Function(SnPost)? onUpdate; + final double? maxWidth; const SliverPostList({ super.key, @@ -98,6 +99,7 @@ class SliverPostList extends HookConsumerWidget { this.isOpenable = true, this.onRefresh, this.onUpdate, + this.maxWidth, }); @override @@ -139,6 +141,15 @@ class SliverPostList extends HookConsumerWidget { final post = data.items[index]; + if (maxWidth != null) { + return Center( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth!), + child: _buildPostItem(post), + ), + ); + } + return _buildPostItem(post); }, ),