diff --git a/.gitignore b/.gitignore index 28c28463..15aa51b5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.g.dart *.mapper.dart *.freezed.dart +/devtools_options.yaml # Miscellaneous *.class *.log diff --git a/CHANGELOG.md b/CHANGELOG.md index bfc191b0..d7db94b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - 新增显示帖子的悬赏状态及对应的最佳答案。 +- 新增支持编辑已发布的帖子和回复。 + * 楼层右下角菜单 -> 编辑。 + * 一些附加选项可能需要文本编辑器支持,目前可能不完全支持。 + * 暂不支持设置阅读权限和售价。 - 在通知页内显示已有x条相同通知被忽略。 - 支持筛选积分变更历史。 - 帖子内按页数显示分组。 diff --git a/README.md b/README.md index 9f116d4a..f1a84d38 100644 --- a/README.md +++ b/README.md @@ -80,12 +80,12 @@ * [ ] 回复表情 * [ ] 设置字体、字号、链接等 * [x] 回复其他楼层 - * [ ] 编辑回复 + * [x] 编辑回复 * [ ] 编辑帖子(一楼) * [ ] 编辑帖子 - * [ ] 修改纯文本内容 - * [ ] 设置分类和标题 - * [ ] 设置附加选项 + * [x] *修改纯文本内容* + * [x] *设置分类和标题* + * [x] *设置附加选项* * [ ] 设置阅读权限 * [ ] 设置售价 * [ ] 富文本模式 @@ -106,7 +106,7 @@ * [x] 按作者id和论坛id搜索 * [ ] 积分 * [x] 积分统计和历史记录 - * [ ] *查询积分记录* + * [x] *查询积分记录* * [ ] 购买 * [x] 购买帖子 * [x] 回复后可见 diff --git a/lib/constants/layout.dart b/lib/constants/layout.dart index 15738a8e..b6b92cd6 100644 --- a/lib/constants/layout.dart +++ b/lib/constants/layout.dart @@ -11,6 +11,9 @@ const sizedBoxW5H5 = SizedBox(width: 5, height: 5); /// A [SizedBox] with 10 width and 10 height. const sizedBoxW10H10 = SizedBox(width: 10, height: 10); +/// A [SizedBox] with 15 width and 15 height. +const sizedBoxW15H15 = SizedBox(width: 15, height: 15); + /// A [SizedBox] with 20 width and 20 height. const sizedBoxW20H20 = SizedBox(width: 20, height: 20); diff --git a/lib/features/post/bloc/post_edit_bloc.dart b/lib/features/post/bloc/post_edit_bloc.dart index 69947cf0..6380070b 100644 --- a/lib/features/post/bloc/post_edit_bloc.dart +++ b/lib/features/post/bloc/post_edit_bloc.dart @@ -2,6 +2,7 @@ import 'package:bloc/bloc.dart'; import 'package:dart_mappable/dart_mappable.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/extensions/string.dart'; +import 'package:tsdm_client/features/post/exceptions/exceptions.dart'; import 'package:tsdm_client/features/post/models/post_edit_content.dart'; import 'package:tsdm_client/features/post/repository/post_edit_repository.dart'; import 'package:tsdm_client/utils/debug.dart'; @@ -21,6 +22,7 @@ final class PostEditBloc extends Bloc { : _postEditRepository = postEditRepository, super(const PostEditState()) { on(_onPostEditLoadDataRequested); + on(_onPostEditCompleteEditRequested); } final PostEditRepository _postEditRepository; @@ -34,13 +36,52 @@ final class PostEditBloc extends Bloc { final document = await _postEditRepository.fetchData(event.editUrl); final content = _parseContent(document); if (content == null) { - emit(state.copyWith(status: PostEditStatus.failed)); + emit(state.copyWith(status: PostEditStatus.failedToLoad)); return; } - emit(state.copyWith(status: PostEditStatus.success, content: content)); + emit(state.copyWith(status: PostEditStatus.editing, content: content)); } on HttpRequestFailedException catch (e) { debug('failed to load post edit data: $e'); - emit(state.copyWith(status: PostEditStatus.failed)); + emit(state.copyWith(status: PostEditStatus.failedToLoad)); + } + } + + Future _onPostEditCompleteEditRequested( + PostEditCompleteEditRequested event, + PostEditEmit emit, + ) async { + emit(state.copyWith(status: PostEditStatus.uploading)); + try { + await _postEditRepository.postEditedContent( + formHash: event.formHash, + postTime: event.postTime, + delattachop: event.delattachop, + wysiwyg: event.wysiwyg, + fid: event.fid, + tid: event.tid, + pid: event.pid, + page: event.page, + threadType: event.threadType?.typeID, + threadTitle: event.threadTitle, + data: event.data, + options: Map.fromEntries( + event.options + .where((e) => !e.disabled && e.checked) + .map((e) => MapEntry(e.name, e.value)), + ), + ); + emit(state.copyWith(status: PostEditStatus.success)); + } on HttpRequestFailedException catch (e) { + debug('failed to post edited post data: $e'); + emit(state.copyWith(status: PostEditStatus.failedToUpload)); + } on PostEditFailedToUploadResult catch (e) { + debug('failed to post edited post data: $e'); + emit( + state.copyWith( + status: PostEditStatus.failedToUpload, + errorText: e.errorText, + ), + ); } } @@ -139,13 +180,19 @@ final class PostEditBloc extends Bloc { e.querySelector('label') != null, ) .map( - (e) => PostEditContentOption( - name: e.querySelector('input')!.id, - readableName: e.querySelector('label')!.innerText, - value: e.querySelector('input')!.attributes['value']!, - ), - ) - .toList(); + (e) { + final input = e.querySelector('input')!; + final label = e.querySelector('label')!; + + return PostEditContentOption( + name: input.id, + readableName: label.innerText, + disabled: input.attributes.containsKey('disabled'), + checked: input.attributes.containsKey('checked'), + value: input.attributes['value']!, + ); + }, + ).toList(); if (formHash == null || postTime == null || diff --git a/lib/features/post/bloc/post_edit_event.dart b/lib/features/post/bloc/post_edit_event.dart index 48673c65..dd924aa3 100644 --- a/lib/features/post/bloc/post_edit_event.dart +++ b/lib/features/post/bloc/post_edit_event.dart @@ -21,4 +21,64 @@ final class PostEditLoadDataRequested extends PostEditEvent /// User completed the editing and need to post to the server. @MappableClass() final class PostEditCompleteEditRequested extends PostEditEvent - with PostEditCompleteEditRequestedMappable {} + with PostEditCompleteEditRequestedMappable { + /// Constructor. + const PostEditCompleteEditRequested({ + required this.formHash, + required this.postTime, + required this.delattachop, + required this.wysiwyg, + required this.fid, + required this.tid, + required this.pid, + required this.page, + required this.threadType, + required this.threadTitle, + required this.data, + required this.options, + }) : super(); + + /// Form hash. + final String formHash; + + /// Post time. + final String postTime; + + /// Delattachop. + final String delattachop; + + /// What you see is what you get. + /// + /// "0". + final String wysiwyg; + + /// Forum id. + final String fid; + + /// Thread id. + final String tid; + + /// Post id. + final String pid; + + /// Page. + /// + /// Provided by server. + final String page; + + /// Thread type. + /// + /// Only needed when editing or creating a new thread. + final PostEditThreadType? threadType; + + /// Thread title. + /// + /// Only needed when editing or creating a new thread. + final String? threadTitle; + + /// Post data, or first floor post data when submitting a thread. + final String data; + + /// Additional options provided by server. + final List options; +} diff --git a/lib/features/post/bloc/post_edit_state.dart b/lib/features/post/bloc/post_edit_state.dart index 5cae2b56..d52049f4 100644 --- a/lib/features/post/bloc/post_edit_state.dart +++ b/lib/features/post/bloc/post_edit_state.dart @@ -6,14 +6,23 @@ enum PostEditStatus { /// Initial. initial, - /// Loading data or posting edit result. + /// Loading data. loading, + /// Failed to load data. + failedToLoad, + + /// Waiting for user to edit. + editing, + + /// Uploading data. + uploading, + + /// Failed to load data. + failedToUpload, + /// Post edit result success. success, - - /// Failed to post the edit result to server. - failed, } /// State of mappable. @@ -23,6 +32,7 @@ final class PostEditState with PostEditStateMappable { const PostEditState({ this.status = PostEditStatus.initial, this.content, + this.errorText, }); /// Status. @@ -30,4 +40,9 @@ final class PostEditState with PostEditStateMappable { /// Post content. final PostEditContent? content; + + /// Error text html element. + /// + /// Use this to show the error message. + final String? errorText; } diff --git a/lib/features/post/exceptions/exceptions.dart b/lib/features/post/exceptions/exceptions.dart new file mode 100644 index 00000000..7f248397 --- /dev/null +++ b/lib/features/post/exceptions/exceptions.dart @@ -0,0 +1,14 @@ +/// Exceptions used in editing post. +sealed class PostEditException implements Exception { + /// Constructor. + const PostEditException() : super(); +} + +/// Failed to upload edit result. +final class PostEditFailedToUploadResult extends PostEditException { + /// Constructor. + const PostEditFailedToUploadResult(this.errorText) : super(); + + /// Html element contains the error message. + final String errorText; +} diff --git a/lib/features/post/models/post_edit_content.dart b/lib/features/post/models/post_edit_content.dart index 4fa842d1..4e851dec 100644 --- a/lib/features/post/models/post_edit_content.dart +++ b/lib/features/post/models/post_edit_content.dart @@ -36,15 +36,42 @@ final class PostEditContentOption with PostEditContentOptionMappable { const PostEditContentOption({ required this.name, required this.value, + required this.disabled, + required this.checked, required this.readableName, }); /// Attribute "name". + /// + /// This value is the name parameter in form. final String name; /// Attribute "value". + /// + /// When this checkbox meets all the following conditions: + /// * Do not have [disabled] attribute. + /// * Have [checked] attribute. + /// + /// This [value] will be added in form when submit to the server. final String value; + /// Attribute "disabled". + /// + /// Html checkbox "disabled" attribute. + /// + /// Have this attribute (no matter has value or not) means the checkbox is + /// disabled. + final bool disabled; + + /// Attribute "checked". + /// + /// Have this attribute (no matter has value or not) means the checkbox is in + /// checked state. + /// + /// When in checked state, the [value] will be added in form data when submit + /// form to server. + final bool checked; + /// Human readable name inside node. final String readableName; } diff --git a/lib/features/post/repository/post_edit_repository.dart b/lib/features/post/repository/post_edit_repository.dart index f8e20f8d..ef484971 100644 --- a/lib/features/post/repository/post_edit_repository.dart +++ b/lib/features/post/repository/post_edit_repository.dart @@ -1,6 +1,8 @@ import 'dart:io'; +import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/post/exceptions/exceptions.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; import 'package:tsdm_client/shared/providers/server_time_provider/server_time_provider.dart'; @@ -9,6 +11,9 @@ import 'package:universal_html/parsing.dart'; /// Repository for editing posts. final class PostEditRepository { + static const _submitTarget = + '$baseUrl/forum.php?mod=post&action=edit&extra=&editsubmit=yes'; + /// Fetch edit data from given [url]. /// /// # Exceptions @@ -25,4 +30,78 @@ final class PostEditRepository { return document; } + + /// Post some edited content to server. The content is in a certain post, with + /// additional options provided by server. + /// + /// What's more, when editing a thread (means the first floor post in some + /// thread), additional [threadType] and [threadTitle] are required. + /// + /// This method post to a certain target with data in `multipart/form-data` + /// Content-Type. + /// + /// [fid], [tid] and [pid] is used to specify the post we made modification. + /// + /// [data] is post content, now in plain text. + /// + /// [threadType] is the number (String) of thread type. + /// + /// [options] is a map of option-name - option-value pair. + /// + /// # Exceptions + /// + /// * **HttpRequestFailedException** when http request failed. + /// * **PostEditFailedToUploadResult** when server returns error. + Future postEditedContent({ + required String formHash, + required String postTime, + required String delattachop, + required String wysiwyg, + required String fid, + required String tid, + required String pid, + required String page, + required String? threadType, + required String? threadTitle, + required String data, + required Map options, + }) async { + final body = { + 'formhash': formHash, + 'posttime': postTime, + 'delattachop': delattachop, + 'wysiwyg': wysiwyg, + 'fid': fid, + 'tid': tid, + 'pid': pid, + 'checkbox': '0', + 'page': page, + 'subject': threadTitle ?? '', + 'message': data, + 'editsubmit': 'true', + 'save': '', + }; + if (threadType != null) { + body['typeid'] = threadType; + } + for (final entry in options.entries) { + body[entry.key] = entry.value; + } + final resp = await getIt.get().postMultipartForm( + _submitTarget, + data: body, + ); + // When post succeed, server will response 301. + // If we got a 200, likely we run into some error and server responded it. + if (resp.statusCode == HttpStatus.ok) { + final document = parseHtmlDocument(resp.data as String); + throw PostEditFailedToUploadResult( + document.querySelector('div#messagetext > p')?.innerText ?? + 'unknown error', + ); + } + if (resp.statusCode != HttpStatus.movedPermanently) { + throw HttpRequestFailedException(resp.statusCode!); + } + } } diff --git a/lib/features/post/view/post_edit_page.dart b/lib/features/post/view/post_edit_page.dart index 155b96ce..02e52a19 100644 --- a/lib/features/post/view/post_edit_page.dart +++ b/lib/features/post/view/post_edit_page.dart @@ -124,6 +124,16 @@ class _PostEditPageState extends State { /// This is ugly. bool init = false; + /// Additional options used here. + /// + /// Here we copy and save the additional options in state to here. This avoid + /// updating state when user just changed an option. Only apply these options + /// to state when posting the data to server. + /// + /// Key is option's attribute name. + /// Value is the option itself. + Map? additionalOptionsMap; + /// Show a modal bottom sheet to let user select a thread type. /// /// Note that the content data [state.content.threadTypeList] MUST be @@ -135,23 +145,55 @@ class _PostEditPageState extends State { await showCustomBottomSheet( context: context, title: context.t.postEditPage.editPostTitle, - childrenBuilder: (BuildContext context) { - return state.content!.threadTypeList! - .map( - (e) => ListTile( - title: Text(e.name), - trailing: e.name == threadTypeController.text - ? const Icon(Icons.check_outlined) - : null, - onTap: () { - threadType = e; - threadTypeController.text = e.name; - context.pop(); - }, - ), - ) - .toList(); - }, + childrenBuilder: (context) => state.content!.threadTypeList! + .map( + (e) => ListTile( + title: Text(e.name), + trailing: e.name == threadTypeController.text + ? const Icon(Icons.check_outlined) + : null, + onTap: () { + threadType = e; + threadTypeController.text = e.name; + context.pop(); + }, + ), + ) + .toList(), + ); + } + + /// Show a bottom sheet to let user configure the additional options provided + /// by the server side. + /// + /// Options are a list of checkbox. + /// + /// Note that the additional options map MUST NOT a null value. + Future _showAdditionalOptionBottomSheet( + BuildContext context, + PostEditState state, + ) async { + await showCustomBottomSheet( + context: context, + title: context.t.postEditPage.additionalOptions, + childrenBuilder: (context) => additionalOptionsMap!.values + .map( + (e) => StatefulBuilder( + builder: (context, setState) { + return SwitchListTile( + title: Text(e.readableName), + value: additionalOptionsMap![e.name]!.checked, + onChanged: e.disabled + ? null + : (value) => setState(() { + additionalOptionsMap![e.name] = + e.copyWith(checked: value); + }), + ); + }, + ), + ) + .toList(), ); } @@ -161,7 +203,7 @@ class _PostEditPageState extends State { (state.content?.threadTypeList?.isNotEmpty ?? false)) { ret.add( ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 150), + constraints: const BoxConstraints(maxWidth: 100), child: TextFormField( key: formKey, controller: threadTypeController, @@ -199,7 +241,7 @@ class _PostEditPageState extends State { controller: threadTitleController, decoration: InputDecoration( labelText: context.t.postEditPage.threadTitle, - suffixText: '$threadTitleRestLength', + suffixText: ' $threadTitleRestLength', ), onChanged: (value) { setState(() { @@ -234,7 +276,51 @@ class _PostEditPageState extends State { if (ret.isEmpty) { return Container(); } - return Row(children: ret.insertBetween(sizedBoxW5H5)); + return Row(children: ret.insertBetween(sizedBoxW10H10)); + } + + Widget _buildControlRow(BuildContext context, PostEditState state) { + return Row( + children: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: additionalOptionsMap != null + ? () async => _showAdditionalOptionBottomSheet(context, state) + : null, + ), + const Spacer(), + ElevatedButton.icon( + icon: state.status == PostEditStatus.uploading + ? sizedCircularProgressIndicator + : const Icon(Icons.send_outlined), + label: Text(context.t.postEditPage.saveAndBack), + onPressed: state.status == PostEditStatus.uploading + ? null + : () { + if (widget.editType.isEditingPost) { + final event = PostEditCompleteEditRequested( + formHash: state.content!.formHash, + postTime: state.content!.postTime, + delattachop: state.content!.delattachop, + page: state.content!.page, + wysiwyg: state.content!.wysiwyg, + fid: widget.fid, + tid: widget.tid, + pid: widget.pid, + threadType: threadType, + threadTitle: threadTitleController.text, + data: dataController.text, + options: additionalOptionsMap?.values.toList() ?? [], + ); + context.read().add(event); + return; + } + // TODO: Handle creating a post. + // TODO: Handle creating a thread. + }, + ), + ], + ); } Widget _buildBody(BuildContext context) { @@ -259,10 +345,21 @@ class _PostEditPageState extends State { ], child: BlocListener( listener: (context, state) { - if (state.status == PostEditStatus.failed) { + if (state.status == PostEditStatus.failedToLoad) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.t.postEditPage.failedToLoadData)), ); + } else if (state.status == PostEditStatus.failedToUpload && + state.errorText != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(state.errorText!)), + ); + } else if (state.status == PostEditStatus.success && + widget.editType.isEditingPost) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.t.postEditPage.editSuccess)), + ); + context.pop(); } }, child: BlocBuilder( @@ -271,7 +368,7 @@ class _PostEditPageState extends State { state.status == PostEditStatus.loading) { return const Center(child: CircularProgressIndicator()); } - if (state.status == PostEditStatus.failed) { + if (state.status == PostEditStatus.failedToLoad) { return buildRetryButton(context, () { context.read().add( PostEditLoadDataRequested( @@ -289,12 +386,19 @@ class _PostEditPageState extends State { if (!init) { threadTypeController.text = state.content?.threadType?.name ?? ' '; + threadType = state.content?.threadType; threadTitleController.text = state.content?.threadTitle ?? ''; // Update the length of chars user can still input. // Bytes of chars for title in utf-8 encoding. threadTitleRestLength = (state.content?.threadTitleMaxLength ?? _defaultThreadTitleMaxlength) - threadTitleController.text.parseUtf8Length; + dataController.text = state.content?.data ?? ''; + if (state.content?.options != null) { + additionalOptionsMap = Map.fromEntries( + state.content!.options!.map((e) => MapEntry(e.name, e)), + ); + } init = true; } @@ -322,7 +426,8 @@ class _PostEditPageState extends State { }, ), ), - ].insertBetween(sizedBoxW10H10), + _buildControlRow(context, state), + ].insertBetween(sizedBoxW15H15), ); }, ), diff --git a/lib/i18n/strings.i18n.json b/lib/i18n/strings.i18n.json index 5091b17f..31f3be3e 100644 --- a/lib/i18n/strings.i18n.json +++ b/lib/i18n/strings.i18n.json @@ -294,7 +294,8 @@ "threadTypeShouldNotBeEmpty": "Thread type should not be empty", "titleShouldNotBeEmpty": "Title should not be empty", "titleTooLong": "Title too long", - "threadBodyTooShort": "Thread body too short" + "threadBodyTooShort": "Thread body too short", + "editSuccess": "Edit success" }, "forumCard": { "links": "Links", diff --git a/lib/i18n/strings_zh-CN.i18n.json b/lib/i18n/strings_zh-CN.i18n.json index 25822a2f..f397e18e 100644 --- a/lib/i18n/strings_zh-CN.i18n.json +++ b/lib/i18n/strings_zh-CN.i18n.json @@ -294,7 +294,8 @@ "threadTypeShouldNotBeEmpty": "分类不能为空", "titleShouldNotBeEmpty": "标题不能为空", "titleTooLong": "标题太长", - "threadBodyTooShort": "正文太短" + "threadBodyTooShort": "正文太短", + "editSuccess": "编辑成功" }, "forumCard": { "links": "链接", diff --git a/lib/i18n/strings_zh-TW.i18n.json b/lib/i18n/strings_zh-TW.i18n.json index b451c509..dbef3e60 100644 --- a/lib/i18n/strings_zh-TW.i18n.json +++ b/lib/i18n/strings_zh-TW.i18n.json @@ -294,7 +294,8 @@ "threadTypeShouldNotBeEmpty": "分類不能為空", "titleShouldNotBeEmpty": "標題不能為空", "titleTooLong": "標題太長", - "threadBodyTooShort": "正文太短" + "threadBodyTooShort": "正文太短", + "editSuccess": "編輯成功" }, "forumCard": { "links": "連結", diff --git a/lib/shared/providers/net_client_provider/net_client_provider.dart b/lib/shared/providers/net_client_provider/net_client_provider.dart index 5ae46455..75a11bdb 100644 --- a/lib/shared/providers/net_client_provider/net_client_provider.dart +++ b/lib/shared/providers/net_client_provider/net_client_provider.dart @@ -158,6 +158,31 @@ class NetClientProvider { return resp; } + /// Post a form [data] to url [path] in `Content-Type` multipart/form-data. + /// + /// Automatically set `Content-Type` to `multipart/form-data`. + Future> postMultipartForm( + String path, { + required Map data, + }) async { + final resp = _dio.post( + path, + options: Options( + headers: { + HttpHeaders.contentTypeHeader: Headers.multipartFormDataContentType, + }, + validateStatus: (code) { + if (code == 301 || code == 200) { + return true; + } + return false; + }, + ), + data: FormData.fromMap(data), + ); + return resp; + } + /// Download the file from url [path] and save to [savePath]. Future download( String path,