Compare commits

...

5 Commits

Author SHA1 Message Date
2375c46852 Search filtering by categories 2024-12-22 15:57:37 +08:00
fd2eb5cda6 Localized post categories 2024-12-22 15:20:33 +08:00
1256f440bd Show post categories 2024-12-22 15:11:40 +08:00
5b05ca67b6 Editing categories 2024-12-22 14:56:34 +08:00
95af7140cd 💄 Optimize app bar 2024-12-22 13:54:46 +08:00
16 changed files with 929 additions and 300 deletions

View File

@ -57,7 +57,7 @@
"reply": "Reply", "reply": "Reply",
"unset": "Unset", "unset": "Unset",
"untitled": "Untitled", "untitled": "Untitled",
"postDetail": "Post detail", "postDetail": "Post Detail",
"postNoun": "Post", "postNoun": "Post",
"postReadMore": "Read more", "postReadMore": "Read more",
"postReadEstimate": "Est read time {}", "postReadEstimate": "Est read time {}",
@ -139,6 +139,7 @@
"fieldPostTitle": "Title", "fieldPostTitle": "Title",
"fieldPostDescription": "Description", "fieldPostDescription": "Description",
"fieldPostTags": "Tags", "fieldPostTags": "Tags",
"fieldPostCategories": "Categories",
"fieldPostAlias": "Alias", "fieldPostAlias": "Alias",
"fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.", "fieldPostAliasHint": "Optional, used to represent the post in URL, should follow URL-Safe.",
"postPublish": "Publish", "postPublish": "Publish",
@ -477,5 +478,15 @@
"colorSchemeRed": "Red", "colorSchemeRed": "Red",
"colorSchemeWhite": "White", "colorSchemeWhite": "White",
"colorSchemeBlack": "Black", "colorSchemeBlack": "Black",
"colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect." "colorSchemeApplied": "Color scheme has been applied, may need restart the app to take effect.",
"postCategoryTechnology": "Technology",
"postCategoryGaming": "Gaming",
"postCategoryLife": "Life",
"postCategoryArts": "Arts",
"postCategorySports": "Sports",
"postCategoryMusic": "Music",
"postCategoryNews": "News",
"postCategoryKnowledge": "Knowledge",
"postCategoryLiterature": "Literature",
"postCategoryUncategorized": "Uncategorized"
} }

View File

@ -123,6 +123,7 @@
"fieldPostTitle": "标题", "fieldPostTitle": "标题",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "标签", "fieldPostTags": "标签",
"fieldPostCategories": "分类",
"fieldPostAlias": "别名", "fieldPostAlias": "别名",
"fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。", "fieldPostAliasHint": "可选项,用于在 URL 中表示该帖子,应遵循 URL-Safe 的原则。",
"postPublish": "发布", "postPublish": "发布",
@ -416,7 +417,7 @@
"accountStatus": "状态", "accountStatus": "状态",
"accountStatusOnline": "在线", "accountStatusOnline": "在线",
"accountStatusOffline": "离线", "accountStatusOffline": "离线",
"accountStatusLastSeen": "最后一次在 {} 上线", "accountStatusLastSeen": "最后一次上线于 {}",
"postArticle": "Solar Network 上的文章", "postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事", "postStory": "Solar Network 上的故事",
"articleWrittenAt": "发表于 {}", "articleWrittenAt": "发表于 {}",
@ -475,5 +476,15 @@
"colorSchemeRed": "红色", "colorSchemeRed": "红色",
"colorSchemeWhite": "白色", "colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主题色已应用,可能需要重启来生效。" "colorSchemeApplied": "主题色已应用,可能需要重启来生效。",
"postCategoryTechnology": "技术",
"postCategoryGaming": "游戏",
"postCategoryLife": "生活",
"postCategoryArts": "艺术",
"postCategorySports": "体育",
"postCategoryMusic": "音乐",
"postCategoryNews": "新闻",
"postCategoryKnowledge": "知识",
"postCategoryLiterature": "文学",
"postCategoryUncategorized": "未分类"
} }

View File

@ -123,6 +123,7 @@
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類",
"fieldPostAlias": "別名", "fieldPostAlias": "別名",
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "發佈", "postPublish": "發佈",
@ -416,7 +417,7 @@
"accountStatus": "狀態", "accountStatus": "狀態",
"accountStatusOnline": "在線", "accountStatusOnline": "在線",
"accountStatusOffline": "離線", "accountStatusOffline": "離線",
"accountStatusLastSeen": "最後一次在 {} 上線", "accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章", "postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事", "postStory": "Solar Network 上的故事",
"articleWrittenAt": "發表於 {}", "articleWrittenAt": "發表於 {}",
@ -475,5 +476,15 @@
"colorSchemeRed": "紅色", "colorSchemeRed": "紅色",
"colorSchemeWhite": "白色", "colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啓來生效。" "colorSchemeApplied": "主題色已應用,可能需要重啓來生效。",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
"postCategoryArts": "藝術",
"postCategorySports": "體育",
"postCategoryMusic": "音樂",
"postCategoryNews": "新聞",
"postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學",
"postCategoryUncategorized": "未分類"
} }

View File

@ -123,6 +123,7 @@
"fieldPostTitle": "標題", "fieldPostTitle": "標題",
"fieldPostDescription": "描述", "fieldPostDescription": "描述",
"fieldPostTags": "標籤", "fieldPostTags": "標籤",
"fieldPostCategories": "分類",
"fieldPostAlias": "別名", "fieldPostAlias": "別名",
"fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。", "fieldPostAliasHint": "可選項,用於在 URL 中表示該帖子,應遵循 URL-Safe 的原則。",
"postPublish": "釋出", "postPublish": "釋出",
@ -416,7 +417,7 @@
"accountStatus": "狀態", "accountStatus": "狀態",
"accountStatusOnline": "線上", "accountStatusOnline": "線上",
"accountStatusOffline": "離線", "accountStatusOffline": "離線",
"accountStatusLastSeen": "最後一次在 {} 上線", "accountStatusLastSeen": "最後一次上線於 {}",
"postArticle": "Solar Network 上的文章", "postArticle": "Solar Network 上的文章",
"postStory": "Solar Network 上的故事", "postStory": "Solar Network 上的故事",
"articleWrittenAt": "發表於 {}", "articleWrittenAt": "發表於 {}",
@ -475,5 +476,15 @@
"colorSchemeRed": "紅色", "colorSchemeRed": "紅色",
"colorSchemeWhite": "白色", "colorSchemeWhite": "白色",
"colorSchemeBlack": "黑色", "colorSchemeBlack": "黑色",
"colorSchemeApplied": "主題色已應用,可能需要重啟來生效。" "colorSchemeApplied": "主題色已應用,可能需要重啟來生效。",
"postCategoryTechnology": "技術",
"postCategoryGaming": "遊戲",
"postCategoryLife": "生活",
"postCategoryArts": "藝術",
"postCategorySports": "體育",
"postCategoryMusic": "音樂",
"postCategoryNews": "新聞",
"postCategoryKnowledge": "知識",
"postCategoryLiterature": "文學",
"postCategoryUncategorized": "未分類"
} }

View File

