✨ Editing categories
This commit is contained in:
@ -83,167 +83,178 @@ class PostMetaEditor extends StatelessWidget {
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller.titleController,
|
||||
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')
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8),
|
||||
child: Column(
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller.descriptionController,
|
||||
maxLines: null,
|
||||
controller: controller.titleController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostDescription'.tr(),
|
||||
labelText: 'fieldPostTitle'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
PostTagsField(
|
||||
initialTags: controller.tags,
|
||||
labelText: 'fieldPostTags'.tr(),
|
||||
onUpdate: (value) {
|
||||
controller.setTags(value);
|
||||
},
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: controller.aliasController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostAlias'.tr(),
|
||||
helperText: 'fieldPostAliasHint'.tr(),
|
||||
helperMaxLines: 2,
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.visibility),
|
||||
title: Text('postVisibility').tr(),
|
||||
subtitle: Text('postVisibilityDescription').tr(),
|
||||
trailing: SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<int>(
|
||||
isExpanded: true,
|
||||
items: kPostVisibilityLevel.entries
|
||||
.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,
|
||||
if (controller.mode == 'articles') const Gap(4),
|
||||
if (controller.mode == 'articles')
|
||||
TextField(
|
||||
controller: controller.descriptionController,
|
||||
maxLines: null,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostDescription'.tr(),
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
PostTagsField(
|
||||
initialTags: controller.tags,
|
||||
labelText: 'fieldPostTags'.tr(),
|
||||
onUpdate: (value) {
|
||||
controller.setTags(value);
|
||||
},
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
PostCategoriesField(
|
||||
initialCategories: controller.categories,
|
||||
labelText: 'fieldPostCategories'.tr(),
|
||||
onUpdate: (value) {
|
||||
controller.setCategories(value);
|
||||
},
|
||||
).padding(horizontal: 24),
|
||||
const Gap(4),
|
||||
TextField(
|
||||
controller: controller.aliasController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'fieldPostAlias'.tr(),
|
||||
helperText: 'fieldPostAliasHint'.tr(),
|
||||
helperMaxLines: 2,
|
||||
border: UnderlineInputBorder(),
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).padding(horizontal: 24),
|
||||
const Gap(12),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Symbols.visibility),
|
||||
title: Text('postVisibility').tr(),
|
||||
subtitle: Text('postVisibilityDescription').tr(),
|
||||
trailing: SizedBox(
|
||||
width: 180,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<int>(
|
||||
isExpanded: true,
|
||||
items: kPostVisibilityLevel.entries
|
||||
.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(
|
||||
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),
|
||||
leading: const Icon(Symbols.event_available),
|
||||
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: () {
|
||||
_selectVisibleUser(context);
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedAt,
|
||||
).then((value) {
|
||||
controller.setPublishedAt(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
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),
|
||||
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: () {
|
||||
_selectInvisibleUser(context);
|
||||
_selectDate(
|
||||
context,
|
||||
initialDateTime: controller.publishedUntil,
|
||||
).then((value) {
|
||||
controller.setPublishedUntil(value);
|
||||
});
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Symbols.event_available),
|
||||
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);
|
||||
],
|
||||
).padding(vertical: 8),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -21,9 +21,9 @@ class PostTagsField extends StatefulWidget {
|
||||
State<PostTagsField> createState() => _PostTagsFieldState();
|
||||
}
|
||||
|
||||
class _PostTagsFieldState extends State<PostTagsField> {
|
||||
static const List<String> kTagsDividers = [' ', ','];
|
||||
const List<String> kTagsDividers = [' ', ','];
|
||||
|
||||
class _PostTagsFieldState extends State<PostTagsField> {
|
||||
late final _Debounceable<List<String>?, String> _debouncedSearch;
|
||||
|
||||
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);
|
||||
|
||||
_Debounceable<S, T> _debounce<S, T>(_Debounceable<S?, T> function) {
|
||||
|
Reference in New Issue
Block a user