diff --git a/lib/controllers/post_editor_controller.dart b/lib/controllers/post_editor_controller.dart index b345bd8..38d2026 100644 --- a/lib/controllers/post_editor_controller.dart +++ b/lib/controllers/post_editor_controller.dart @@ -7,6 +7,7 @@ import 'package:solian/models/post.dart'; import 'package:solian/models/realm.dart'; import 'package:solian/widgets/attachments/attachment_editor.dart'; import 'package:solian/widgets/posts/editor/post_editor_categories_tags.dart'; +import 'package:solian/widgets/posts/editor/post_editor_date.dart'; import 'package:solian/widgets/posts/editor/post_editor_overview.dart'; import 'package:solian/widgets/posts/editor/post_editor_publish_zone.dart'; import 'package:solian/widgets/posts/editor/post_editor_visibility.dart'; @@ -26,6 +27,8 @@ class PostEditorController extends GetxController { Rx replyTo = Rx(null); Rx repostTo = Rx(null); Rx realmZone = Rx(null); + Rx publishedAt = Rx(null); + Rx publishedUntil = Rx(null); RxList attachments = RxList.empty(growable: true); RxList tags = RxList.empty(growable: true); @@ -98,6 +101,15 @@ class PostEditorController extends GetxController { ); } + Future editPublishDate(BuildContext context) { + return showDialog( + context: context, + builder: (context) => PostEditorDateDialog( + controller: this, + ), + ); + } + Future editAttachment(BuildContext context) { return showModalBottomSheet( context: context, @@ -173,6 +185,8 @@ class PostEditorController extends GetxController { titleController.text = value.body['title'] ?? ''; descriptionController.text = value.body['description'] ?? ''; contentController.text = value.body['content'] ?? ''; + publishedAt.value = value.publishedAt; + publishedUntil.value = value.publishedUntil; tags.value = value.body['tags']?.map((x) => x['alias']).toList() ?? List.empty(); tags.refresh(); @@ -233,6 +247,9 @@ class PostEditorController extends GetxController { 'visible_users': visibleUsers, 'invisible_users': invisibleUsers, 'visibility': visibility.value, + 'published_at': publishedAt.value?.toUtc().toIso8601String() ?? + DateTime.now().toUtc().toIso8601String(), + 'published_until': publishedUntil.value?.toUtc().toIso8601String(), 'is_draft': isDraft.value, if (replyTo.value != null) 'reply_to': replyTo.value!.id, if (repostTo.value != null) 'repost_to': repostTo.value!.id, @@ -256,6 +273,12 @@ class PostEditorController extends GetxController { if (value['invisible_users'] != null) { invisibleUsers.value = value['invisible_users'].cast(); } + if (value['published_at'] != null) { + publishedAt.value = DateTime.parse(value['published_at']).toLocal(); + } + if (value['published_until'] != null) { + publishedAt.value = DateTime.parse(value['published_until']).toLocal(); + } if (value['reply_to'] != null) { replyTo.value = Post.fromJson(value['reply_to']); } diff --git a/lib/models/post.dart b/lib/models/post.dart index bf813f5..db1d717 100755 --- a/lib/models/post.dart +++ b/lib/models/post.dart @@ -20,6 +20,7 @@ class Post { Post? repostTo; Realm? realm; DateTime? publishedAt; + DateTime? publishedUntil; DateTime? pinnedAt; bool? isDraft; int authorId; @@ -44,6 +45,7 @@ class Post { required this.repostTo, required this.realm, required this.publishedAt, + required this.publishedUntil, required this.pinnedAt, required this.isDraft, required this.authorId, @@ -80,6 +82,9 @@ class Post { publishedAt: json['published_at'] != null ? DateTime.parse(json['published_at']) : null, + publishedUntil: json['published_until'] != null + ? DateTime.parse(json['published_until']) + : null, pinnedAt: json['pinned_at'] != null ? DateTime.parse(json['pinned_at']) : null, @@ -108,6 +113,7 @@ class Post { 'repost_to': repostTo?.toJson(), 'realm': realm?.toJson(), 'published_at': publishedAt?.toIso8601String(), + 'published_until': publishedUntil?.toIso8601String(), 'pinned_at': pinnedAt?.toIso8601String(), 'is_draft': isDraft, 'author_id': authorId, diff --git a/lib/screens/account/personalize.dart b/lib/screens/account/personalize.dart index 5d2d736..ee66960 100644 --- a/lib/screens/account/personalize.dart +++ b/lib/screens/account/personalize.dart @@ -34,7 +34,7 @@ class _PersonalizeScreenState extends State { bool _isBusy = false; - void selectBirthday() async { + void _selectBirthday() async { final DateTime? picked = await showDatePicker( context: context, initialDate: _birthday?.toLocal(), @@ -49,7 +49,7 @@ class _PersonalizeScreenState extends State { } } - void syncWidget() async { + void _syncWidget() async { setState(() => _isBusy = true); final AuthProvider auth = Get.find(); @@ -72,7 +72,7 @@ class _PersonalizeScreenState extends State { }); } - Future updateImage(String position) async { + Future _editImage(String position) async { final AuthProvider auth = Get.find(); if (auth.isAuthorized.isFalse) return; @@ -87,11 +87,7 @@ class _PersonalizeScreenState extends State { try { final file = File(image.path); attachResp = await provider.createAttachment( - await file.readAsBytes(), - file.path, - 'p.$position', - null - ); + await file.readAsBytes(), file.path, 'p.$position', null); } catch (e) { setState(() => _isBusy = false); context.showErrorDialog(e); @@ -105,7 +101,7 @@ class _PersonalizeScreenState extends State { {'attachment': attachResp.body['id']}, ); if (resp.statusCode == 200) { - syncWidget(); + _syncWidget(); context.showSnackbar('accountPersonalizeApplied'.tr); } else { context.showErrorDialog(resp.bodyString); @@ -114,7 +110,7 @@ class _PersonalizeScreenState extends State { setState(() => _isBusy = false); } - void updatePersonalize() async { + void _editUserInfo() async { final AuthProvider auth = Get.find(); if (auth.isAuthorized.isFalse) return; @@ -134,7 +130,7 @@ class _PersonalizeScreenState extends State { }, ); if (resp.statusCode == 200) { - syncWidget(); + _syncWidget(); context.showSnackbar('accountPersonalizeApplied'.tr); } else { context.showErrorDialog(resp.bodyString); @@ -147,7 +143,7 @@ class _PersonalizeScreenState extends State { void initState() { super.initState(); - Future.delayed(Duration.zero, () => syncWidget()); + Future.delayed(Duration.zero, () => _syncWidget()); } @override @@ -168,7 +164,7 @@ class _PersonalizeScreenState extends State { left: 40, child: FloatingActionButton.small( heroTag: const Key('avatar-editor'), - onPressed: () => updateImage('avatar'), + onPressed: () => _editImage('avatar'), child: const Icon( Icons.camera, ), @@ -187,7 +183,8 @@ class _PersonalizeScreenState extends State { color: Theme.of(context).colorScheme.surfaceContainerHigh, child: _banner != null ? Image.network( - ServiceFinder.buildUrl('files', '/attachments/$_banner'), + ServiceFinder.buildUrl( + 'files', '/attachments/$_banner'), fit: BoxFit.cover, loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { @@ -212,7 +209,7 @@ class _PersonalizeScreenState extends State { right: 16, child: FloatingActionButton( heroTag: const Key('banner-editor'), - onPressed: () => updateImage('banner'), + onPressed: () => _editImage('banner'), child: const Icon( Icons.camera_alt, ), @@ -293,18 +290,18 @@ class _PersonalizeScreenState extends State { border: const OutlineInputBorder(), labelText: 'birthday'.tr, ), - onTap: () => selectBirthday(), + onTap: () => _selectBirthday(), ).paddingSymmetric(horizontal: padding), const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton( - onPressed: _isBusy ? null : () => syncWidget(), + onPressed: _isBusy ? null : () => _syncWidget(), child: Text('reset'.tr), ), ElevatedButton( - onPressed: _isBusy ? null : () => updatePersonalize(), + onPressed: _isBusy ? null : () => _editUserInfo(), child: Text('apply'.tr), ), ], diff --git a/lib/screens/posts/post_editor.dart b/lib/screens/posts/post_editor.dart index 0a37e73..e763b69 100644 --- a/lib/screens/posts/post_editor.dart +++ b/lib/screens/posts/post_editor.dart @@ -82,7 +82,7 @@ class _PostPublishScreenState extends State { setState(() => _isBusy = false); } - void syncWidget() { + void _syncWidget() { _editorController.mode.value = widget.mode; if (widget.edit != null) { _editorController.editTarget = widget.edit; @@ -105,7 +105,7 @@ class _PostPublishScreenState extends State { void initState() { super.initState(); _editorController.contentController.addListener(() => setState(() {})); - syncWidget(); + _syncWidget(); } @override @@ -418,6 +418,25 @@ class _PostPublishScreenState extends State { _editorController.editPublishZone(context); }, ), + IconButton( + icon: Obx(() { + return badges.Badge( + showBadge: + _editorController.publishedAt.value != null || + _editorController.publishedUntil.value != + null, + position: badges.BadgePosition.topEnd( + top: -4, + end: -6, + ), + child: const Icon(Icons.schedule), + ); + }), + color: Theme.of(context).colorScheme.primary, + onPressed: () { + _editorController.editPublishDate(context); + }, + ), MarkdownToolbar( hideImage: true, useIncludedTextField: false, diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index d6e29f9..b3effa0 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -101,6 +101,9 @@ const i18nEnglish = { 'postRestoreFromLocal': 'Restore from local', 'postAutoSaveAt': 'Auto saved at @date', 'postCategoriesAndTags': 'Categories n\' Tags', + 'postPublishDate': 'Publish Date', + 'postPublishAt': 'Publish At', + 'postPublishedUntil': 'Publish Until', 'postPublishZone': 'Publish Zone', 'postPublishZoneNone': 'None', 'postVisibility': 'Visibility', diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index a074f65..2216f85 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -95,6 +95,9 @@ const i18nSimplifiedChinese = { 'postRestoreFromLocal': '内容从本地暂存回复', 'postAutoSaveAt': '已自动保存于 @date', 'postCategoriesAndTags': '分类与标签', + 'postPublishDate': '发布时间', + 'postPublishAt': '发布帖子于', + 'postPublishedUntil': '取消发布于', 'postPublishZone': '帖子发布区', 'postPublishZoneNone': '无所属领域', 'postVisibility': '帖子可见性', diff --git a/lib/widgets/posts/editor/post_editor_date.dart b/lib/widgets/posts/editor/post_editor_date.dart new file mode 100644 index 0000000..edcdec6 --- /dev/null +++ b/lib/widgets/posts/editor/post_editor_date.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:solian/controllers/post_editor_controller.dart'; + +class PostEditorDateDialog extends StatefulWidget { + final PostEditorController controller; + + const PostEditorDateDialog({super.key, required this.controller}); + + @override + State createState() => _PostEditorDateDialogState(); +} + +class _PostEditorDateDialogState extends State { + final TextEditingController _publishedAtController = TextEditingController(); + final TextEditingController _publishedUntilController = + TextEditingController(); + + final _dateFormatter = DateFormat('yyyy-MM-dd HH:mm:ss'); + + void _selectDate(int mode) async { + final initial = mode == 0 + ? widget.controller.publishedAt.value + : widget.controller.publishedUntil.value; + final DateTime? pickedDate = await showDatePicker( + context: context, + initialDate: initial?.toLocal(), + firstDate: DateTime(DateTime.now().year), + lastDate: DateTime(DateTime.now().year + 5), + ); + if (pickedDate == null) return; + final TimeOfDay? pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (pickedTime == null) return; + final picked = pickedDate.copyWith( + hour: pickedTime.hour, + minute: pickedTime.minute, + ); + if (mode == 0) { + setState(() { + widget.controller.publishedAt.value = picked; + _publishedAtController.text = _dateFormatter.format(picked); + }); + } else { + widget.controller.publishedUntil.value = pickedDate; + _publishedUntilController.text = _dateFormatter.format(picked); + } + } + + @override + void initState() { + super.initState(); + if (widget.controller.publishedAt.value != null) { + _publishedAtController.text = + _dateFormatter.format(widget.controller.publishedAt.value!); + } + if (widget.controller.publishedUntil.value != null) { + _publishedUntilController.text = + _dateFormatter.format(widget.controller.publishedUntil.value!); + } + } + + @override + void dispose() { + super.dispose(); + _publishedAtController.dispose(); + _publishedUntilController.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('postPublishDate'.tr), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: _publishedAtController, + readOnly: true, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'postPublishAt'.tr, + ), + onTap: () => _selectDate(0), + ), + const SizedBox(height: 16), + TextField( + controller: _publishedUntilController, + readOnly: true, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + labelText: 'postPublishedUntil'.tr, + ), + onTap: () => _selectDate(1), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + widget.controller.publishedAt.value = null; + widget.controller.publishedUntil.value = null; + _publishedAtController.clear(); + _publishedUntilController.clear(); + }, + child: Text('clear'.tr), + ), + TextButton( + onPressed: () => Navigator.pop(context), + child: Text('confirm'.tr), + ), + ], + ); + } +}