diff --git a/README.md b/README.md index 4b1dfd1d3..970de55f7 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ ## feat +- [x] 创建/编辑/删除收藏夹 - [x] 评论楼中楼查看对话 - [x] 评论楼中楼定位点击查看的评论 - [x] 评论楼中楼按热度/时间排序 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3787de1f8..b8f7f1305 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -169,6 +169,11 @@ + + > internationalDialingPrefix = [ diff --git a/lib/http/api.dart b/lib/http/api.dart index 9b3086a0b..880e3277a 100644 --- a/lib/http/api.dart +++ b/lib/http/api.dart @@ -172,6 +172,14 @@ class Api { // https://api.bilibili.com/x/v3/fav/folder/created/list?pn=1&ps=10&up_mid=17340771 static const String userFavFolder = '/x/v3/fav/folder/created/list'; + static const String folderInfo = '/x/v3/fav/folder/info'; + + static const String addFolder = '/x/v3/fav/folder/add'; + + static const String editFolder = '/x/v3/fav/folder/edit'; + + static const String deleteFolder = '/x/v3/fav/folder/del'; + /// 收藏夹 详情 /// media_id 当前收藏夹id 搜索全部时为默认收藏夹id /// pn int 当前页 @@ -664,6 +672,8 @@ class Api { static const String uploadBfs = '/x/dynamic/feed/draw/upload_bfs'; + static const String uploadImage = '/x/upload/web/image'; + static const String videoRelation = '/x/web-interface/archive/relation'; static const String seasonFav = '/x/v3/fav/season/'; // + fav unfav diff --git a/lib/http/msg.dart b/lib/http/msg.dart index bd49dad15..2b385de72 100644 --- a/lib/http/msg.dart +++ b/lib/http/msg.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'dart:math'; import 'package:PiliPalaX/http/constants.dart'; import 'package:PiliPalaX/pages/dynamics/view.dart' show ReplyOption; +import 'package:PiliPalaX/utils/storage.dart'; import 'package:dio/dio.dart'; import '../models/msg/account.dart'; @@ -205,6 +206,33 @@ class MsgHttp { } } + static Future uploadImage({ + required dynamic path, + required String bucket, + required String dir, + }) async { + var res = await Request().post( + Api.uploadImage, + data: FormData.fromMap({ + 'bucket': bucket, + 'file': await MultipartFile.fromFile(path), + 'dir': dir, + 'csrf': await Request.getCsrf(), + }), + ); + if (res.data['code'] == 0) { + return { + 'status': true, + 'data': res.data['data'], + }; + } else { + return { + 'status': false, + 'msg': res.data['message'], + }; + } + } + static Future uploadBfs( dynamic path, ) async { diff --git a/lib/http/user.dart b/lib/http/user.dart index 90c9e333f..34af8bc09 100644 --- a/lib/http/user.dart +++ b/lib/http/user.dart @@ -1,4 +1,5 @@ import 'package:PiliPalaX/http/loading_state.dart'; +import 'package:PiliPalaX/utils/storage.dart'; import 'package:dio/dio.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import '../common/constants.dart'; @@ -65,6 +66,65 @@ class UserHttp { } } + static Future deleteFolder({ + required List mediaIds, + }) async { + var res = await Request().post(Api.deleteFolder, + data: { + 'media_ids': mediaIds.join(','), + 'platform': 'web', + 'csrf': await Request.getCsrf(), + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + )); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + static Future addOrEditFolder({ + required bool isAdd, + dynamic mediaId, + required String title, + required int privacy, + required String cover, + required String intro, + }) async { + var res = await Request().post(isAdd ? Api.addFolder : Api.editFolder, + data: { + 'title': title, + 'intro': intro, + 'privacy': privacy, + 'cover': cover.isNotEmpty ? Uri.encodeFull(cover) : cover, + 'csrf': await Request.getCsrf(), + if (mediaId != null) 'media_id': mediaId, + }, + options: Options( + contentType: Headers.formUrlEncodedContentType, + )); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + + static Future folderInfo({ + dynamic mediaId, + }) async { + var res = await Request().get(Api.folderInfo, data: { + 'media_id': mediaId, + }); + if (res.data['code'] == 0) { + return {'status': true, 'data': res.data['data']}; + } else { + return {'status': false, 'msg': res.data['message']}; + } + } + static Future userFavFolderDetail( {required int mediaId, required int pn, diff --git a/lib/pages/bangumi/introduction/view.dart b/lib/pages/bangumi/introduction/view.dart index 17f25cca0..8f29d8444 100644 --- a/lib/pages/bangumi/introduction/view.dart +++ b/lib/pages/bangumi/introduction/view.dart @@ -174,10 +174,10 @@ class _BangumiInfoState extends State return DraggableScrollableSheet( minChildSize: 0, maxChildSize: 1, - initialChildSize: 0.6, + initialChildSize: 0.7, snap: true, expand: false, - snapSizes: const [0.6], + snapSizes: const [0.7], builder: (BuildContext context, ScrollController scrollController) { return FavPanel( ctr: bangumiIntroController, diff --git a/lib/pages/fav_detail/controller.dart b/lib/pages/fav_detail/controller.dart index 7fdb41c22..00800fee3 100644 --- a/lib/pages/fav_detail/controller.dart +++ b/lib/pages/fav_detail/controller.dart @@ -1,6 +1,7 @@ import 'package:PiliPalaX/http/loading_state.dart'; import 'package:PiliPalaX/http/user.dart'; import 'package:PiliPalaX/pages/common/common_controller.dart'; +import 'package:PiliPalaX/utils/storage.dart'; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:PiliPalaX/http/video.dart'; @@ -15,6 +16,8 @@ class FavDetailController extends CommonController { RxString title = ''.obs; RxString cover = ''.obs; RxString name = ''.obs; + late int attr; + RxBool isOwner = false.obs; @override void onInit() { @@ -49,6 +52,9 @@ class FavDetailController extends CommonController { cover.value = response.response.info['cover']; name.value = response.response.info['upper']['name']; mediaCount = response.response.info['media_count']; + attr = response.response.info['attr']; + isOwner.value = response.response.info['mid'] == + GStorage.userInfo.get('userInfoCache')?.mid; } List currentList = loadingState.value is Success ? (loadingState.value as Success).response diff --git a/lib/pages/fav_detail/view.dart b/lib/pages/fav_detail/view.dart index e8fd590ac..2be517157 100644 --- a/lib/pages/fav_detail/view.dart +++ b/lib/pages/fav_detail/view.dart @@ -1,9 +1,12 @@ import 'dart:async'; import 'package:PiliPalaX/http/loading_state.dart'; +import 'package:PiliPalaX/http/user.dart'; import 'package:PiliPalaX/pages/fav_search/view.dart' show SearchType; +import 'package:PiliPalaX/utils/utils.dart'; import 'package:easy_debounce/easy_throttle.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart'; import 'package:PiliPalaX/common/skeleton/video_card_h.dart'; import 'package:PiliPalaX/common/widgets/http_error.dart'; @@ -113,6 +116,39 @@ class _FavDetailPageState extends State { // onPressed: () {}, // icon: const Icon(Icons.more_vert), // ), + Obx( + () => _favDetailController.isOwner.value + ? PopupMenuButton( + icon: const Icon(Icons.more_vert), + itemBuilder: (context) => [ + PopupMenuItem( + onTap: () { + Get.toNamed( + '/createFav', + parameters: {'mediaId': mediaId}, + ); + }, + child: Text('编辑信息'), + ), + if (!Utils.isDefault(_favDetailController.attr)) + PopupMenuItem( + onTap: () { + UserHttp.deleteFolder(mediaIds: [mediaId]) + .then((data) { + if (data['status']) { + SmartDialog.showToast('删除成功'); + Get.back(); + } else { + SmartDialog.showToast(data['msg']); + } + }); + }, + child: Text('删除'), + ), + ], + ) + : const SizedBox.shrink(), + ), const SizedBox(width: 6), ], flexibleSpace: FlexibleSpaceBar( diff --git a/lib/pages/video/detail/introduction/view.dart b/lib/pages/video/detail/introduction/view.dart index c4d0bea86..2fd27c9e2 100644 --- a/lib/pages/video/detail/introduction/view.dart +++ b/lib/pages/video/detail/introduction/view.dart @@ -180,10 +180,10 @@ class _VideoInfoState extends State with TickerProviderStateMixin { return DraggableScrollableSheet( minChildSize: 0, maxChildSize: 1, - initialChildSize: 0.6, + initialChildSize: 0.7, snap: true, expand: false, - snapSizes: const [0.6], + snapSizes: const [0.7], builder: (BuildContext context, ScrollController scrollController) { return FavPanel( ctr: videoIntroController, diff --git a/lib/pages/video/detail/introduction/widgets/create_fav_page.dart b/lib/pages/video/detail/introduction/widgets/create_fav_page.dart new file mode 100644 index 000000000..9fcf4df1c --- /dev/null +++ b/lib/pages/video/detail/introduction/widgets/create_fav_page.dart @@ -0,0 +1,384 @@ +import 'package:PiliPalaX/common/widgets/http_error.dart'; +import 'package:PiliPalaX/http/msg.dart'; +import 'package:PiliPalaX/http/user.dart'; +import 'package:PiliPalaX/utils/utils.dart'; +import 'package:easy_debounce/easy_throttle.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; +import 'package:get/get.dart'; +import 'package:image_cropper/image_cropper.dart'; +import 'package:image_picker/image_picker.dart'; + +class CreateFavPage extends StatefulWidget { + const CreateFavPage({super.key}); + + @override + State createState() => _CreateFavPageState(); +} + +class _CreateFavPageState extends State { + dynamic _mediaId; + late final _titleController = TextEditingController(); + late final _introController = TextEditingController(); + String? _cover; + bool _isPublic = true; + late final _imagePicker = ImagePicker(); + String? _errMsg; + int? _attr; + + @override + void initState() { + super.initState(); + _mediaId = Get.parameters['mediaId']; + if (_mediaId != null) { + _getFolderInfo(); + } + } + + void _getFolderInfo() { + UserHttp.folderInfo(mediaId: _mediaId).then((data) { + if (data['status']) { + _titleController.text = data['data']['title']; + _introController.text = data['data']['intro']; + _isPublic = Utils.isPublic(data['data']['attr']); + _cover = data['data']['cover']; + _attr = data['data']['attr']; + } else { + _errMsg = data['msg']; + } + setState(() {}); + }); + } + + @override + void dispose() { + _titleController.dispose(); + _introController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_mediaId != null ? '编辑' : '创建'), + actions: [ + TextButton( + onPressed: () { + if (_titleController.text.isEmpty) { + SmartDialog.showToast('名称不能为空'); + return; + } + UserHttp.addOrEditFolder( + isAdd: _mediaId == null, + mediaId: _mediaId, + title: _titleController.text, + privacy: _isPublic ? 0 : 1, + cover: _cover ?? '', + intro: _introController.text, + ).then((data) { + if (data['status']) { + Get.back(result: data['data']); + SmartDialog.showToast('${_mediaId != null ? '编辑' : '创建'}成功'); + } else { + SmartDialog.showToast(data['msg']); + } + }); + }, + child: const Text('完成'), + ), + const SizedBox(width: 16), + ], + ), + body: _mediaId != null + ? _titleController.text.isNotEmpty + ? _buildBody + : _errMsg?.isNotEmpty == true + ? Center( + child: CustomScrollView( + shrinkWrap: true, + slivers: [ + HttpError( + errMsg: _errMsg, + fn: _getFolderInfo, + ), + ], + ), + ) + : Center(child: CircularProgressIndicator()) + : _buildBody, + ); + } + + void _pickImg() async { + XFile? pickedFile = await _imagePicker.pickImage( + source: ImageSource.gallery, + imageQuality: 100, + ); + if (pickedFile != null && mounted) { + CroppedFile? croppedFile = await ImageCropper().cropImage( + sourcePath: pickedFile.path, + uiSettings: [ + AndroidUiSettings( + toolbarTitle: '裁剪', + toolbarColor: Theme.of(context).colorScheme.primary, + toolbarWidgetColor: Colors.white, + aspectRatioPresets: [ + CropAspectRatioPreset.ratio16x9, + ], + lockAspectRatio: true, + hideBottomControls: true, + initAspectRatio: CropAspectRatioPreset.ratio16x9, + ), + IOSUiSettings( + title: '裁剪', + aspectRatioPresets: [ + CropAspectRatioPreset.ratio16x9, + ], + aspectRatioLockEnabled: true, + resetAspectRatioEnabled: false, + aspectRatioPickerButtonHidden: true, + ), + ], + ); + if (croppedFile != null) { + MsgHttp.uploadImage( + path: croppedFile.path, + bucket: 'medialist', + dir: 'cover', + ).then((data) { + if (data['status']) { + _cover = data['data']['location']; + setState(() {}); + } else { + SmartDialog.showToast(data['msg']); + } + }); + } + } + } + + dynamic leadingStyle = TextStyle(fontSize: 14); + + Widget get _buildBody => SingleChildScrollView( + child: Column( + children: [ + if (_attr == null || !Utils.isDefault(_attr!)) ...[ + ListTile( + tileColor: Theme.of(context).colorScheme.onInverseSurface, + onTap: () { + EasyThrottle.throttle( + 'imagePicker', const Duration(milliseconds: 500), + () async { + if (_cover?.isNotEmpty == true) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + clipBehavior: Clip.hardEdge, + contentPadding: + const EdgeInsets.fromLTRB(0, 12, 0, 12), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + dense: true, + onTap: () { + Get.back(); + _pickImg(); + }, + title: const Text( + '替换封面', + style: TextStyle(fontSize: 14), + ), + ), + ListTile( + dense: true, + onTap: () { + Get.back(); + _cover = null; + setState(() {}); + }, + title: const Text( + '移除封面', + style: TextStyle(fontSize: 14), + ), + ), + ], + ), + ); + }, + ); + } else { + _pickImg(); + } + }); + }, + leading: Text( + '封面', + style: leadingStyle, + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_cover?.isNotEmpty == true) + Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: LayoutBuilder( + builder: (_, constraints) { + return ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + _cover!, + height: constraints.maxHeight, + width: constraints.maxHeight * 16 / 9, + fit: BoxFit.cover, + ), + ); + }, + ), + ), + const SizedBox(width: 10), + Icon( + Icons.keyboard_arrow_right, + color: Theme.of(context).colorScheme.outline, + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ListTile( + tileColor: Theme.of(context).colorScheme.onInverseSurface, + leading: Text.rich( + style: TextStyle( + height: 1, + fontSize: 14, + ), + TextSpan( + children: [ + TextSpan( + text: '*', + style: TextStyle( + fontSize: 14, + height: 1, + color: Theme.of(context).colorScheme.error, + ), + ), + TextSpan( + text: '名称', + style: TextStyle( + height: 1, + fontSize: 14, + ), + ), + ], + ), + ), + title: TextField( + readOnly: _attr != null && Utils.isDefault(_attr!), + controller: _titleController, + style: TextStyle( + fontSize: 14, + color: _attr != null && Utils.isDefault(_attr!) + ? Theme.of(context).colorScheme.outline + : null, + ), + inputFormatters: [ + LengthLimitingTextInputFormatter(21), + ], + decoration: InputDecoration( + isDense: true, + hintText: '名称', + hintStyle: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.outline, + ), + border: OutlineInputBorder( + borderSide: BorderSide.none, + gapPadding: 0, + ), + contentPadding: EdgeInsets.all(0), + ), + ), + ), + const SizedBox(height: 16), + if (_attr == null || !Utils.isDefault(_attr!)) ...[ + ListTile( + tileColor: Theme.of(context).colorScheme.onInverseSurface, + title: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: '简介', + style: leadingStyle, + ), + TextSpan( + text: '*', + style: TextStyle(color: Colors.transparent), + ) + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + minLines: 6, + maxLines: 6, + controller: _introController, + style: TextStyle(fontSize: 14), + inputFormatters: [ + LengthLimitingTextInputFormatter(201), + ], + decoration: InputDecoration( + isDense: true, + hintText: '简介', + hintStyle: TextStyle( + fontSize: 14, + color: Theme.of(context).colorScheme.outline, + ), + border: OutlineInputBorder( + borderSide: BorderSide.none, + gapPadding: 0, + ), + contentPadding: EdgeInsets.all(0), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ListTile( + onTap: () { + setState(() { + _isPublic = !_isPublic; + }); + }, + tileColor: Theme.of(context).colorScheme.onInverseSurface, + leading: Text( + '公开', + style: leadingStyle, + ), + trailing: Transform.scale( + alignment: Alignment.centerRight, + scale: 0.8, + child: Switch( + value: _isPublic, + onChanged: (value) { + setState(() { + _isPublic = value; + }); + }), + ), + ), + const SizedBox(height: 16), + ], + ), + ); +} diff --git a/lib/pages/video/detail/introduction/widgets/fav_panel.dart b/lib/pages/video/detail/introduction/widgets/fav_panel.dart index 28a01ffa4..f923eb2f7 100644 --- a/lib/pages/video/detail/introduction/widgets/fav_panel.dart +++ b/lib/pages/video/detail/introduction/widgets/fav_panel.dart @@ -1,3 +1,4 @@ +import 'package:PiliPalaX/models/user/fav_folder.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:PiliPalaX/common/widgets/http_error.dart'; @@ -62,7 +63,22 @@ class _FavPanelState extends State { actions: [ TextButton.icon( onPressed: () { - // TODO + Get.toNamed('/createFav')?.then((data) { + (widget.ctr?.favFolderData.value as FavFolderData?) + ?.list + ?.insert( + 1, + FavFolderItemData( + id: data['id'], + fid: data['fid'], + attr: data['attr'], + title: data['title'], + favState: data['fav_state'], + mediaCount: data['media_count'], + ), + ); + widget.ctr?.favFolderData.refresh(); + }); }, icon: Icon( Icons.add, diff --git a/lib/router/app_pages.dart b/lib/router/app_pages.dart index 1a303407c..1b641da4d 100644 --- a/lib/router/app_pages.dart +++ b/lib/router/app_pages.dart @@ -2,6 +2,7 @@ import 'package:PiliPalaX/pages/member/new/member_page.dart'; import 'package:PiliPalaX/pages/setting/sponsor_block_page.dart'; +import 'package:PiliPalaX/pages/video/detail/introduction/widgets/create_fav_page.dart'; import 'package:PiliPalaX/pages/webview/webview_page.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -186,6 +187,7 @@ class Routes { // 弹幕屏蔽管理 CustomGetPage(name: '/danmakuBlock', page: () => const DanmakuBlockPage()), CustomGetPage(name: '/sponsorBlock', page: () => const SponsorBlockPage()), + CustomGetPage(name: '/createFav', page: () => const CreateFavPage()), ]; } diff --git a/lib/utils/storage.dart b/lib/utils/storage.dart index 270ac2348..6cc95b878 100644 --- a/lib/utils/storage.dart +++ b/lib/utils/storage.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui'; import 'package:PiliPalaX/common/widgets/pair.dart'; -import 'package:PiliPalaX/main.dart'; import 'package:PiliPalaX/models/common/theme_type.dart'; import 'package:PiliPalaX/pages/video/detail/controller.dart' show SegmentType, SegmentTypeExt, SkipType; diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 23d5ac518..0d69fa49d 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -28,6 +28,10 @@ import '../models/github/latest.dart'; class Utils { static final Random random = Random(); + static bool isDefault(int attr) { + return (attr & 2) == 0; + } + static bool isPublic(int attr) { return (attr & 1) == 0; } @@ -119,10 +123,10 @@ class Utils { return DraggableScrollableSheet( minChildSize: 0, maxChildSize: 1, - initialChildSize: 0.6, + initialChildSize: 0.7, snap: true, expand: false, - snapSizes: const [0.6], + snapSizes: const [0.7], builder: (BuildContext context, ScrollController scrollController) { return GroupPanel( diff --git a/pubspec.lock b/pubspec.lock index fa17293d6..3daffaac1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -949,6 +949,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + image_cropper: + dependency: "direct main" + description: + name: image_cropper + sha256: fe37d9a129411486e0d93089b61bd326d05b89e78ad4981de54b560725bf5bd5 + url: "https://pub.dev" + source: hosted + version: "8.0.2" + image_cropper_for_web: + dependency: transitive + description: + name: image_cropper_for_web + sha256: "34256c8fb7fcb233251787c876bb37271744459b593a948a2db73caa323034d0" + url: "https://pub.dev" + source: hosted + version: "6.0.2" + image_cropper_platform_interface: + dependency: transitive + description: + name: image_cropper_platform_interface + sha256: e8e9d2ca36360387aee39295ce49029362ae4df3071f23e8e71f2b81e40b7531 + url: "https://pub.dev" + source: hosted + version: "7.0.0" image_picker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 501ed7283..8e62ccaf0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -165,6 +165,7 @@ dependencies: intl: ^0.19.0 grpc: ^4.0.1 flutter_svg: ^2.0.10+1 + image_cropper: ^8.0.2 dependency_overrides: screen_brightness: ^2.0.0+2