diff --git a/assets/i18n/en-US.json b/assets/i18n/en-US.json index 3c74c515..edbc9ed5 100644 --- a/assets/i18n/en-US.json +++ b/assets/i18n/en-US.json @@ -854,5 +854,11 @@ "failedToLoadUserInfo": "Failed to load user info", "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" + "okay": "Okay", + "postDetails": "Post Details", + "postCount": { + "zero": "No posts", + "one": "{} post", + "other": "{} posts" + } } diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 668220ed..a76a5271 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -828,5 +828,6 @@ "failedToLoadUserInfo": "加载用户信息失败", "failedToLoadUserInfoNetwork": "这看起来是个网络问题,你可以按下面的按钮来重试", "failedToLoadUserInfoUnauthorized": "看来您的会话已被注销或不再可用,如果您愿意,您仍然可以再次尝试获取用户信息。", - "okay": "了解" + "okay": "了解", + "postDetails": "帖子详情" } diff --git a/lib/models/post_category.dart b/lib/models/post_category.dart index 0232ca64..767f2883 100644 --- a/lib/models/post_category.dart +++ b/lib/models/post_category.dart @@ -15,6 +15,7 @@ sealed class SnPostCategory with _$SnPostCategory { required String slug, String? name, @Default([]) List posts, + @Default(0) int usage, }) = _SnPostCategory; factory SnPostCategory.fromJson(Map json) => diff --git a/lib/models/post_category.freezed.dart b/lib/models/post_category.freezed.dart index 6e6d8276..be1eb454 100644 --- a/lib/models/post_category.freezed.dart +++ b/lib/models/post_category.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$SnPostCategory { - String get id; String get slug; String? get name; List get posts; + String get id; String get slug; String? get name; List get posts; int get usage; /// Create a copy of SnPostCategory /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $SnPostCategoryCopyWith get copyWith => _$SnPostCategoryCopyWith @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts)&&(identical(other.usage, usage) || other.usage == usage)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts)); +int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts),usage); @override String toString() { - return 'SnPostCategory(id: $id, slug: $slug, name: $name, posts: $posts)'; + return 'SnPostCategory(id: $id, slug: $slug, name: $name, posts: $posts, usage: $usage)'; } @@ -48,7 +48,7 @@ abstract mixin class $SnPostCategoryCopyWith<$Res> { factory $SnPostCategoryCopyWith(SnPostCategory value, $Res Function(SnPostCategory) _then) = _$SnPostCategoryCopyWithImpl; @useResult $Res call({ - String id, String slug, String? name, List posts + String id, String slug, String? name, List posts, int usage }); @@ -65,13 +65,14 @@ class _$SnPostCategoryCopyWithImpl<$Res> /// Create a copy of SnPostCategory /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,Object? usage = null,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String?,posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable -as List, +as List,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable +as int, )); } @@ -153,10 +154,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String slug, String? name, List posts)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String slug, String? name, List posts, int usage)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SnPostCategory() when $default != null: -return $default(_that.id,_that.slug,_that.name,_that.posts);case _: +return $default(_that.id,_that.slug,_that.name,_that.posts,_that.usage);case _: return orElse(); } @@ -174,10 +175,10 @@ return $default(_that.id,_that.slug,_that.name,_that.posts);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String slug, String? name, List posts) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String slug, String? name, List posts, int usage) $default,) {final _that = this; switch (_that) { case _SnPostCategory(): -return $default(_that.id,_that.slug,_that.name,_that.posts);} +return $default(_that.id,_that.slug,_that.name,_that.posts,_that.usage);} } /// A variant of `when` that fallback to returning `null` /// @@ -191,10 +192,10 @@ return $default(_that.id,_that.slug,_that.name,_that.posts);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String slug, String? name, List posts)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String slug, String? name, List posts, int usage)? $default,) {final _that = this; switch (_that) { case _SnPostCategory() when $default != null: -return $default(_that.id,_that.slug,_that.name,_that.posts);case _: +return $default(_that.id,_that.slug,_that.name,_that.posts,_that.usage);case _: return null; } @@ -206,7 +207,7 @@ return $default(_that.id,_that.slug,_that.name,_that.posts);case _: @JsonSerializable() class _SnPostCategory extends SnPostCategory { - const _SnPostCategory({required this.id, required this.slug, this.name, final List posts = const []}): _posts = posts,super._(); + const _SnPostCategory({required this.id, required this.slug, this.name, final List posts = const [], this.usage = 0}): _posts = posts,super._(); factory _SnPostCategory.fromJson(Map json) => _$SnPostCategoryFromJson(json); @override final String id; @@ -219,6 +220,7 @@ class _SnPostCategory extends SnPostCategory { return EqualUnmodifiableListView(_posts); } +@override@JsonKey() final int usage; /// Create a copy of SnPostCategory /// with the given fields replaced by the non-null parameter values. @@ -233,16 +235,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPostCategory&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts)&&(identical(other.usage, usage) || other.usage == usage)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts)); +int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts),usage); @override String toString() { - return 'SnPostCategory(id: $id, slug: $slug, name: $name, posts: $posts)'; + return 'SnPostCategory(id: $id, slug: $slug, name: $name, posts: $posts, usage: $usage)'; } @@ -253,7 +255,7 @@ abstract mixin class _$SnPostCategoryCopyWith<$Res> implements $SnPostCategoryCo factory _$SnPostCategoryCopyWith(_SnPostCategory value, $Res Function(_SnPostCategory) _then) = __$SnPostCategoryCopyWithImpl; @override @useResult $Res call({ - String id, String slug, String? name, List posts + String id, String slug, String? name, List posts, int usage }); @@ -270,13 +272,14 @@ class __$SnPostCategoryCopyWithImpl<$Res> /// Create a copy of SnPostCategory /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,Object? usage = null,}) { return _then(_SnPostCategory( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String?,posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable -as List, +as List,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable +as int, )); } diff --git a/lib/models/post_category.g.dart b/lib/models/post_category.g.dart index ced2399c..4b45ebdc 100644 --- a/lib/models/post_category.g.dart +++ b/lib/models/post_category.g.dart @@ -16,6 +16,7 @@ _SnPostCategory _$SnPostCategoryFromJson(Map json) => ?.map((e) => SnPost.fromJson(e as Map)) .toList() ?? const [], + usage: (json['usage'] as num?)?.toInt() ?? 0, ); Map _$SnPostCategoryToJson(_SnPostCategory instance) => @@ -24,4 +25,5 @@ Map _$SnPostCategoryToJson(_SnPostCategory instance) => 'slug': instance.slug, 'name': instance.name, 'posts': instance.posts.map((e) => e.toJson()).toList(), + 'usage': instance.usage, }; diff --git a/lib/models/post_tag.dart b/lib/models/post_tag.dart index 5dae7864..f4b899c3 100644 --- a/lib/models/post_tag.dart +++ b/lib/models/post_tag.dart @@ -11,6 +11,7 @@ sealed class SnPostTag with _$SnPostTag { required String slug, String? name, @Default([]) List posts, + @Default(0) int usage, }) = _SnPostTag; factory SnPostTag.fromJson(Map json) => diff --git a/lib/models/post_tag.freezed.dart b/lib/models/post_tag.freezed.dart index 5436b8cc..c30b29dc 100644 --- a/lib/models/post_tag.freezed.dart +++ b/lib/models/post_tag.freezed.dart @@ -15,7 +15,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$SnPostTag { - String get id; String get slug; String? get name; List get posts; + String get id; String get slug; String? get name; List get posts; int get usage; /// Create a copy of SnPostTag /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -28,16 +28,16 @@ $SnPostTagCopyWith get copyWith => _$SnPostTagCopyWithImpl @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is SnPostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other.posts, posts)&&(identical(other.usage, usage) || other.usage == usage)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts)); +int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(posts),usage); @override String toString() { - return 'SnPostTag(id: $id, slug: $slug, name: $name, posts: $posts)'; + return 'SnPostTag(id: $id, slug: $slug, name: $name, posts: $posts, usage: $usage)'; } @@ -48,7 +48,7 @@ abstract mixin class $SnPostTagCopyWith<$Res> { factory $SnPostTagCopyWith(SnPostTag value, $Res Function(SnPostTag) _then) = _$SnPostTagCopyWithImpl; @useResult $Res call({ - String id, String slug, String? name, List posts + String id, String slug, String? name, List posts, int usage }); @@ -65,13 +65,14 @@ class _$SnPostTagCopyWithImpl<$Res> /// Create a copy of SnPostTag /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,Object? usage = null,}) { return _then(_self.copyWith( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String?,posts: null == posts ? _self.posts : posts // ignore: cast_nullable_to_non_nullable -as List, +as List,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable +as int, )); } @@ -153,10 +154,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String slug, String? name, List posts)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String id, String slug, String? name, List posts, int usage)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _SnPostTag() when $default != null: -return $default(_that.id,_that.slug,_that.name,_that.posts);case _: +return $default(_that.id,_that.slug,_that.name,_that.posts,_that.usage);case _: return orElse(); } @@ -174,10 +175,10 @@ return $default(_that.id,_that.slug,_that.name,_that.posts);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String id, String slug, String? name, List posts) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String id, String slug, String? name, List posts, int usage) $default,) {final _that = this; switch (_that) { case _SnPostTag(): -return $default(_that.id,_that.slug,_that.name,_that.posts);} +return $default(_that.id,_that.slug,_that.name,_that.posts,_that.usage);} } /// A variant of `when` that fallback to returning `null` /// @@ -191,10 +192,10 @@ return $default(_that.id,_that.slug,_that.name,_that.posts);} /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String slug, String? name, List posts)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String id, String slug, String? name, List posts, int usage)? $default,) {final _that = this; switch (_that) { case _SnPostTag() when $default != null: -return $default(_that.id,_that.slug,_that.name,_that.posts);case _: +return $default(_that.id,_that.slug,_that.name,_that.posts,_that.usage);case _: return null; } @@ -206,7 +207,7 @@ return $default(_that.id,_that.slug,_that.name,_that.posts);case _: @JsonSerializable() class _SnPostTag implements SnPostTag { - const _SnPostTag({required this.id, required this.slug, this.name, final List posts = const []}): _posts = posts; + const _SnPostTag({required this.id, required this.slug, this.name, final List posts = const [], this.usage = 0}): _posts = posts; factory _SnPostTag.fromJson(Map json) => _$SnPostTagFromJson(json); @override final String id; @@ -219,6 +220,7 @@ class _SnPostTag implements SnPostTag { return EqualUnmodifiableListView(_posts); } +@override@JsonKey() final int usage; /// Create a copy of SnPostTag /// with the given fields replaced by the non-null parameter values. @@ -233,16 +235,16 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _SnPostTag&&(identical(other.id, id) || other.id == id)&&(identical(other.slug, slug) || other.slug == slug)&&(identical(other.name, name) || other.name == name)&&const DeepCollectionEquality().equals(other._posts, _posts)&&(identical(other.usage, usage) || other.usage == usage)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts)); +int get hashCode => Object.hash(runtimeType,id,slug,name,const DeepCollectionEquality().hash(_posts),usage); @override String toString() { - return 'SnPostTag(id: $id, slug: $slug, name: $name, posts: $posts)'; + return 'SnPostTag(id: $id, slug: $slug, name: $name, posts: $posts, usage: $usage)'; } @@ -253,7 +255,7 @@ abstract mixin class _$SnPostTagCopyWith<$Res> implements $SnPostTagCopyWith<$Re factory _$SnPostTagCopyWith(_SnPostTag value, $Res Function(_SnPostTag) _then) = __$SnPostTagCopyWithImpl; @override @useResult $Res call({ - String id, String slug, String? name, List posts + String id, String slug, String? name, List posts, int usage }); @@ -270,13 +272,14 @@ class __$SnPostTagCopyWithImpl<$Res> /// Create a copy of SnPostTag /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? slug = null,Object? name = freezed,Object? posts = null,Object? usage = null,}) { return _then(_SnPostTag( id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable as String,slug: null == slug ? _self.slug : slug // ignore: cast_nullable_to_non_nullable as String,name: freezed == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String?,posts: null == posts ? _self._posts : posts // ignore: cast_nullable_to_non_nullable -as List, +as List,usage: null == usage ? _self.usage : usage // ignore: cast_nullable_to_non_nullable +as int, )); } diff --git a/lib/models/post_tag.g.dart b/lib/models/post_tag.g.dart index 43b43b4e..9c79d535 100644 --- a/lib/models/post_tag.g.dart +++ b/lib/models/post_tag.g.dart @@ -15,6 +15,7 @@ _SnPostTag _$SnPostTagFromJson(Map json) => _SnPostTag( ?.map((e) => SnPost.fromJson(e as Map)) .toList() ?? const [], + usage: (json['usage'] as num?)?.toInt() ?? 0, ); Map _$SnPostTagToJson(_SnPostTag instance) => @@ -23,4 +24,5 @@ Map _$SnPostTagToJson(_SnPostTag instance) => 'slug': instance.slug, 'name': instance.name, 'posts': instance.posts.map((e) => e.toJson()).toList(), + 'usage': instance.usage, }; diff --git a/lib/route.dart b/lib/route.dart index 0a66634a..89a1dbb6 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -11,6 +11,7 @@ import 'package:island/screens/developers/edit_app.dart'; import 'package:island/screens/developers/new_app.dart'; import 'package:island/screens/developers/hub.dart'; import 'package:island/screens/discovery/articles.dart'; +import 'package:island/screens/posts/post_categories_list.dart'; import 'package:island/screens/posts/post_category_detail.dart'; import 'package:island/screens/posts/post_search.dart'; import 'package:island/widgets/app_wrapper.dart'; @@ -376,12 +377,9 @@ final routerProvider = Provider((ref) { builder: (context, state) => const PostSearchScreen(), ), GoRoute( - name: 'postDetail', - path: '/posts/:id', - builder: (context, state) { - final id = state.pathParameters['id']!; - return PostDetailScreen(id: id); - }, + name: 'postCategories', + path: '/posts/categories', + builder: (context, state) => const PostCategoriesListScreen(), ), GoRoute( name: 'postCategoryDetail', @@ -391,6 +389,11 @@ final routerProvider = Provider((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', @@ -402,6 +405,14 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + name: 'postDetail', + path: '/posts/:id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return PostDetailScreen(id: id); + }, + ), GoRoute( name: 'publisherProfile', path: '/publishers/:name', diff --git a/lib/screens/chat/room_detail.g.dart b/lib/screens/chat/room_detail.g.dart index f5d4711a..54b8247b 100644 --- a/lib/screens/chat/room_detail.g.dart +++ b/lib/screens/chat/room_detail.g.dart @@ -7,7 +7,7 @@ part of 'room_detail.dart'; // ************************************************************************** String _$chatMemberListNotifierHash() => - r'c8fbf4b95df6dae24b1ba21063e9a43351832974'; + r'3ea30150278523e9f6b23f9200ea9a9fbae9c973'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/screens/creators/stickers/stickers.g.dart b/lib/screens/creators/stickers/stickers.g.dart index 82f1adda..cd5a4d4b 100644 --- a/lib/screens/creators/stickers/stickers.g.dart +++ b/lib/screens/creators/stickers/stickers.g.dart @@ -148,7 +148,7 @@ class _StickerPackProviderElement } String _$stickerPacksNotifierHash() => - r'0a8edcf9c35396c411f1214f5e77b1e8fac6a3e6'; + r'30024b35235f3085a5b1ec2204d0a974ee907e22'; abstract class _$StickerPacksNotifier extends BuildlessAutoDisposeAsyncNotifier> { diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index d4847ff7..be1644cd 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -173,12 +173,48 @@ class ExploreScreen extends HookConsumerWidget { ), tooltip: 'webArticlesStand'.tr(), ), - IconButton( - onPressed: () { - context.pushNamed('postSearch'); - }, + PopupMenuButton( + itemBuilder: + (context) => [ + PopupMenuItem( + child: Row( + children: [ + const Icon(Symbols.category), + const Gap(12), + Text('categories').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: [ + const Icon(Symbols.search), + const Gap(12), + Text('search').tr(), + ], + ), + onTap: () { + context.pushNamed('postSearch'); + }, + ), + ], icon: Icon( - Symbols.search, + Symbols.action_key, color: Theme.of(context).appBarTheme.foregroundColor!, ), tooltip: 'search'.tr(), diff --git a/lib/screens/posts/post_categories_list.dart b/lib/screens/posts/post_categories_list.dart new file mode 100644 index 00000000..f0f7393e --- /dev/null +++ b/lib/screens/posts/post_categories_list.dart @@ -0,0 +1,242 @@ +import 'dart:async'; +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/widgets/app_scaffold.dart'; +import 'package:island/widgets/response.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:riverpod_paging_utils/riverpod_paging_utils.dart'; + +// Post Categories Notifier +final postCategoriesNotifierProvider = StateNotifierProvider.autoDispose< + PostCategoriesNotifier, + AsyncValue> +>((ref) { + return PostCategoriesNotifier(ref); +}); + +class PostCategoriesNotifier + extends StateNotifier>> { + final AutoDisposeRef ref; + static const int _pageSize = 20; + bool _isLoading = false; + + PostCategoriesNotifier(this.ref) : super(const AsyncValue.loading()) { + state = const AsyncValue.data( + CursorPagingData(items: [], hasMore: false, nextCursor: null), + ); + fetch(cursor: null); + } + + Future fetch({String? cursor}) async { + if (_isLoading) return; + + _isLoading = true; + if (cursor == null) { + state = const AsyncValue.loading(); + } + + try { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final response = await client.get( + '/sphere/posts/categories', + queryParameters: { + 'offset': offset, + 'take': _pageSize, + 'order': 'usage', + }, + ); + + final data = response.data as List; + final categories = + data.map((json) => SnPostCategory.fromJson(json)).toList(); + final hasMore = categories.length == _pageSize; + final nextCursor = + hasMore ? (offset + categories.length).toString() : null; + + state = AsyncValue.data( + CursorPagingData( + items: [...(state.value?.items ?? []), ...categories], + hasMore: hasMore, + nextCursor: nextCursor, + ), + ); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } finally { + _isLoading = false; + } + } +} + +// Post Tags Notifier +final postTagsNotifierProvider = StateNotifierProvider.autoDispose< + PostTagsNotifier, + AsyncValue> +>((ref) { + return PostTagsNotifier(ref); +}); + +class PostTagsNotifier + extends StateNotifier>> { + final AutoDisposeRef ref; + static const int _pageSize = 20; + bool _isLoading = false; + + PostTagsNotifier(this.ref) : super(const AsyncValue.loading()) { + state = const AsyncValue.data( + CursorPagingData(items: [], hasMore: false, nextCursor: null), + ); + fetch(cursor: null); + } + + Future fetch({String? cursor}) async { + if (_isLoading) return; + + _isLoading = true; + if (cursor == null) { + state = const AsyncValue.loading(); + } + + try { + final client = ref.read(apiClientProvider); + final offset = cursor == null ? 0 : int.parse(cursor); + + final response = await client.get( + '/sphere/posts/tags', + queryParameters: { + 'offset': offset, + 'take': _pageSize, + 'order': 'usage', + }, + ); + + final data = response.data as List; + final tags = data.map((json) => SnPostTag.fromJson(json)).toList(); + final hasMore = tags.length == _pageSize; + final nextCursor = hasMore ? (offset + tags.length).toString() : null; + + state = AsyncValue.data( + CursorPagingData( + items: [...(state.value?.items ?? []), ...tags], + hasMore: hasMore, + nextCursor: nextCursor, + ), + ); + } catch (e, stack) { + state = AsyncValue.error(e, stack); + } finally { + _isLoading = false; + } + } +} + +class PostCategoriesListScreen extends ConsumerWidget { + const PostCategoriesListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final categoriesState = ref.watch(postCategoriesNotifierProvider); + + return AppScaffold( + appBar: AppBar(title: const Text('Categories')), + body: categoriesState.when( + data: (data) { + if (data.items.isEmpty) { + return const Center(child: Text('No categories found')); + } + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: data.items.length + (data.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= data.items.length) { + ref + .read(postCategoriesNotifierProvider.notifier) + .fetch(cursor: data.nextCursor); + return const Center(child: CircularProgressIndicator()); + } + final category = data.items[index]; + return ListTile( + leading: const Icon(Symbols.category), + contentPadding: EdgeInsets.symmetric(horizontal: 24), + trailing: const Icon(Symbols.chevron_right), + title: Text(category.categoryDisplayTitle), + subtitle: Text('postCount'.plural(category.usage)), + onTap: () { + context.pushNamed( + 'postCategoryDetail', + pathParameters: {'slug': category.slug}, + ); + }, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => ResponseErrorWidget( + error: error, + onRetry: () => ref.invalidate(postCategoriesNotifierProvider), + ), + ), + ); + } +} + +class PostTagsListScreen extends ConsumerWidget { + const PostTagsListScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tagsState = ref.watch(postTagsNotifierProvider); + + return AppScaffold( + appBar: AppBar(title: const Text('Tags')), + body: tagsState.when( + data: (data) { + if (data.items.isEmpty) { + return const Center(child: Text('No tags found')); + } + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: data.items.length + (data.hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index >= data.items.length) { + ref + .read(postTagsNotifierProvider.notifier) + .fetch(cursor: data.nextCursor); + return const Center(child: CircularProgressIndicator()); + } + final tag = data.items[index]; + return ListTile( + title: Text(tag.name ?? '#${tag.slug}'), + contentPadding: 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}, + ); + }, + ); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: + (error, stack) => ResponseErrorWidget( + error: error, + onRetry: () => ref.invalidate(postTagsNotifierProvider), + ), + ), + ); + } +} diff --git a/lib/screens/realm/realm_detail.g.dart b/lib/screens/realm/realm_detail.g.dart index 535aa75d..4dd9f0c3 100644 --- a/lib/screens/realm/realm_detail.g.dart +++ b/lib/screens/realm/realm_detail.g.dart @@ -399,7 +399,7 @@ class _RealmChatRoomsProviderElement } String _$realmMemberListNotifierHash() => - r'022bcef5a90cbae05ff23b937851afc3ef913d42'; + r'2f88f803b2e61e7287ed8a43025173e56ff6ca3b'; abstract class _$RealmMemberListNotifier extends BuildlessAutoDisposeAsyncNotifier> { diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index 8bbf398e..989a63b8 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -50,6 +50,6 @@ class AppWrapper extends HookConsumerWidget { } } - return TourTriggerWidget(child: child); + return TourTriggerWidget(key: UniqueKey(), child: child); } } diff --git a/lib/widgets/post/post_item_creator.dart b/lib/widgets/post/post_item_creator.dart index d1152ec5..10a4f61e 100644 --- a/lib/widgets/post/post_item_creator.dart +++ b/lib/widgets/post/post_item_creator.dart @@ -94,7 +94,7 @@ class PostItemCreator extends HookConsumerWidget { borderRadius: BorderRadius.circular(12), onTap: () { if (isOpenable) { - context.pushNamed('postDetail', pathParameters: {'id': item.id}); + context.goNamed('postDetail', pathParameters: {'id': item.id}); } }, child: Padding(