Subscribe to category and tags

This commit is contained in:
2025-08-25 16:15:51 +08:00
parent 96c2f45c85
commit c7f5b63fe5
5 changed files with 346 additions and 50 deletions

View File

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

View File

@@ -829,7 +829,7 @@
"failedToLoadUserInfoNetwork": "这看起来是个网络问题,你可以按下面的按钮来重试",
"failedToLoadUserInfoUnauthorized": "看来您的会话已被注销或不再可用,如果您愿意,您仍然可以再次尝试获取用户信息。",
"okay": "了解",
"postDetails": "帖子详情",
"postDetail": "帖子详情",
"mimeType": "类型",
"fileSize": "大小",
"fileHash": "哈希",

View File

@@ -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<SnPostTag> postTag(Ref ref, String slug) async {
return SnPostTag.fromJson(resp.data);
}
@riverpod
Future<bool> 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<void> _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<void> _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,58 +97,155 @@ 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)),
),
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(
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(),
),
),
),
),
)
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),
],
),
),
],
),
);
}
}

View File

@@ -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<AsyncValue<bool>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'postCategorySubscriptionStatusProvider';
}
/// See also [postCategorySubscriptionStatus].
class PostCategorySubscriptionStatusProvider
extends AutoDisposeFutureProvider<bool> {
/// 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<bool> 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<bool> 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<bool> {
/// The parameter `slug` of this provider.
String get slug;
/// The parameter `isCategory` of this provider.
bool get isCategory;
}
class _PostCategorySubscriptionStatusProviderElement
extends AutoDisposeFutureProviderElement<bool>
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

View File

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