diff --git a/lib/controllers/moment_controller.dart b/lib/controllers/moment_controller.dart new file mode 100644 index 0000000..388a2d9 --- /dev/null +++ b/lib/controllers/moment_controller.dart @@ -0,0 +1,150 @@ +import 'package:chat/models/moment/moment_model.dart'; +import 'package:chat/routes/moments_routes.dart'; +import 'package:chat/services/moment_service.dart'; +import 'package:chat/utils/ui_tools.dart'; +import 'package:chat/views/moments/index/widgets/quick_reply_bar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyrefresh/easy_refresh.dart'; +import 'package:get/get.dart'; + +class MomentController extends GetxController { + static MomentController get to => Get.find(); + + final EasyRefreshController refreshController = EasyRefreshController(); + final ScrollController scrollController = ScrollController(); + + // 连接通知器 + final LinkHeaderNotifier headerNotifier = LinkHeaderNotifier(); + + final momentData = Rx(null); + + // 当前选中的moment id + int currentMomentIndex = 0; + + static const defaultHitText = '回复'; + final replyBarHitTest = defaultHitText.obs; + + @override + void onReady() { + super.onReady(); + callRefresh(); + } + + @override + void onClose() { + refreshController.dispose(); + scrollController.dispose(); + headerNotifier.dispose(); + super.onClose(); + } + + void callRefresh() { + refreshController.callRefresh(); + } + + Future refreshList() async { + final res = await MomentService.fetchMomentList(); + if (res != null) { + momentData.value = res; + } + refreshController.resetLoadState(); + } + + Future loadMoreList() async { + final res = await MomentService.fetchMomentList( + momentData.value?.data?.last.createdAt); + if (res != null) { + final data = res.data ?? []; + if (data.isEmpty || res.page?.hasMore == true) { + refreshController.finishLoad(noMore: true); + } + momentData.value?.data?.addAll(data); + momentData.refresh(); + } + } + + void pushToDetail(int index) { + currentMomentIndex = index; + Get.toNamed(MomentsRoutes.detail, arguments: {'index': index}); + } + + // 点赞 + Future likeMoment(MomentItemModel item) async { + final result = await MomentService.likeMoment(item.dynamicId!); + if (result != null) { + if (item.isLike != result && result && item.likerCount != null) { + item.likerCount = item.likerCount! + 1; + } else { + item.likerCount = item.likerCount! - 1; + } + item.isLike = result; + momentData.refresh(); + } + } + + // 删除动态 + Future delMoment(MomentItemModel item) async { + final result = await MomentService.delMoment(item.dynamicId!); + if (result == true) { + final moment = momentData.value?.data?.indexWhere( + (e) => e.dynamicId == item.dynamicId, + ); + momentData.value?.data!.removeAt(moment!); + momentData.refresh(); + Get.back(); + } + } + + // 删除评论 + Future delReply( + int index, + int dynamicId, [ + Comment? comment, + ]) async { + final result = await MomentService.delComment( + dynamicId, + comment?.id, + ); + if (result == true) { + final moment = momentData.value?.data?.firstWhere( + (e) => e.dynamicId == dynamicId, + orElse: () => MomentItemModel(), + ); + if (moment?.dynamicId == null) return; + moment?.comments?.removeAt(index); + momentData.refresh(); + UiTools.toast('删除成功'); + } + } + + // 发送回复 + Future sendReply( + int dynamicId, + String content, [ + Comment? comment, + ]) async { + final result = await MomentService.replyComment( + dynamicId, + content, + comment?.id, + ); + if (result != null) { + final moment = momentData.value?.data?.firstWhere( + (e) => e.dynamicId == dynamicId, + orElse: () => MomentItemModel(), + ); + if (moment?.dynamicId == null) return; + moment?.comments?.add(result); + momentData.refresh(); + Get.back(); + // UiTools.toast('回复成功'); + } + } + + // 弹出QuickReplyBar + Future showReplyBar(int dynamicId, [Comment? comment]) async { + Get.bottomSheet( + QuickReplyBar(dynamicId: dynamicId, comment: comment), + ); + } +} diff --git a/lib/controllers/publish_controller.dart b/lib/controllers/publish_controller.dart new file mode 100644 index 0000000..e732c86 --- /dev/null +++ b/lib/controllers/publish_controller.dart @@ -0,0 +1,149 @@ +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/models/upload_model.dart'; +import 'package:chat/routes/moments_routes.dart'; +import 'package:chat/services/moment_service.dart'; +import 'package:chat/utils/ui_tools.dart'; +import 'package:chat/views/moments/publish/widgets/delete_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:get/get.dart'; +import 'package:tuple/tuple.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +class PublishController extends GetxController { + static PublishController get to => Get.find(); + static const fileMaxLength = 9; + + /// 发布"发现"的文件列表 + final publishFileList = [].obs; + + /// 已上传的文件获得的url + /// + /// [Tuple(hashCode, UploadModel)] + final uploadedFileList = >[]; + + /// preview页显示的下标 + final publistFileIndex = 0.obs; + + /// 发布的文本内容 + final publishContent = ''.obs; + + /// 退出确认 + Future exitConfirmation() async { + if (publishFileList.isEmpty && publishContent.isEmpty) return true; + final result = await Get.defaultDialog( + title: '确认', + middleText: '退出后将不会保存内容', + onConfirm: () { + Get.back(result: true); + }, + ); + return result == true; + } + + Future publish() async { + FocusScope.of(Get.context!).requestFocus(FocusNode()); + EasyLoading.show(status: '上传中', maskType: EasyLoadingMaskType.black); + final result = await uploadAllFile(); + if (!result) return; + final res = await MomentService.publishMoment( + description: publishContent.value, + pictures: uploadedFileList.map((e) => e.item2!.url).toList(), + ); + EasyLoading.dismiss(); + if (res != null) { + UiTools.toast('发表成功'); + Get.back(); + MomentController.to.refreshList(); + } + } + + Future uploadAllFile() async { + try { + for (var i = 0; i < publishFileList.length; i++) { + final file = publishFileList[i]; + final exists = uploadedFileList.any( + (e) => (e.item1 == file.hashCode && e.item2?.url != null), + ); + if (!exists) { + final res = await MomentService.uploadFile((await file.file)!.path); + if (res == null) throw Exception('上传失败'); + for (var index = 0; index < uploadedFileList.length; index++) { + final uploaded = uploadedFileList[index]; + if (uploaded.item1 == file.hashCode) { + uploadedFileList[index] = uploaded.withItem2(res); + } + } + } + } + return true; + } catch (e) { + UiTools.toast('上传失败'); + return false; + } + } + + Future deleteImageOrVideo(int index) async { + final result = await Get.dialog(const PublishDeleteDialog()); + if (result == true) { + removeFileByIndex(index); + } + } + + /// 选择文件并添加到[publishFileList]里 + Future pickImageOrVideo() async { + FocusScope.of(Get.context!).requestFocus(FocusNode()); + final result = await AssetPicker.pickAssets( + Get.context!, + pickerConfig: AssetPickerConfig( + maxAssets: fileMaxLength - publishFileList.length, + ), + ); + if (result == null) return; + bool videoAlreadyExists = + publishFileList.any((e) => e.type == AssetType.video); + final list = result.where( + (e) { + if (e.type == AssetType.image) return true; + if (videoAlreadyExists) return false; + final authorized = e.videoDuration.inSeconds <= 30; + if (authorized) videoAlreadyExists = true; + return authorized; + }, + ).toList(); + if (videoAlreadyExists && list.length != result.length) { + UiTools.toast('最多能选择一个视频, 且视频时长不能超过30秒'); + } else if (list.length != result.length) { + UiTools.toast('视频时长不能超过30秒'); + } + // ignore: todo + // TODO: 限制视频或者图片大小 + publishFileList.addAll(list); + list.asMap().forEach((index, file) { + uploadedFileList.add(Tuple2(file.hashCode, null)); + }); + } + + /// preview[publishFileList]里的文件 + void previewFiles(int index) { + FocusScope.of(Get.context!).requestFocus(FocusNode()); + publistFileIndex.value = index; + Get.toNamed(MomentsRoutes.publishPreview); + } + + /// 按index删除file + void removeFileByIndex(int index) { + uploadedFileList.removeAt(index); + publishFileList.removeAt(index); + if (publishFileList.isEmpty && + Get.currentRoute == MomentsRoutes.publishPreview) Get.back(); + if (publistFileIndex.value >= publishFileList.length) { + publistFileIndex.value = publishFileList.length - 1; + } + } + + /// 删除preview当前显示的file + void removeFileByCurrentIndex() { + removeFileByIndex(publistFileIndex.value); + } +} diff --git a/lib/main.dart b/lib/main.dart index 3fe3b0e..d6128e4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,7 @@ import 'package:chat/configs/themes.dart'; +import 'package:chat/controllers/group_controller.dart'; +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/controllers/private_controller.dart'; import 'package:chat/routes/app_router.dart'; import 'package:chat/routes/app_routes.dart'; import 'package:chat/services/auth_service.dart'; @@ -23,18 +26,34 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return GetMaterialApp( - title: 'ZH-CHAT', - debugShowCheckedModeBanner: false, - theme: Themes.light, - darkTheme: Themes.dark, - initialRoute: AppRoutes.transit, - defaultTransition: Transition.cupertino, - getPages: AppRouter.getPages, - builder: EasyLoading.init(), - initialBinding: BindingsBuilder(() { + title: 'ZH-CHAT', + debugShowCheckedModeBanner: false, + theme: Themes.light, + darkTheme: Themes.dark, + initialRoute: AppRoutes.transit, + defaultTransition: Transition.cupertino, + getPages: AppRouter.getPages, + builder: EasyLoading.init(), + initialBinding: BindingsBuilder( + () { Get.put(AuthService()); Get.put(TabbarService()); Get.put(TimService()); - })); + + Get.lazyPut( + () => GroupController(), + fenix: true, + ); + Get.lazyPut( + () => PrivateController(), + fenix: true, + ); + Get.lazyPut( + () => MomentController(), + fenix: true, + ); + }, + ), + ); } } diff --git a/lib/models/moment/moment_model.dart b/lib/models/moment/moment_model.dart new file mode 100644 index 0000000..b38438f --- /dev/null +++ b/lib/models/moment/moment_model.dart @@ -0,0 +1,91 @@ +import 'package:chat/models/page_model.dart'; +import 'package:chat/models/user_info_model.dart'; + +class MomentModel { + MomentModel({ + this.data, + this.page, + }); + + List? data; + PageModel? page; + + factory MomentModel.fromMap(Map json) => MomentModel( + data: List.from( + json['data'].map( + (x) => MomentItemModel.fromMap(x), + ), + ), + page: PageModel.fromJson(json['page']), + ); +} + +class MomentItemModel { + MomentItemModel({ + this.dynamicId, + this.user, + this.description, + this.pictures, + this.isLike, + this.isMe, + this.likerCount, + this.liker, + this.comments, + this.createdAt, + this.time, + }); + + int? dynamicId; + UserInfoModel? user; + String? description; + bool? isLike; + bool? isMe; + int? likerCount; + List? pictures; + List? liker; + List? comments; + String? createdAt; + String? time; + + factory MomentItemModel.fromMap(Map json) => MomentItemModel( + dynamicId: json['dynamic_id'], + user: UserInfoModel.fromJson(json['user']), + description: json['description'], + pictures: List.from(json['pictures'].map((x) => x)), + isLike: json['is_like'], + isMe: json['is_me'], + likerCount: json['liker_count'], + liker: List.from( + json['liker'].map((x) => UserInfoModel.fromJson(x))), + comments: + List.from(json['comments'].map((x) => Comment.fromMap(x))), + createdAt: json['created_at'], + time: json['time'], + ); +} + +class Comment { + Comment({ + this.id, + this.user, + this.parent, + this.content, + required this.isMe, + }); + + int? id; + UserInfoModel? user; + UserInfoModel? parent; + String? content; + bool isMe; + + factory Comment.fromMap(Map json) => Comment( + id: json['comment_id'], + parent: json['parent'] == null + ? null + : UserInfoModel.fromJson(json['parent']), + user: UserInfoModel.fromJson(json['user']), + content: json['content'], + isMe: json['is_me'], + ); +} diff --git a/lib/models/page_model.dart b/lib/models/page_model.dart new file mode 100644 index 0000000..21e2404 --- /dev/null +++ b/lib/models/page_model.dart @@ -0,0 +1,23 @@ +class PageModel { + PageModel({ + required this.current, + required this.totalPage, + required this.perPage, + required this.hasMore, + required this.total, + }); + + final int current; + final int totalPage; + final int perPage; + final bool hasMore; + final int total; + + factory PageModel.fromJson(Map json) => PageModel( + current: json['current'], + totalPage: json['total_page'], + perPage: json['per_page'], + hasMore: json['has_more'], + total: json['total'], + ); +} diff --git a/lib/models/upload_model.dart b/lib/models/upload_model.dart new file mode 100644 index 0000000..e9a5c41 --- /dev/null +++ b/lib/models/upload_model.dart @@ -0,0 +1,20 @@ +class UploadModel { + final bool exists; + final int size; + final String path; + final String url; + + UploadModel({ + required this.exists, + required this.size, + required this.path, + required this.url, + }); + + factory UploadModel.fromJson(Map json) => UploadModel( + exists: json['exists'], + size: json['size'], + path: json['path'], + url: json['url'], + ); +} diff --git a/lib/models/user_info_model.dart b/lib/models/user_info_model.dart new file mode 100644 index 0000000..98c4c27 --- /dev/null +++ b/lib/models/user_info_model.dart @@ -0,0 +1,23 @@ +class UserInfoModel { + UserInfoModel({ + required this.userId, + required this.username, + required this.nickname, + required this.avatar, + required this.address, + }); + + int userId; + String username; + String nickname; + String avatar; + String? address; + + factory UserInfoModel.fromJson(Map json) => UserInfoModel( + userId: json['user_id'], + username: json['username'], + nickname: json['nickname'], + avatar: json['avatar'], + address: json['address'], + ); +} diff --git a/lib/routes/moments_routes.dart b/lib/routes/moments_routes.dart new file mode 100644 index 0000000..f4addd8 --- /dev/null +++ b/lib/routes/moments_routes.dart @@ -0,0 +1,46 @@ +import 'package:chat/middleware/auth_middleware.dart'; +import 'package:chat/views/moments/detail/detail_page.dart'; +import 'package:chat/views/moments/index/index_page.dart'; +import 'package:chat/views/moments/publish/preview_page.dart'; +import 'package:chat/views/moments/publish/publish_page.dart'; +import 'package:chat/views/moments/user/index_page.dart'; +import 'package:get/get.dart'; + +abstract class MomentsRoutes { + static const String index = '/moments'; + + static const String publish = '/moments/publish'; + static const String publishPreview = '/moments/publish/preview'; + + static const String detail = '/moments/detail'; + + static const String user = '/moments/user'; + + static GetPage router = GetPage( + name: MomentsRoutes.index, + middlewares: [ + EnsureAuthMiddleware(), + ], + page: () => const MomentsPage(), + children: [ + GetPage( + name: '/publish', + page: () => const MomentsPublishPage(), + children: [ + GetPage( + name: '/preview', + page: () => const PublishPreviewPage(), + ), + ], + ), + GetPage( + name: '/detail', + page: () => const MomentDetailPage(), + ), + GetPage( + name: '/user', + page: () => const MomentsUserPage(), + ), + ], + ); +} diff --git a/lib/services/moment_service.dart b/lib/services/moment_service.dart new file mode 100644 index 0000000..c9c33ba --- /dev/null +++ b/lib/services/moment_service.dart @@ -0,0 +1,123 @@ +import 'package:chat/models/moment/moment_model.dart'; +import 'package:chat/models/upload_model.dart'; +import 'package:chat/utils/request/http.dart'; +import 'package:chat/utils/ui_tools.dart'; + +class MomentService { + MomentService._(); + + /// 获取“发现”列表数据 + /// + /// [createAt] 最后一列的create_at + static Future fetchMomentList([String? createAt]) async { + try { + final result = await Http.get( + 'user/dynamics', + params: {'created_at': createAt}, + ); + return MomentModel.fromMap(result); + } catch (e) { + UiTools.toast('获取发现列表失败'); + } + return null; + } + + /// 点赞 + /// + /// [dynamicId] + static Future likeMoment(int dynamicId) async { + try { + final result = await Http.post('user/dynamics/$dynamicId/like'); + return result['is_like']; + } catch (e) { + UiTools.toast('点赞失败'); + } + return null; + } + + /// 删除 + /// + /// [dynamicId] + static Future delMoment(int dynamicId) async { + try { + final result = await Http.delete('user/dynamics/$dynamicId'); + return result; + } catch (e) { + UiTools.toast('删除失败'); + } + return null; + } + + /// 回复或评论 + /// [dynamicId] + /// [content] 评论内容 + /// [parentId] 回复的评论id + static Future replyComment( + int dynamicId, + String content, [ + int? parentId, + ]) async { + try { + final result = await Http.post( + 'user/dynamics/$dynamicId/comment', + data: { + 'content': content, + 'parent_id': parentId, + }, + ); + return Comment.fromMap(result); + } catch (e) { + UiTools.toast('评论失败'); + } + return null; + } + + /// 删除评论 + /// [dynamicId] + /// [comment] 评论id + static Future delComment( + int dynamicId, [ + int? commentId, + ]) async { + try { + final result = await Http.delete( + 'user/dynamics/$dynamicId/comment/$commentId', + ); + return result; + } catch (e) { + UiTools.toast('删除评论失败'); + } + return null; + } + + /// 上传文件 + static Future uploadFile(String filePath) async { + try { + final result = await Http.upload('storage/upload', filePath: filePath); + return UploadModel.fromJson(result); + } catch (e) { + UiTools.toast('上传失败'); + } + return null; + } + + /// 发布“发现”内容 + /// + /// [description] 文本内容 + /// [pictures] 媒体内容 + static Future publishMoment({ + required String description, + required List pictures, + }) async { + try { + final result = await Http.post('user/dynamics', data: { + 'description': description, + 'pictures': pictures, + }); + return result; + } catch (e) { + UiTools.toast('发布失败'); + } + return null; + } +} diff --git a/lib/views/moments/detail/detail_page.dart b/lib/views/moments/detail/detail_page.dart new file mode 100644 index 0000000..aa1061f --- /dev/null +++ b/lib/views/moments/detail/detail_page.dart @@ -0,0 +1,51 @@ +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/views/moments/index/widgets/moment_list_item.dart'; +import 'package:chat/views/moments/index/widgets/moment_list_reply.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MomentDetailPage extends StatelessWidget { + const MomentDetailPage({Key? key}) : super(key: key); + @override + Widget build(BuildContext context) { + final ctrl = MomentController.to; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + appBar: AppBar( + title: const Text('动态详情'), + // actions: [ + // PopupMenuButton( + // itemBuilder: (context) => [ + // const PopupMenuItem( + // child: Text(''), + // ), + // ], + // ) + // ], + ), + body: SafeArea( + child: SingleChildScrollView( + child: Obx(() { + final item = + ctrl.momentData.value!.data![ctrl.currentMomentIndex]; + return Column( + children: [ + MomentListItem(item: item), + MomentListItemReplay( + index: 0, + item: item, + reply: (value) => MomentController.to + .showReplyBar(item.dynamicId!, value), + ), + const SizedBox(height: 16), + ], + ); + }), + ), + ), + ), + ); + } +} diff --git a/lib/views/moments/index/index_page.dart b/lib/views/moments/index/index_page.dart index d3ebfce..a37fc15 100644 --- a/lib/views/moments/index/index_page.dart +++ b/lib/views/moments/index/index_page.dart @@ -1,19 +1,90 @@ +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/models/moment/moment_model.dart'; +import 'package:chat/views/moments/index/widgets/moment_header.dart'; +import 'package:chat/views/moments/index/widgets/moment_list_item.dart'; +import 'package:chat/views/moments/index/widgets/moment_list_reply.dart'; +import 'package:chat/widgets/custom_easy_refresh.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_easyrefresh/easy_refresh.dart'; +import 'package:get/get.dart'; -class MomentsPage extends StatefulWidget { +class MomentsPage extends StatelessWidget { const MomentsPage({Key? key}) : super(key: key); - - @override - _MomentsPageState createState() => _MomentsPageState(); -} - -class _MomentsPageState extends State { @override Widget build(BuildContext context) { + final ctrl = MomentController.to; return Scaffold( - appBar: AppBar( - title: const Text('发现'), + body: Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewPadding.bottom), + child: EasyRefresh.custom( + scrollController: ctrl.scrollController, + controller: ctrl.refreshController, + header: LinkHeader( + ctrl.headerNotifier, + extent: 70.0, + triggerDistance: 70.0, + completeDuration: const Duration(milliseconds: 500), + ), + footer: CustomEasyRefresh.footer, + onRefresh: () => ctrl.refreshList(), + onLoad: () => ctrl.loadMoreList(), + slivers: [ + MomentHeader( + linkNotifier: ctrl.headerNotifier, + onTitleDoubleTap: () { + ctrl.scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 300), + curve: Curves.fastOutSlowIn, + ); + }, + ), + Obx(() { + final momentList = ctrl.momentData.value?.data ?? []; + if (momentList.isEmpty) { + return SliverFillRemaining( + child: CustomEasyRefresh.empty(text: '暂无动态内容'), + ); + } + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index.isEven) { + final i = (index / 2).round(); + return momentItemWidget(i, momentList[i]); + } else { + return const Divider(); + } + }, + childCount: (momentList.length * 2) - 1, + ), + ); + }) + ], + ), ), ); } + + Widget momentItemWidget(int index, MomentItemModel item) { + return Column(children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => MomentController.to.pushToDetail(index), + child: MomentListItem(item: item), + ), + Padding( + padding: const EdgeInsets.only(left: 50), + child: MomentListItemReplay( + index: index, + item: item, + maxDisplayCount: 3, + maxLine: 2, + reply: (value) => + MomentController.to.showReplyBar(item.dynamicId!, value), + ), + ), + ]); + } } diff --git a/lib/views/moments/index/widgets/future_button.dart b/lib/views/moments/index/widgets/future_button.dart new file mode 100644 index 0000000..e6aee97 --- /dev/null +++ b/lib/views/moments/index/widgets/future_button.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +typedef VoidFutureCallBack = Future Function(); + +class FutureTextButton extends StatefulWidget { + const FutureTextButton({ + Key? key, + this.onLongPress, + this.onHover, + this.onFocusChange, + this.style, + this.focusNode, + this.autofocus = false, + this.clipBehavior = Clip.none, + required this.child, + required this.onPressed, + }) : super(key: key); + + final VoidFutureCallBack? onPressed; + final VoidCallback? onLongPress; + final ValueChanged? onHover; + final ValueChanged? onFocusChange; + final ButtonStyle? style; + final Clip clipBehavior; + final FocusNode? focusNode; + final bool autofocus; + final Widget child; + + @override + State createState() => _FutureTextButtonState(); +} + +class _FutureTextButtonState extends State { + bool _isBusy = false; + + Future onPressed() async { + if (_isBusy) return; + setState(() => _isBusy = true); + try { + await widget.onPressed?.call(); + } catch (e) { + rethrow; + } finally { + if (mounted) setState(() => _isBusy = false); + } + } + + @override + Widget build(BuildContext context) { + return TextButton( + key: widget.key, + onPressed: _isBusy || widget.onPressed == null ? null : () => onPressed(), + onLongPress: widget.onLongPress, + onHover: widget.onHover, + onFocusChange: widget.onFocusChange, + style: widget.style, + focusNode: widget.focusNode, + autofocus: widget.autofocus, + clipBehavior: widget.clipBehavior, + child: widget.child, + ); + } +} diff --git a/lib/views/moments/index/widgets/grid_media.dart b/lib/views/moments/index/widgets/grid_media.dart new file mode 100644 index 0000000..8d7d4f8 --- /dev/null +++ b/lib/views/moments/index/widgets/grid_media.dart @@ -0,0 +1,143 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/views/moments/index/widgets/media_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class GridMedia extends StatelessWidget { + final List? mediaList; + + const GridMedia(this.mediaList, {Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (mediaList?.length == 1) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: Get.width * 0.5, + maxWidth: Get.width * 0.5, + minHeight: 50, + minWidth: 50, + ), + child: mediaList!.first.split('?').first.isImageFileName + ? imageWidget(mediaList![0], 0) + : videoWidget(mediaList![0], 0), + ); + } else if (mediaList?.length == 2 || mediaList?.length == 4) { + return GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: mediaList?.length, + itemBuilder: (context, index) { + final source = mediaList![index]; + if (source.split('?').first.isImageFileName) { + return imageWidget(source, index); + } + return videoWidget(source, index); + }, + ); + } else { + return GridView.builder( + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1, + ), + itemCount: mediaList?.length ?? 0, + itemBuilder: (context, index) { + final source = mediaList![index]; + if (source.split('?').first.isImageFileName) { + return imageWidget(source, index); + } + return videoWidget(source, index); + }, + ); + } + } + + Widget videoWidget(String sourse, int index) { + return GestureDetector( + onTap: () { + pushToPreview(index); + }, + child: const ColoredBox( + color: AppColors.black, + child: Center( + child: ClipOval( + child: ColoredBox( + color: AppColors.white, + child: SizedBox.square( + dimension: 30, + child: Icon(Icons.play_arrow), + ), + ), + ), + ), + ), + + // ignore: todo + // TODO: 缩略图太耗流量 + // child: FutureBuilder( + // // future: getVideoThumbnail(sourse), + // builder: (context, snapshot) { + // if (snapshot.data == null) { + // return Container(color: Colors.white); + // } else { + // return DecoratedBox( + // decoration: BoxDecoration( + // image: DecorationImage( + // image: MemoryImage(snapshot.data!), + // fit: BoxFit.cover, + // ), + // ), + // child: const Center( + // child: Icon( + // Icons.play_arrow_rounded, + // color: Colors.white, + // size: 40, + // ), + // ), + // ); + // } + // }, + // ), + ); + } + + Widget imageWidget(String source, int index) { + return GestureDetector( + onTap: () { + pushToPreview(index); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: source, + alignment: Alignment.center, + fit: BoxFit.cover, + ), + ), + ); + } + + void pushToPreview(int index) { + Get.dialog( + MomentMediaPreview( + mediaSourceList: mediaList!, + initialPage: index, + ), + useSafeArea: false, + ); + } +} diff --git a/lib/views/moments/index/widgets/media_preview.dart b/lib/views/moments/index/widgets/media_preview.dart new file mode 100644 index 0000000..7c2697f --- /dev/null +++ b/lib/views/moments/index/widgets/media_preview.dart @@ -0,0 +1,107 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chat/configs/app_colors.dart'; +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:video_player/video_player.dart'; + +class MomentMediaPreview extends StatelessWidget { + final List mediaSourceList; + final int? initialPage; + const MomentMediaPreview({ + Key? key, + required this.mediaSourceList, + this.initialPage, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.pop(context), + child: Material( + color: AppColors.black, + child: SafeArea( + child: Stack(children: [ + Positioned.fill( + child: PhotoViewGallery.builder( + pageController: PageController(initialPage: initialPage ?? 0), + itemCount: mediaSourceList.length, + builder: (context, index) { + final source = mediaSourceList[index]; + if (source.split('?').first.isImageFileName) { + return PhotoViewGalleryPageOptions( + imageProvider: CachedNetworkImageProvider(source), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + ); + } else if (source.split('?').first.isVideoFileName) { + return PhotoViewGalleryPageOptions.customChild( + disableGestures: true, + child: _VideoPreview(source: source), + ); + } else { + return PhotoViewGalleryPageOptions.customChild( + child: const Center( + child: Text( + '格式不支持', + style: TextStyle(color: AppColors.white), + ), + ), + ); + } + }, + ), + ), + const SafeArea( + child: Padding( + padding: EdgeInsets.all(4.0), + child: BackButton(color: AppColors.white), + ), + ) + ]), + ), + ), + ); + } +} + +class _VideoPreview extends StatefulWidget { + final String source; + const _VideoPreview({Key? key, required this.source}) : super(key: key); + + @override + State<_VideoPreview> createState() => __VideoPreviewState(); +} + +class __VideoPreviewState extends State<_VideoPreview> { + late final videoCtrl = VideoPlayerController.network(widget.source); + + @override + void dispose() { + videoCtrl.dispose(); + super.dispose(); + } + + Future initVideo() async { + await videoCtrl.initialize(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: initVideo(), + builder: (context, snapshot) { + return Chewie( + controller: ChewieController( + showOptions: false, + autoPlay: true, + aspectRatio: videoCtrl.value.aspectRatio, + videoPlayerController: videoCtrl, + ), + ); + }, + ); + } +} diff --git a/lib/views/moments/index/widgets/moment_avatar.dart b/lib/views/moments/index/widgets/moment_avatar.dart new file mode 100644 index 0000000..00b72cf --- /dev/null +++ b/lib/views/moments/index/widgets/moment_avatar.dart @@ -0,0 +1,30 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:chat/configs/app_colors.dart'; +import 'package:flutter/material.dart'; + +class MomentAvatar extends StatelessWidget { + final String imageUrl; + const MomentAvatar({Key? key, String? imageUrl}) + : imageUrl = imageUrl ?? '', + super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 40, + height: 40, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + color: AppColors.white, + image: imageUrl.isNotEmpty + ? DecorationImage( + image: CachedNetworkImageProvider(imageUrl), + fit: BoxFit.cover, + ) + : null, + ), + ), + ); + } +} diff --git a/lib/views/moments/index/widgets/moment_header.dart b/lib/views/moments/index/widgets/moment_header.dart new file mode 100644 index 0000000..9cba93d --- /dev/null +++ b/lib/views/moments/index/widgets/moment_header.dart @@ -0,0 +1,184 @@ +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/routes/moments_routes.dart'; +import 'package:chat/services/tabbar_service.dart'; +import 'package:chat/widgets/custom_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_easyrefresh/easy_refresh.dart'; +import 'package:get/get.dart'; + +class MomentHeader extends StatelessWidget { + final LinkHeaderNotifier linkNotifier; + final VoidCallback? onTitleDoubleTap; + const MomentHeader({ + Key? key, + required this.linkNotifier, + this.onTitleDoubleTap, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SliverAppBar( + systemOverlayStyle: SystemUiOverlayStyle.light, + pinned: true, + expandedHeight: 260, + backgroundColor: AppColors.primary, + foregroundColor: AppColors.white, + titleTextStyle: const TextStyle(color: AppColors.white), + leading: CircleHeader(linkNotifier), + actions: [ + IconButton( + onPressed: () { + Get.toNamed(MomentsRoutes.publish); + }, + icon: const Icon( + Icons.camera_alt_rounded, + ), + ), + ], + flexibleSpace: LayoutBuilder( + builder: (context, constraints) { + final FlexibleSpaceBarSettings settings = context + .dependOnInheritedWidgetOfExactType()!; + return FlexibleSpaceBar( + collapseMode: CollapseMode.pin, + centerTitle: true, + title: Visibility( + visible: constraints.maxHeight <= settings.minExtent, + child: GestureDetector( + onDoubleTap: () { + onTitleDoubleTap?.call(); + }, + child: Text( + '发现', + style: Theme.of(context) + .appBarTheme + .titleTextStyle + ?.copyWith(color: AppColors.white), + ), + ), + ), + background: const _HeaderBackground(), + ); + }, + ), + ); + } +} + +class _HeaderBackground extends StatelessWidget { + const _HeaderBackground({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Theme.of(context).scaffoldBackgroundColor, + child: Stack( + children: [ + Positioned.fill( + bottom: 32, + child: GestureDetector( + child: Image.asset( + 'assets/backgrounds/moment_3.jpg', + fit: BoxFit.cover, + ), + ), + ), + Positioned( + right: 16, + bottom: 0, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + height: 52, + child: Obx(() { + return const Text( + "UserController.to.userInfo.value?.nickname ?? ''", + style: TextStyle( + color: AppColors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ); + }), + ), + const SizedBox(width: 8), + Obx(() { + return GestureDetector( + onTap: () => TabbarService.to.index = 4, + child: CustomAvatar( + '', + size: 64, + ), + ); + }), + ], + ), + ) + ], + ), + ); + } +} + +// 圆形Header +class CircleHeader extends StatefulWidget { + final LinkHeaderNotifier linkNotifier; + + const CircleHeader(this.linkNotifier, {Key? key}) : super(key: key); + + @override + CircleHeaderState createState() { + return CircleHeaderState(); + } +} + +class CircleHeaderState extends State { + // 指示器值 + double? _indicatorValue = 0.0; + + RefreshMode get _refreshState => widget.linkNotifier.refreshState; + double get _pulledExtent => widget.linkNotifier.pulledExtent; + + @override + void initState() { + super.initState(); + widget.linkNotifier.addListener(onLinkNotify); + } + + void onLinkNotify() { + if (!mounted) return; + setState(() { + if (_refreshState == RefreshMode.armed || + _refreshState == RefreshMode.refresh) { + _indicatorValue = null; + } else if (_refreshState == RefreshMode.refreshed || + _refreshState == RefreshMode.done) { + _indicatorValue = 1.0; + } else { + if (_refreshState == RefreshMode.inactive) { + _indicatorValue = 0.0; + } else { + double indicatorValue = _pulledExtent / 70.0 * 0.8; + _indicatorValue = indicatorValue < 0.8 ? indicatorValue : 0.8; + } + } + }); + } + + @override + Widget build(BuildContext context) { + return Center( + child: SizedBox.square( + dimension: 24.0, + child: CircularProgressIndicator( + value: _indicatorValue, + valueColor: const AlwaysStoppedAnimation(AppColors.white), + strokeWidth: 2.4, + ), + ), + ); + } +} diff --git a/lib/views/moments/index/widgets/moment_list_item.dart b/lib/views/moments/index/widgets/moment_list_item.dart new file mode 100644 index 0000000..256ba5b --- /dev/null +++ b/lib/views/moments/index/widgets/moment_list_item.dart @@ -0,0 +1,204 @@ +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/configs/app_size.dart'; +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/models/moment/moment_model.dart'; +import 'package:chat/views/moments/index/widgets/future_button.dart'; +import 'package:chat/views/moments/index/widgets/grid_media.dart'; +import 'package:chat/views/moments/index/widgets/moment_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MomentListItem extends StatelessWidget { + final MomentItemModel item; + MomentListItem({Key? key, required this.item}) : super(key: key); + + final actionStyle = ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(vertical: 4, horizontal: 8)), + minimumSize: MaterialStateProperty.all(Size.zero), + foregroundColor: MaterialStateProperty.all(AppColors.deep.withOpacity(0.6)), + textStyle: MaterialStateProperty.all( + const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + ), + ), + ); + + // Future getVideoThumbnail(String source) async { + // return await VideoThumbnail.thumbnailData( + // video: source, + // imageFormat: ImageFormat.JPEG, + // maxWidth: 128, + // quality: 25, + // ); + // } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSize.horizontalLargePadding, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MomentAvatar(imageUrl: item.user?.avatar), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.user?.nickname ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: AppSize.fontSize + 1, + color: AppColors.darkBlue, + ), + ), + const SizedBox(height: 2), + Text( + item.description ?? '', + style: const TextStyle( + fontSize: AppSize.fontSize, + ), + ), + const SizedBox(height: 8), + Visibility( + visible: item.pictures?.isNotEmpty ?? false, + child: GridMedia(item.pictures), + ), + timeAndAction() + ], + ), + ) + ], + ), + ); + } + + // Widget gridMedia() { + // if (item.pictures?.length == 1) { + // return ConstrainedBox( + // constraints: BoxConstraints( + // maxHeight: Get.width * 0.5, + // maxWidth: Get.width * 0.5, + // minHeight: 50, + // minWidth: 50, + // ), + // child: imageWidget(item.pictures![0], 0), + // ); + // } else { + // return GridView.builder( + // padding: EdgeInsets.zero, + // shrinkWrap: true, + // physics: const NeverScrollableScrollPhysics(), + // gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + // crossAxisCount: 3, + // mainAxisSpacing: 8, + // crossAxisSpacing: 8, + // childAspectRatio: 1, + // ), + // itemCount: item.pictures?.length ?? 0, + // itemBuilder: (context, index) { + // final source = item.pictures![index]; + // if (source.isImageFileName) { + // return imageWidget(source, index); + // } + // return videoWidget(source, index); + // }, + // ); + // } + // } + + Widget timeAndAction() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Text( + item.time ?? '', + style: const TextStyle( + fontSize: AppSize.fontSize - 2, + color: AppColors.unactive, + ), + ), + item.isMe == true + ? InkWell( + onTap: () async { + OkCancelResult result = await showOkCancelAlertDialog( + style: AdaptiveStyle.iOS, + context: Get.context!, + title: '系统提示', + message: '删除后无法撤回', + okLabel: '确定', + cancelLabel: '取消', + defaultType: OkCancelAlertDefaultType.cancel, + ); + if (result == OkCancelResult.ok) { + MomentController.to.delMoment(item); + } + }, + child: const Padding( + padding: EdgeInsets.all(10.0), + child: Text( + '删除', + style: TextStyle( + fontSize: AppSize.fontSize - 2, + color: AppColors.darkBlue, + ), + ), + ), + ) + : const SizedBox(), + ], + ), + ), + FutureTextButton( + style: actionStyle, + onPressed: () => MomentController.to.likeMoment(item), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Visibility( + visible: item.isLike ?? false, + child: const Icon(Icons.favorite, size: 14), + replacement: const Icon(Icons.favorite_border, size: 14), + ), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Text('${item.likerCount ?? ''}'), + ), + ], + ), + ), + const SizedBox(width: 6), + TextButton( + style: actionStyle, + onPressed: () => MomentController.to.showReplyBar(item.dynamicId!), + child: Row( + children: [ + const Icon(Icons.message, size: 14), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Text('${item.comments?.length ?? ''}'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/moments/index/widgets/moment_list_reply.dart b/lib/views/moments/index/widgets/moment_list_reply.dart new file mode 100644 index 0000000..f98689d --- /dev/null +++ b/lib/views/moments/index/widgets/moment_list_reply.dart @@ -0,0 +1,200 @@ +import 'package:adaptive_dialog/adaptive_dialog.dart'; +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/configs/app_size.dart'; +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/models/moment/moment_model.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class MomentListItemReplay extends StatelessWidget { + final int? maxDisplayCount; + final int? maxLine; + final int index; + final MomentItemModel item; + final void Function(Comment value)? reply; + const MomentListItemReplay({ + Key? key, + required this.index, + this.maxDisplayCount, + this.reply, + this.maxLine, + required this.item, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final listLength = item.comments?.length ?? 0; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSize.horizontalLargePadding), + child: DecoratedBox( + decoration: BoxDecoration( + color: AppColors.unactive.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: AppSize.horizontalPadding, + ), + physics: const NeverScrollableScrollPhysics(), + shrinkWrap: true, + itemCount: + listLength > 3 ? maxDisplayCount ?? listLength : listLength, + itemBuilder: (context, index) { + final comment = item.comments![index]; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + if (comment.isMe) return; + reply?.call(comment); + }, + onLongPress: () async { + if (comment.isMe) { + OkCancelResult result = await showOkCancelAlertDialog( + style: AdaptiveStyle.iOS, + context: Get.context!, + title: '系统提示', + message: '删除后无法恢复', + okLabel: '确定', + cancelLabel: '取消', + defaultType: OkCancelAlertDefaultType.cancel, + ); + if (result == OkCancelResult.ok) { + MomentController.to + .delReply(index, item.dynamicId!, comment); + } + } else { + reply?.call(comment); + } + }, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSize.verticalPadding, + ), + child: replayItem(comment), + ), + ); + }, + separatorBuilder: (context, index) => const Divider(height: 0.4), + ), + if (maxDisplayCount != null && listLength > 3) + Padding( + padding: const EdgeInsets.all( + AppSize.verticalPadding, + ), + child: GestureDetector( + onTap: () { + MomentController.to.pushToDetail(index); + }, + child: Row( + children: [ + Text( + '查看全部$listLength条回复', + style: const TextStyle( + color: AppColors.tTextColor999, + fontSize: 12, + ), + ), + const Padding( + padding: EdgeInsets.only( + top: 2.0, + ), + child: Icon( + Icons.keyboard_arrow_down, + color: AppColors.tTextColor999, + size: 16, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget replayItem(Comment comment) { + final name = comment.user?.nickname ?? ''; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // MomentAvatar(imageUrl: comment.user?.avatar), + // const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (comment.parent == null) + Text.rich(TextSpan(children: [ + TextSpan( + text: '$name:', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: AppColors.darkBlue, + ), + ), + TextSpan( + text: comment.content ?? '', + style: const TextStyle( + fontWeight: FontWeight.normal, + color: AppColors.tTextColor333, + fontSize: 13, + ), + ), + ])) + else + Text.rich( + TextSpan(children: [ + TextSpan( + text: name, + style: const TextStyle( + fontSize: 13, + ), + ), + const TextSpan( + text: ' 回复 ', + style: TextStyle( + fontWeight: FontWeight.normal, + color: AppColors.tTextColor333, + fontSize: 13, + ), + ), + TextSpan( + text: '${comment.parent?.nickname}: ', + style: const TextStyle( + fontSize: 13, + ), + ), + TextSpan( + text: comment.content ?? '', + style: const TextStyle( + fontWeight: FontWeight.normal, + color: AppColors.tTextColor333, + fontSize: 13, + ), + ), + ]), + style: const TextStyle( + fontWeight: FontWeight.bold, + // fontSize: AppSize.titleFontSize, + color: AppColors.darkBlue, + ), + ), + // Text( + // comment.content ?? '', + // maxLines: maxLine ?? 99, + // overflow: TextOverflow.ellipsis, + // ) + ], + ), + ) + ], + ); + } +} diff --git a/lib/views/moments/index/widgets/quick_reply_bar.dart b/lib/views/moments/index/widgets/quick_reply_bar.dart new file mode 100644 index 0000000..d49183e --- /dev/null +++ b/lib/views/moments/index/widgets/quick_reply_bar.dart @@ -0,0 +1,101 @@ +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/controllers/moment_controller.dart'; +import 'package:chat/models/moment/moment_model.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class QuickReplyBar extends StatefulWidget { + final bool? autofocus; + final int dynamicId; + final Comment? comment; + const QuickReplyBar({ + Key? key, + this.autofocus, + required this.dynamicId, + this.comment, + }) : super(key: key); + + static const _border = OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.all(Radius.circular(6)), + ); + + @override + State createState() => _QuickReplyBarState(); +} + +class _QuickReplyBarState extends State { + final content = ''.obs; + + @override + Widget build(BuildContext context) { + final ctrl = MomentController.to; + return ColoredBox( + color: AppColors.page, + child: SafeArea( + child: Row( + children: [ + const SizedBox(width: 16), + Expanded( + child: SizedBox( + height: 36, + child: TextField( + onChanged: (value) => content.value = value, + autofocus: widget.autofocus ?? true, + decoration: InputDecoration( + hintStyle: const TextStyle( + fontSize: 13, + ), + border: QuickReplyBar._border, + focusedBorder: QuickReplyBar._border, + disabledBorder: QuickReplyBar._border, + hintText: widget.comment?.user?.nickname.isNotEmpty ?? false + ? '回复:${widget.comment?.user?.nickname}' + : '评论', + filled: true, + fillColor: AppColors.white, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + constraints: const BoxConstraints(), + ), + ), + ), + ), + const SizedBox(width: 8), + // IconButton( + // onPressed: () { + // print('emoji picker'); + // }, + // icon: const Icon(Icons.emoji_emotions_outlined), + // color: Colors.grey, + // ), + Obx(() { + return ElevatedButton( + style: ButtonStyle( + elevation: MaterialStateProperty.all(0), + backgroundColor: MaterialStateProperty.resolveWith( + (states) { + if (states.contains(MaterialState.disabled)) { + return AppColors.tTextColor999.withAlpha(128); + } else { + return AppColors.primary; + } + }, + ), + ), + onPressed: content.isEmpty + ? null + : () => ctrl.sendReply( + widget.dynamicId, + content.value, + widget.comment, + ), + child: const Text('发送'), + ); + }), + const SizedBox(width: 16), + ], + ), + ), + ); + } +} diff --git a/lib/views/moments/publish/preview_page.dart b/lib/views/moments/publish/preview_page.dart new file mode 100644 index 0000000..c66007d --- /dev/null +++ b/lib/views/moments/publish/preview_page.dart @@ -0,0 +1,89 @@ +import 'dart:io'; + +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/controllers/publish_controller.dart'; +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:video_player/video_player.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +class PublishPreviewPage extends StatelessWidget { + const PublishPreviewPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final ctrl = PublishController.to; + return Scaffold( + appBar: AppBar( + title: Obx(() { + return Text( + '${(ctrl.publistFileIndex.value + 1)}/${ctrl.publishFileList.length}', + ); + }), + actions: [ + IconButton( + onPressed: () => ctrl.removeFileByCurrentIndex(), + icon: const Icon(Icons.delete), + ) + ], + ), + body: Obx(() { + final assetList = ctrl.publishFileList; + return PhotoViewGallery.builder( + onPageChanged: (index) => ctrl.publistFileIndex.value = index, + pageController: PageController( + initialPage: ctrl.publistFileIndex.value, + ), + itemCount: assetList.length, + builder: (context, index) { + final source = assetList[index]; + if (source.type == AssetType.image) { + return PhotoViewGalleryPageOptions( + imageProvider: AssetEntityImageProvider(source), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 2, + ); + } else if (source.type == AssetType.video) { + return PhotoViewGalleryPageOptions.customChild( + disableGestures: true, + child: FutureBuilder( + initialData: null, + future: source.originFile, + builder: (context, snapshot) { + if (snapshot.data == null) { + return const Center(child: CircularProgressIndicator()); + } + return Chewie( + controller: ChewieController( + showOptions: false, + aspectRatio: source.orientatedSize.width / + source.orientatedSize.height, + autoInitialize: true, + autoPlay: true, + videoPlayerController: VideoPlayerController.file( + snapshot.data!, + ), + ), + ); + }, + ), + ); + } else { + return PhotoViewGalleryPageOptions.customChild( + child: const Center( + child: Text( + '格式不支持', + style: TextStyle(color: AppColors.white), + ), + ), + ); + } + }, + ); + }), + ); + } +} diff --git a/lib/views/moments/publish/publish_page.dart b/lib/views/moments/publish/publish_page.dart new file mode 100644 index 0000000..3ef1553 --- /dev/null +++ b/lib/views/moments/publish/publish_page.dart @@ -0,0 +1,146 @@ +import 'package:chat/configs/app_colors.dart'; +import 'package:chat/configs/app_size.dart'; +import 'package:chat/controllers/publish_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +class MomentsPublishPage extends StatelessWidget { + const MomentsPublishPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final ctrl = Get.put(PublishController()); + return GestureDetector( + onTap: () => FocusScope.of(context).requestFocus(FocusNode()), + child: Scaffold( + appBar: AppBar( + title: const Text('发布动态'), + actions: [ + Center( + child: Padding( + padding: const EdgeInsets.only( + right: AppSize.horizontalLargePadding, + ), + child: Obx(() { + return ElevatedButton( + style: ButtonStyle( + elevation: MaterialStateProperty.all(0), + backgroundColor: MaterialStateProperty.resolveWith( + (states) { + if (states.contains(MaterialState.disabled)) { + return AppColors.tTextColor999.withAlpha(128); + } else { + return AppColors.primary; + } + }, + ), + minimumSize: MaterialStateProperty.all(Size.zero), + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric( + vertical: 6, + horizontal: 16, + ), + ), + ), + onPressed: ctrl.publishContent.isNotEmpty + ? () => ctrl.publish() + : null, + child: const Text('发布'), + ); + }), + ), + ) + ], + ), + body: CustomScrollView( + slivers: [ + const SizedBox(height: 10).sliverBox, + TextField( + decoration: const InputDecoration( + contentPadding: EdgeInsets.zero, + border: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + fillColor: AppColors.transparent, + filled: true, + hintText: '这一刻的想法...', + hintStyle: TextStyle( + fontSize: 14, + ), + ), + onChanged: (value) => ctrl.publishContent.value = value, + minLines: 7, + maxLines: 14, + ).sliverBox.contentPadding, + const SizedBox(height: 8).sliverBox, + Obx(() { + return GridView.count( + crossAxisCount: 3, + childAspectRatio: 1, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: [ + ...ctrl.publishFileList + .map((e) => + showImageWidget(ctrl.publishFileList.indexOf(e))) + .toList(), + if (ctrl.publishFileList.length < 9) pickImageWidget(), + ], + ).sliverBox.contentPadding; + }), + ], + ), + ), + ); + } + + Widget showImageWidget(int index) { + final ctrl = PublishController.to; + return GestureDetector( + onLongPress: () => ctrl.deleteImageOrVideo(index), + onTap: () => ctrl.previewFiles(index), + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image( + image: AssetEntityImageProvider( + ctrl.publishFileList[index], + isOriginal: false, + ), + fit: BoxFit.cover, + alignment: Alignment.center, + ), + ), + ); + } + + Widget pickImageWidget() { + return InkWell( + onTap: () => PublishController.to.pickImageOrVideo(), + child: Container( + decoration: BoxDecoration( + color: AppColors.unactive.withOpacity(0.2), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon( + Icons.add_rounded, + size: 40, + color: AppColors.active, + ), + ), + ); + } +} + +extension on Widget { + Widget get contentPadding { + return SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: AppSize.horizontalLargePadding, + ), + sliver: this, + ); + } +} diff --git a/lib/views/moments/publish/widgets/delete_dialog.dart b/lib/views/moments/publish/widgets/delete_dialog.dart new file mode 100644 index 0000000..b199f3c --- /dev/null +++ b/lib/views/moments/publish/widgets/delete_dialog.dart @@ -0,0 +1,20 @@ +import 'package:chat/configs/app_colors.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +class PublishDeleteDialog extends StatelessWidget { + const PublishDeleteDialog({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Dialog( + child: ListTile( + textColor: AppColors.red, + title: const Text('删除'), + onTap: () { + Get.back(result: true); + }, + ), + ); + } +} diff --git a/lib/views/moments/user/index_page.dart b/lib/views/moments/user/index_page.dart new file mode 100644 index 0000000..7dc60bb --- /dev/null +++ b/lib/views/moments/user/index_page.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class MomentsUserPage extends StatefulWidget { + const MomentsUserPage({Key? key}) : super(key: key); + + @override + State createState() => _MomentsUserPageState(); +} + +class _MomentsUserPageState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Moments'), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 6c0acb8..e054fae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -94,6 +94,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.1" + chewie: + dependency: "direct main" + description: + name: chewie + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.5" clock: dependency: transitive description: @@ -571,6 +578,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.4.1" + photo_view: + dependency: "direct main" + description: + name: photo_view + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.14.0" pinput: dependency: "direct main" description: @@ -758,6 +772,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.3.0" + tuple: + dependency: "direct main" + description: + name: tuple + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" typed_data: dependency: transitive description: @@ -821,6 +842,41 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.12" + wakelock: + dependency: transitive + description: + name: wakelock + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.2" + wakelock_macos: + dependency: transitive + description: + name: wakelock_macos + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + wakelock_platform_interface: + dependency: transitive + description: + name: wakelock_platform_interface + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.0" + wakelock_web: + dependency: transitive + description: + name: wakelock_web + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.4.0" + wakelock_windows: + dependency: transitive + description: + name: wakelock_windows + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.0" wechat_assets_picker: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 602c518..0169786 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,9 @@ dependencies: scroll_to_index: ^2.1.1 dart_date: ^1.1.1 permission_handler: ^10.2.0 + tuple: ^2.0.1 + photo_view: ^0.14.0 + chewie: ^1.3.5 dev_dependencies: flutter_test: