Editing categories

This commit is contained in:
LittleSheep 2024-12-22 14:56:34 +08:00
parent 95af7140cd
commit 5b05ca67b6
8 changed files with 318 additions and 144 deletions

View File

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

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": "发布",

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": "發佈",

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": "釋出",

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

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

@ -83,167 +83,178 @@ class PostMetaEditor extends StatelessWidget {
return ListenableBuilder( return ListenableBuilder(
listenable: controller, listenable: controller,
builder: (context, _) { builder: (context, _) {
return Column( return SingleChildScrollView(
children: [ padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
TextField( child: Column(
controller: controller.titleController, children: [
decoration: InputDecoration(
labelText: 'fieldPostTitle'.tr(),
border: UnderlineInputBorder(),
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24),
if (controller.mode == 'articles') const Gap(4),
if (controller.mode == 'articles')
TextField( TextField(
controller: controller.descriptionController, controller: controller.titleController,
maxLines: null,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'fieldPostDescription'.tr(), labelText: 'fieldPostTitle'.tr(),
border: UnderlineInputBorder(), border: UnderlineInputBorder(),
), ),
onTapOutside: (_) => onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(4), if (controller.mode == 'articles') const Gap(4),
PostTagsField( if (controller.mode == 'articles')
initialTags: controller.tags, TextField(
labelText: 'fieldPostTags'.tr(), controller: controller.descriptionController,
onUpdate: (value) { maxLines: null,
controller.setTags(value); decoration: InputDecoration(
}, labelText: 'fieldPostDescription'.tr(),
).padding(horizontal: 24), border: UnderlineInputBorder(),
const Gap(4), ),
TextField( onTapOutside: (_) =>
controller: controller.aliasController, FocusManager.instance.primaryFocus?.unfocus(),
decoration: InputDecoration( ).padding(horizontal: 24),
labelText: 'fieldPostAlias'.tr(), const Gap(4),
helperText: 'fieldPostAliasHint'.tr(), PostTagsField(
helperMaxLines: 2, initialTags: controller.tags,
border: UnderlineInputBorder(), labelText: 'fieldPostTags'.tr(),
), onUpdate: (value) {
onTapOutside: (_) => controller.setTags(value);
FocusManager.instance.primaryFocus?.unfocus(), },
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(12), const Gap(4),
ListTile( PostCategoriesField(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), initialCategories: controller.categories,
leading: const Icon(Symbols.visibility), labelText: 'fieldPostCategories'.tr(),
title: Text('postVisibility').tr(), onUpdate: (value) {
subtitle: Text('postVisibilityDescription').tr(), controller.setCategories(value);
trailing: SizedBox( },
width: 180, ).padding(horizontal: 24),
child: DropdownButtonHideUnderline( const Gap(4),
child: DropdownButton2<int>( TextField(
isExpanded: true, controller: controller.aliasController,
items: kPostVisibilityLevel.entries decoration: InputDecoration(
.map( labelText: 'fieldPostAlias'.tr(),
(entry) => DropdownMenuItem<int>( helperText: 'fieldPostAliasHint'.tr(),
value: entry.key, helperMaxLines: 2,
child: Text( border: UnderlineInputBorder(),
entry.value, ),
style: const TextStyle(fontSize: 14), onTapOutside: (_) =>
).tr(), FocusManager.instance.primaryFocus?.unfocus(),
), ).padding(horizontal: 24),
) const Gap(12),
.toList(), ListTile(
value: controller.visibility, contentPadding: const EdgeInsets.symmetric(horizontal: 24),
onChanged: (int? value) { leading: const Icon(Symbols.visibility),
if (value != null) { title: Text('postVisibility').tr(),
controller.setVisibility(value); subtitle: Text('postVisibilityDescription').tr(),
} trailing: SizedBox(
}, width: 180,
buttonStyleData: const ButtonStyleData( child: DropdownButtonHideUnderline(
height: 40, child: DropdownButton2<int>(
padding: EdgeInsets.symmetric( isExpanded: true,
horizontal: 4, items: kPostVisibilityLevel.entries
vertical: 8, .map(
(entry) => DropdownMenuItem<int>(
value: entry.key,
child: Text(
entry.value,
style: const TextStyle(fontSize: 14),
).tr(),
),
)
.toList(),
value: controller.visibility,
onChanged: (int? value) {
if (value != null) {
controller.setVisibility(value);
}
},
buttonStyleData: const ButtonStyleData(
height: 40,
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
), ),
menuItemStyleData: const MenuItemStyleData(height: 40),
), ),
menuItemStyleData: const MenuItemStyleData(height: 40),
), ),
), ),
), ),
), if (controller.visibility == 2)
if (controller.visibility == 2) ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right),
title: Text('postVisibleUsers').tr(),
subtitle: Text('postSelectedUsers')
.plural(controller.visibleUsers.length),
onTap: () {
_selectVisibleUser(context);
},
),
if (controller.visibility == 3)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Symbols.person),
trailing: Icon(Symbols.chevron_right),
title: Text('postInvisibleUsers').tr(),
subtitle: Text('postSelectedUsers')
.plural(controller.invisibleUsers.length),
onTap: () {
_selectInvisibleUser(context);
},
),
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Symbols.event_available),
leading: Icon(Symbols.person), title: Text('postPublishedAt').tr(),
trailing: Icon(Symbols.chevron_right), subtitle: Text(
title: Text('postVisibleUsers').tr(), controller.publishedAt != null
subtitle: Text('postSelectedUsers') ? dateFormatter.format(controller.publishedAt!)
.plural(controller.visibleUsers.length), : 'unset'.tr(),
),
trailing: controller.publishedAt != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedAt(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () { onTap: () {
_selectVisibleUser(context); _selectDate(
context,
initialDateTime: controller.publishedAt,
).then((value) {
controller.setPublishedAt(value);
});
}, },
), ),
if (controller.visibility == 3)
ListTile( ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), leading: const Icon(Symbols.event_busy),
leading: Icon(Symbols.person), title: Text('postPublishedUntil').tr(),
trailing: Icon(Symbols.chevron_right), subtitle: Text(
title: Text('postInvisibleUsers').tr(), controller.publishedUntil != null
subtitle: Text('postSelectedUsers') ? dateFormatter.format(controller.publishedUntil!)
.plural(controller.invisibleUsers.length), : 'unset'.tr(),
),
trailing: controller.publishedUntil != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedUntil(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () { onTap: () {
_selectInvisibleUser(context); _selectDate(
context,
initialDateTime: controller.publishedUntil,
).then((value) {
controller.setPublishedUntil(value);
});
}, },
), ),
ListTile( ],
leading: const Icon(Symbols.event_available), ).padding(vertical: 8),
title: Text('postPublishedAt').tr(), );
subtitle: Text(
controller.publishedAt != null
? dateFormatter.format(controller.publishedAt!)
: 'unset'.tr(),
),
trailing: controller.publishedAt != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedAt(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () {
_selectDate(
context,
initialDateTime: controller.publishedAt,
).then((value) {
controller.setPublishedAt(value);
});
},
),
ListTile(
leading: const Icon(Symbols.event_busy),
title: Text('postPublishedUntil').tr(),
subtitle: Text(
controller.publishedUntil != null
? dateFormatter.format(controller.publishedUntil!)
: 'unset'.tr(),
),
trailing: controller.publishedUntil != null
? IconButton(
icon: const Icon(Symbols.cancel),
onPressed: () {
controller.setPublishedUntil(null);
},
)
: null,
contentPadding: const EdgeInsets.only(left: 24, right: 18),
onTap: () {
_selectDate(
context,
initialDateTime: controller.publishedUntil,
).then((value) {
controller.setPublishedUntil(value);
});
},
),
],
).padding(vertical: 8);
}, },
); );
} }

View File

@ -21,9 +21,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);
@ -155,6 +155,154 @@ 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(
'#$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) {