@ -178,6 +178,7 @@ class PostWriteController extends ChangeNotifier {
List<int> visibleUsers = List.empty(); List<int> visibleUsers = List.empty();
List<int> invisibleUsers = List.empty(); List<int> invisibleUsers = List.empty();
List<String> tags = List.empty(); List<String> tags = List.empty();
List<String> categories = List.empty();
PostWriteMedia? thumbnail; PostWriteMedia? thumbnail;
List<PostWriteMedia> attachments = List.empty(growable: true); List<PostWriteMedia> attachments = List.empty(growable: true);
DateTime? publishedAt, publishedUntil; DateTime? publishedAt, publishedUntil;
@ -207,6 +208,7 @@ class PostWriteController extends ChangeNotifier {
invisibleUsers = List.from(post.invisibleUsersList ?? []); invisibleUsers = List.from(post.invisibleUsersList ?? []);
visibility = post.visibility; visibility = post.visibility;
tags = List.from(post.tags.map((ele) => ele.alias)); tags = List.from(post.tags.map((ele) => ele.alias));
categories = List.from(post.categories.map((ele) => ele.alias));
attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []); attachments.addAll(post.preload?.attachments?.map((ele) => PostWriteMedia(ele)) ?? []);
if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) { if (post.preload?.thumbnail != null && (post.preload?.thumbnail?.rid.isNotEmpty ?? false)) {
@ -345,6 +347,7 @@ class PostWriteController extends ChangeNotifier {
if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid, if (thumbnail != null && thumbnail!.attachment != null) 'thumbnail': thumbnail!.attachment!.rid,
'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(), 'attachments': attachments.where((e) => e.attachment != null).map((e) => e.attachment!.rid).toList(),
'tags': tags.map((ele) => {'alias': ele}).toList(), 'tags': tags.map((ele) => {'alias': ele}).toList(),
'categories': categories.map((ele) => {'alias': ele}).toList(),
'visibility': visibility, 'visibility': visibility,
'visible_users_list': visibleUsers, 'visible_users_list': visibleUsers,
'invisible_users_list': invisibleUsers, 'invisible_users_list': invisibleUsers,
@ -431,6 +434,11 @@ class PostWriteController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setCategories(List<String> value) {
categories = value;
notifyListeners();
}
void setVisibility(int value) { void setVisibility(int value) {
visibility = value; visibility = value;
notifyListeners(); notifyListeners();
@ -467,6 +475,9 @@ class PostWriteController extends ChangeNotifier {
titleController.clear(); titleController.clear();
descriptionController.clear(); descriptionController.clear();
contentController.clear(); contentController.clear();
aliasController.clear();
tags.clear();
categories.clear();
attachments.clear(); attachments.clear();
editingPost = null; editingPost = null;
replyingPost = null; replyingPost = null;
@ -480,6 +491,7 @@ class PostWriteController extends ChangeNotifier {
contentController.dispose(); contentController.dispose();
titleController.dispose(); titleController.dispose();
descriptionController.dispose(); descriptionController.dispose();
aliasController.dispose();
super.dispose(); super.dispose();
} }
} }

View File

@ -118,12 +118,14 @@ class SnPostContentProvider {
int take = 10, int take = 10,
int offset = 0, int offset = 0,
Iterable<String>? tags, Iterable<String>? tags,
Iterable<String>? categories,
}) async { }) async {
final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: { final resp = await _sn.client.get('/cgi/co/posts/search', queryParameters: {
'take': take, 'take': take,
'offset': offset, 'offset': offset,
'probe': searchTerm, 'probe': searchTerm,
if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','), if (tags?.isNotEmpty ?? false) 'tags': tags!.join(','),
if (categories?.isNotEmpty ?? false) 'categories': categories!.join(','),
}); });
final List<SnPost> out = await _preloadRelatedDataInBatch( final List<SnPost> out = await _preloadRelatedDataInBatch(
List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []), List.from(resp.data['data']?.map((e) => SnPost.fromJson(e)) ?? []),

View File

@ -1,7 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path/path.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:surface/providers/config.dart'; import 'package:surface/providers/config.dart';

View File

@ -228,7 +228,13 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
body: CustomScrollView( body: CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: [ slivers: [
SliverAppBar( Theme(
data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white,
),
),
child: SliverAppBar(
expandedHeight: _appBarHeight, expandedHeight: _appBarHeight,
title: _account == null title: _account == null
? Text('loading').tr() ? Text('loading').tr()
@ -238,7 +244,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
TextSpan( TextSpan(
text: _account!.nick, text: _account!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
), ),
@ -246,7 +252,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
TextSpan( TextSpan(
text: '@${_account!.name}', text: '@${_account!.name}',
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
), ),
@ -288,6 +294,7 @@ class _UserScreenState extends State<UserScreen> with SingleTickerProviderStateM
) )
: null, : null,
), ),
),
if (_account != null) if (_account != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Column( child: Column(

View File

@ -23,6 +23,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
bool _isBusy = false; bool _isBusy = false;
List<String> _searchTags = List.empty(growable: true); List<String> _searchTags = List.empty(growable: true);
List<String> _searchCategories = List.empty(growable: true);
final List<SnPost> _posts = List.empty(growable: true); final List<SnPost> _posts = List.empty(growable: true);
int? _postCount; int? _postCount;
@ -31,7 +32,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
Duration? _lastTook; Duration? _lastTook;
Future<void> _fetchPosts() async { Future<void> _fetchPosts() async {
if (_searchTerm.isEmpty && _searchTags.isEmpty) return; if (_searchTerm.isEmpty && _searchCategories.isEmpty && _searchTags.isEmpty) return;
if (_postCount != null && _posts.length >= _postCount!) return; if (_postCount != null && _posts.length >= _postCount!) return;
setState(() => _isBusy = true); setState(() => _isBusy = true);
@ -45,6 +46,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
take: 10, take: 10,
offset: _posts.length, offset: _posts.length,
tags: _searchTags, tags: _searchTags,
categories: _searchCategories,
); );
final List<SnPost> out = result.$1; final List<SnPost> out = result.$1;
_postCount = result.$2; _postCount = result.$2;
@ -73,9 +75,20 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
setState(() => _searchTags = value); setState(() => _searchTags = value);
}, },
), ),
const Gap(4),
PostCategoriesField(
labelText: 'fieldPostCategories'.tr(),
initialCategories: _searchCategories,
onUpdate: (value) {
setState(() => _searchCategories = value);
},
),
], ],
).padding(horizontal: 24, vertical: 16), ).padding(horizontal: 24, vertical: 16),
); ).then((_) {
_posts.clear();
_fetchPosts();
});
} }
@override @override

View File

@ -277,7 +277,13 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: MultiSliver( sliver: MultiSliver(
children: [ children: [
SliverAppBar( Theme(
data: Theme.of(context).copyWith(
appBarTheme: Theme.of(context).appBarTheme.copyWith(
foregroundColor: Colors.white,
),
),
child: SliverAppBar(
expandedHeight: _appBarHeight, expandedHeight: _appBarHeight,
title: _publisher == null title: _publisher == null
? Text('loading').tr() ? Text('loading').tr()
@ -287,7 +293,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
TextSpan( TextSpan(
text: _publisher!.nick, text: _publisher!.nick,
style: Theme.of(context).textTheme.titleLarge!.copyWith( style: Theme.of(context).textTheme.titleLarge!.copyWith(
color: Theme.of(context).appBarTheme.foregroundColor!, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
), ),
@ -342,6 +348,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTi
) )
: null, : null,
), ),
),
if (_publisher != null) if (_publisher != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Container( child: Container(

View File

@ -19,7 +19,7 @@ class SnPost with _$SnPost {
required String? alias, required String? alias,
required String? aliasPrefix, required String? aliasPrefix,
@Default([]) List<SnPostTag> tags, @Default([]) List<SnPostTag> tags,
@Default([]) List<dynamic> categories, @Default([]) List<SnPostCategory> categories,
required List<SnPost>? replies, required List<SnPost>? replies,
required int? replyId, required int? replyId,
required int? repostId, required int? repostId,
@ -67,6 +67,23 @@ class SnPostTag with _$SnPostTag {
_$SnPostTagFromJson(json); _$SnPostTagFromJson(json);
} }
@freezed
class SnPostCategory with _$SnPostCategory {
const factory SnPostCategory({
required int id,
required DateTime createdAt,
required DateTime updatedAt,
required dynamic deletedAt,
required String alias,
required String name,
required String description,
required dynamic posts,
}) = _SnPostCategory;
factory SnPostCategory.fromJson(Map<String, Object?> json) =>
_$SnPostCategoryFromJson(json);
}
@freezed @freezed
class SnPostPreload with _$SnPostPreload { class SnPostPreload with _$SnPostPreload {
const factory SnPostPreload({ const factory SnPostPreload({

View File

@ -30,7 +30,7 @@ mixin _$SnPost {
String? get alias => throw _privateConstructorUsedError; String? get alias => throw _privateConstructorUsedError;
String? get aliasPrefix => throw _privateConstructorUsedError; String? get aliasPrefix => throw _privateConstructorUsedError;
List<SnPostTag> get tags => throw _privateConstructorUsedError; List<SnPostTag> get tags => throw _privateConstructorUsedError;
List<dynamic> get categories => throw _privateConstructorUsedError; List<SnPostCategory> get categories => throw _privateConstructorUsedError;
List<SnPost>? get replies => throw _privateConstructorUsedError; List<SnPost>? get replies => throw _privateConstructorUsedError;
int? get replyId => throw _privateConstructorUsedError; int? get replyId => throw _privateConstructorUsedError;
int? get repostId => throw _privateConstructorUsedError; int? get repostId => throw _privateConstructorUsedError;
@ -77,7 +77,7 @@ abstract class $SnPostCopyWith<$Res> {
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<SnPostTag> tags, List<SnPostTag> tags,
List<dynamic> categories, List<SnPostCategory> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
int? repostId, int? repostId,
@ -197,7 +197,7 @@ class _$SnPostCopyWithImpl<$Res, $Val extends SnPost>
categories: null == categories categories: null == categories
? _value.categories ? _value.categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostCategory>,
replies: freezed == replies replies: freezed == replies
? _value.replies ? _value.replies
: replies // ignore: cast_nullable_to_non_nullable : replies // ignore: cast_nullable_to_non_nullable
@ -362,7 +362,7 @@ abstract class _$$SnPostImplCopyWith<$Res> implements $SnPostCopyWith<$Res> {
String? alias, String? alias,
String? aliasPrefix, String? aliasPrefix,
List<SnPostTag> tags, List<SnPostTag> tags,
List<dynamic> categories, List<SnPostCategory> categories,
List<SnPost>? replies, List<SnPost>? replies,
int? replyId, int? replyId,
int? repostId, int? repostId,
@ -485,7 +485,7 @@ class __$$SnPostImplCopyWithImpl<$Res>
categories: null == categories categories: null == categories
? _value._categories ? _value._categories
: categories // ignore: cast_nullable_to_non_nullable : categories // ignore: cast_nullable_to_non_nullable
as List<dynamic>, as List<SnPostCategory>,
replies: freezed == replies replies: freezed == replies
? _value._replies ? _value._replies
: replies // ignore: cast_nullable_to_non_nullable : replies // ignore: cast_nullable_to_non_nullable
@ -584,7 +584,7 @@ class _$SnPostImpl extends _SnPost {
required this.alias, required this.alias,
required this.aliasPrefix, required this.aliasPrefix,
final List<SnPostTag> tags = const [], final List<SnPostTag> tags = const [],
final List<dynamic> categories = const [], final List<SnPostCategory> categories = const [],
required final List<SnPost>? replies, required final List<SnPost>? replies,
required this.replyId, required this.replyId,
required this.repostId, required this.repostId,
@ -649,10 +649,10 @@ class _$SnPostImpl extends _SnPost {
return EqualUnmodifiableListView(_tags); return EqualUnmodifiableListView(_tags);
} }
final List<dynamic> _categories; final List<SnPostCategory> _categories;
@override @override
@JsonKey() @JsonKey()
List<dynamic> get categories { List<SnPostCategory> get categories {
if (_categories is EqualUnmodifiableListView) return _categories; if (_categories is EqualUnmodifiableListView) return _categories;
// ignore: implicit_dynamic_type // ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_categories); return EqualUnmodifiableListView(_categories);
@ -853,7 +853,7 @@ abstract class _SnPost extends SnPost {
required final String? alias, required final String? alias,
required final String? aliasPrefix, required final String? aliasPrefix,
final List<SnPostTag> tags, final List<SnPostTag> tags,
final List<dynamic> categories, final List<SnPostCategory> categories,
required final List<SnPost>? replies, required final List<SnPost>? replies,
required final int? replyId, required final int? replyId,
required final int? repostId, required final int? repostId,
@ -899,7 +899,7 @@ abstract class _SnPost extends SnPost {
@override @override
List<SnPostTag> get tags; List<SnPostTag> get tags;
@override @override
List<dynamic> get categories; List<SnPostCategory> get categories;
@override @override
List<SnPost>? get replies; List<SnPost>? get replies;
@override @override
@ -1253,6 +1253,312 @@ abstract class _SnPostTag implements SnPostTag {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
SnPostCategory _$SnPostCategoryFromJson(Map<String, dynamic> json) {
return _SnPostCategory.fromJson(json);
}
/// @nodoc
mixin _$SnPostCategory {
int get id => throw _privateConstructorUsedError;
DateTime get createdAt => throw _privateConstructorUsedError;
DateTime get updatedAt => throw _privateConstructorUsedError;
dynamic get deletedAt => throw _privateConstructorUsedError;
String get alias => throw _privateConstructorUsedError;
String get name => throw _privateConstructorUsedError;
String get description => throw _privateConstructorUsedError;
dynamic get posts => throw _privateConstructorUsedError;
/// Serializes this SnPostCategory to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$SnPostCategoryCopyWith<SnPostCategory> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SnPostCategoryCopyWith<$Res> {
factory $SnPostCategoryCopyWith(
SnPostCategory value, $Res Function(SnPostCategory) then) =
_$SnPostCategoryCopyWithImpl<$Res, SnPostCategory>;
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String alias,
String name,
String description,
dynamic posts});
}
/// @nodoc
class _$SnPostCategoryCopyWithImpl<$Res, $Val extends SnPostCategory>
implements $SnPostCategoryCopyWith<$Res> {
_$SnPostCategoryCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// 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? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? posts = freezed,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
posts: freezed == posts
? _value.posts
: posts // ignore: cast_nullable_to_non_nullable
as dynamic,
) as $Val);
}
}
/// @nodoc
abstract class _$$SnPostCategoryImplCopyWith<$Res>
implements $SnPostCategoryCopyWith<$Res> {
factory _$$SnPostCategoryImplCopyWith(_$SnPostCategoryImpl value,
$Res Function(_$SnPostCategoryImpl) then) =
__$$SnPostCategoryImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{int id,
DateTime createdAt,
DateTime updatedAt,
dynamic deletedAt,
String alias,
String name,
String description,
dynamic posts});
}
/// @nodoc
class __$$SnPostCategoryImplCopyWithImpl<$Res>
extends _$SnPostCategoryCopyWithImpl<$Res, _$SnPostCategoryImpl>
implements _$$SnPostCategoryImplCopyWith<$Res> {
__$$SnPostCategoryImplCopyWithImpl(
_$SnPostCategoryImpl _value, $Res Function(_$SnPostCategoryImpl) _then)
: super(_value, _then);
/// 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? createdAt = null,
Object? updatedAt = null,
Object? deletedAt = freezed,
Object? alias = null,
Object? name = null,
Object? description = null,
Object? posts = freezed,
}) {
return _then(_$SnPostCategoryImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as int,
createdAt: null == createdAt
? _value.createdAt
: createdAt // ignore: cast_nullable_to_non_nullable
as DateTime,
updatedAt: null == updatedAt
? _value.updatedAt
: updatedAt // ignore: cast_nullable_to_non_nullable
as DateTime,
deletedAt: freezed == deletedAt
? _value.deletedAt
: deletedAt // ignore: cast_nullable_to_non_nullable
as dynamic,
alias: null == alias
? _value.alias
: alias // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
description: null == description
? _value.description
: description // ignore: cast_nullable_to_non_nullable
as String,
posts: freezed == posts
? _value.posts
: posts // ignore: cast_nullable_to_non_nullable
as dynamic,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SnPostCategoryImpl implements _SnPostCategory {
const _$SnPostCategoryImpl(
{required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.name,
required this.description,
required this.posts});
factory _$SnPostCategoryImpl.fromJson(Map<String, dynamic> json) =>
_$$SnPostCategoryImplFromJson(json);
@override
final int id;
@override
final DateTime createdAt;
@override
final DateTime updatedAt;
@override
final dynamic deletedAt;
@override
final String alias;
@override
final String name;
@override
final String description;
@override
final dynamic posts;
@override
String toString() {
return 'SnPostCategory(id: $id, createdAt: $createdAt, updatedAt: $updatedAt, deletedAt: $deletedAt, alias: $alias, name: $name, description: $description, posts: $posts)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SnPostCategoryImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.createdAt, createdAt) ||
other.createdAt == createdAt) &&
(identical(other.updatedAt, updatedAt) ||
other.updatedAt == updatedAt) &&
const DeepCollectionEquality().equals(other.deletedAt, deletedAt) &&
(identical(other.alias, alias) || other.alias == alias) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.description, description) ||
other.description == description) &&
const DeepCollectionEquality().equals(other.posts, posts));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
createdAt,
updatedAt,
const DeepCollectionEquality().hash(deletedAt),
alias,
name,
description,
const DeepCollectionEquality().hash(posts));
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith =>
__$$SnPostCategoryImplCopyWithImpl<_$SnPostCategoryImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SnPostCategoryImplToJson(
this,
);
}
}
abstract class _SnPostCategory implements SnPostCategory {
const factory _SnPostCategory(
{required final int id,
required final DateTime createdAt,
required final DateTime updatedAt,
required final dynamic deletedAt,
required final String alias,
required final String name,
required final String description,
required final dynamic posts}) = _$SnPostCategoryImpl;
factory _SnPostCategory.fromJson(Map<String, dynamic> json) =
_$SnPostCategoryImpl.fromJson;
@override
int get id;
@override
DateTime get createdAt;
@override
DateTime get updatedAt;
@override
dynamic get deletedAt;
@override
String get alias;
@override
String get name;
@override
String get description;
@override
dynamic get posts;
/// Create a copy of SnPostCategory
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SnPostCategoryImplCopyWith<_$SnPostCategoryImpl> get copyWith =>
throw _privateConstructorUsedError;
}
SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) { SnPostPreload _$SnPostPreloadFromJson(Map<String, dynamic> json) {
return _SnPostPreload.fromJson(json); return _SnPostPreload.fromJson(json);
} }

View File

@ -22,7 +22,10 @@ _$SnPostImpl _$$SnPostImplFromJson(Map<String, dynamic> json) => _$SnPostImpl(
?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPostTag.fromJson(e as Map<String, dynamic>))
.toList() ?? .toList() ??
const [], const [],
categories: json['categories'] as List<dynamic>? ?? const [], categories: (json['categories'] as List<dynamic>?)
?.map((e) => SnPostCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
replies: (json['replies'] as List<dynamic>?) replies: (json['replies'] as List<dynamic>?)
?.map((e) => SnPost.fromJson(e as Map<String, dynamic>)) ?.map((e) => SnPost.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@ -80,7 +83,7 @@ Map<String, dynamic> _$$SnPostImplToJson(_$SnPostImpl instance) =>
'alias': instance.alias, 'alias': instance.alias,
'alias_prefix': instance.aliasPrefix, 'alias_prefix': instance.aliasPrefix,
'tags': instance.tags.map((e) => e.toJson()).toList(), 'tags': instance.tags.map((e) => e.toJson()).toList(),
'categories': instance.categories, 'categories': instance.categories.map((e) => e.toJson()).toList(),
'replies': instance.replies?.map((e) => e.toJson()).toList(), 'replies': instance.replies?.map((e) => e.toJson()).toList(),
'reply_id': instance.replyId, 'reply_id': instance.replyId,
'repost_id': instance.repostId, 'repost_id': instance.repostId,
@ -127,6 +130,31 @@ Map<String, dynamic> _$$SnPostTagImplToJson(_$SnPostTagImpl instance) =>
'posts': instance.posts, 'posts': instance.posts,
}; };
_$SnPostCategoryImpl _$$SnPostCategoryImplFromJson(Map<String, dynamic> json) =>
_$SnPostCategoryImpl(
id: (json['id'] as num).toInt(),
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
deletedAt: json['deleted_at'],
alias: json['alias'] as String,
name: json['name'] as String,
description: json['description'] as String,
posts: json['posts'],
);
Map<String, dynamic> _$$SnPostCategoryImplToJson(
_$SnPostCategoryImpl instance) =>
<String, dynamic>{
'id': instance.id,
'created_at': instance.createdAt.toIso8601String(),
'updated_at': instance.updatedAt.toIso8601String(),
'deleted_at': instance.deletedAt,
'alias': instance.alias,
'name': instance.name,
'description': instance.description,
'posts': instance.posts,
};
_$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) => _$SnPostPreloadImpl _$$SnPostPreloadImplFromJson(Map<String, dynamic> json) =>
_$SnPostPreloadImpl( _$SnPostPreloadImpl(
thumbnail: json['thumbnail'] == null thumbnail: json['thumbnail'] == null

View File

@ -179,6 +179,7 @@ class PostItem extends StatelessWidget {
children: [ children: [
if (data.visibility > 0) _PostVisibilityHint(data: data), if (data.visibility > 0) _PostVisibilityHint(data: data),
_PostTruncatedHint(data: data), _PostTruncatedHint(data: data),
if (data.tags.isNotEmpty) _PostTagsList(data: data),
], ],
).padding(horizontal: 12), ).padding(horizontal: 12),
const Gap(8), const Gap(8),
@ -186,7 +187,6 @@ class PostItem extends StatelessWidget {
), ),
), ),
Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8), Text('postArticle').tr().fontSize(13).opacity(0.75).padding(horizontal: 24, bottom: 8),
if (data.tags.isNotEmpty) _PostTagsList(data: data).padding(horizontal: 16, bottom: 6),
_PostBottomAction( _PostBottomAction(
data: data, data: data,
showComments: showComments, showComments: showComments,
@ -966,23 +966,55 @@ class _PostTagsList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Wrap( return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 4,
runSpacing: 4,
children: data.categories
.map(
(ele) => InkWell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Symbols.category, size: 20),
const Gap(4),
Text(
'postCategory${ele.alias.capitalize()}'.trExists()
? 'postCategory${ele.alias.capitalize()}'.tr()
: ele.alias,
style: GoogleFonts.robotoMono(),
),
],
),
onTap: () {},
),
)
.toList(),
).opacity(0.8),
Wrap(
spacing: 4, spacing: 4,
runSpacing: 4, runSpacing: 4,
children: data.tags children: data.tags
.map( .map(
(ele) => InkWell( (ele) => InkWell(
child: Text( child: Row(
'#${ele.alias}', mainAxisSize: MainAxisSize.min,
style: TextStyle( children: [
decoration: TextDecoration.underline, const Icon(Symbols.label, size: 20),
const Gap(4),
Text(ele.alias, style: GoogleFonts.robotoMono()),
],
), ),
).fontSize(13),
onTap: () {}, onTap: () {},
), ),
) )
.toList(), .toList(),
).opacity(0.8); ).opacity(0.8),
],
);
} }
} }
@ -1023,6 +1055,7 @@ class _PostTruncatedHint extends StatelessWidget {
return SingleChildScrollView( return SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
spacing: 8,
children: [ children: [
if (data.body['content_length'] != null) if (data.body['content_length'] != null)
Row( Row(
@ -1035,7 +1068,7 @@ class _PostTruncatedHint extends StatelessWidget {
).inSeconds}s', ).inSeconds}s',
]), ]),
], ],
).padding(right: 8), ),
if (data.body['content_length'] != null) if (data.body['content_length'] != null)
Row( Row(
children: [ children: [

View File

@ -83,7 +83,9 @@ class PostMetaEditor extends StatelessWidget {
return ListenableBuilder( return ListenableBuilder(
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
return Column( return SingleChildScrollView(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
child: Column(
children: [ children: [
TextField( TextField(
controller: controller.titleController, controller: controller.titleController,
@ -115,6 +117,14 @@ class PostMetaEditor extends StatelessWidget {
}, },
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(4), const Gap(4),
PostCategoriesField(
initialCategories: controller.categories,
labelText: 'fieldPostCategories'.tr(),
onUpdate: (value) {
controller.setCategories(value);
},
).padding(horizontal: 24),
const Gap(4),
TextField( TextField(
controller: controller.aliasController, controller: controller.aliasController,
decoration: InputDecoration( decoration: InputDecoration(
@ -243,7 +253,8 @@ class PostMetaEditor extends StatelessWidget {
}, },
), ),
], ],
).padding(vertical: 8); ).padding(vertical: 8),
);
}, },
); );
} }

View File

@ -1,9 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/widgets/dialog.dart';
class PostTagsField extends StatefulWidget { class PostTagsField extends StatefulWidget {
final List<String>? initialTags; final List<String>? initialTags;
@ -21,9 +23,9 @@ class PostTagsField extends StatefulWidget {
State<PostTagsField> createState() => _PostTagsFieldState(); State<PostTagsField> createState() => _PostTagsFieldState();
} }
class _PostTagsFieldState extends State<PostTagsField> { const List<String> kTagsDividers = [' ', ','];
static const List<String> kTagsDividers = [' ', ','];
class _PostTagsFieldState extends State<PostTagsField> {
late final _Debounceable<List<String>?, String> _debouncedSearch; late final _Debounceable<List<String>?, String> _debouncedSearch;
final List<String> _currentTags = List.empty(growable: true); final List<String> _currentTags = List.empty(growable: true);
@ -100,8 +102,7 @@ class _PostTagsFieldState extends State<PostTagsField> {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
margin: const EdgeInsets.only(right: 8), margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
horizontal: 10.0, vertical: 4.0),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
@ -155,6 +156,155 @@ class _PostTagsFieldState extends State<PostTagsField> {
} }
} }
class PostCategoriesField extends StatefulWidget {
final List<String>? initialCategories;
final String labelText;
final Function(List<String>) onUpdate;
const PostCategoriesField({
super.key,
this.initialCategories,
required this.labelText,
required this.onUpdate,
});
@override
State<PostCategoriesField> createState() => _PostCategoriesFieldState();
}
class _PostCategoriesFieldState extends State<PostCategoriesField> {
late final _Debounceable<List<String>?, String> _debouncedSearch;
final List<String> _currentCategories = List.empty(growable: true);
String? _currentSearchProbe;
List<String> _lastAutocompleteResult = List.empty();
TextEditingController? _textEditingController;
Future<List<String>?> _searchCategories(String probe) async {
_currentSearchProbe = probe;
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get(
'/cgi/co/categories?take=10&probe=$_currentSearchProbe',
);
if (_currentSearchProbe != probe) {
return null;
}
_currentSearchProbe = null;
return resp.data.map((x) => x['alias']).toList().cast<String>();
}
@override
void initState() {
super.initState();
_debouncedSearch = _debounce<List<String>?, String>(_searchCategories);
if (widget.initialCategories != null) {
_currentCategories.addAll(widget.initialCategories!);
}
}
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) async {
final result = await _debouncedSearch(textEditingValue.text);
if (result == null) {
return _lastAutocompleteResult;
}
_lastAutocompleteResult = result;
return result;
},
onSelected: (String value) {
if (value.isEmpty) return;
if (!_currentCategories.contains(value)) {
setState(() => _currentCategories.add(value));
}
_textEditingController?.clear();
widget.onUpdate(_currentCategories);
},
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
_textEditingController = controller;
return TextField(
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
label: Text(widget.labelText),
border: const UnderlineInputBorder(),
prefixIconConstraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
prefixIcon: _currentCategories.isNotEmpty
? SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: _currentCategories.map((String category) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(20.0),
),
color: Theme.of(context).colorScheme.primary,
),
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
InkWell(
child: Text(
'postCategory${category.capitalize()}'.trExists()
? 'postCategory${category.capitalize()}'.tr()
: '#$category',
style: const TextStyle(color: Colors.white),
),
),
const Gap(4),
InkWell(
child: const Icon(
Icons.cancel,
size: 14.0,
color: Color.fromARGB(255, 233, 233, 233),
),
onTap: () {
setState(() => _currentCategories.remove(category));
widget.onUpdate(_currentCategories);
},
)
],
),
);
}).toList(),
),
)
: null,
),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) {
for (final divider in kTagsDividers) {
if (value.endsWith(divider)) {
final tagValue = value.substring(0, value.length - 1);
if (tagValue.isEmpty) return;
if (!_currentCategories.contains(tagValue)) {
setState(() => _currentCategories.add(tagValue));
}
controller.clear();
widget.onUpdate(_currentCategories);
break;
}
}
},
onSubmitted: (_) {
onSubmitted();
},
);
},
);
}
}
typedef _Debounceable<S, T> = Future<S?> Function(T parameter); typedef _Debounceable<S, T> = Future<S?> Function(T parameter);
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) { _Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {