mod: 评论表情渲染

This commit is contained in:
guozhigq
2023-04-26 22:09:39 +08:00
parent 1d97d1848d
commit 0ec926839c
9 changed files with 503 additions and 276 deletions

View File

@ -45,4 +45,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1

View File

@ -36,6 +36,7 @@ class NetworkImgLayer extends StatelessWidget {
imageUrl: src!,
width: width ?? double.infinity,
height: height ?? double.infinity,
alignment: Alignment.center,
maxWidthDiskCache: ((cacheW ?? width!) * pr).toInt(),
// maxHeightDiskCache: (cacheH ?? height!).toInt(),
memCacheWidth: ((cacheW ?? width!) * pr).toInt(),

View File

@ -1,255 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/utils/utils.dart';
class ReplyItem extends StatelessWidget {
ReplyItem({super.key, this.replyItem, required this.isUp});
ReplyItemModel? replyItem;
bool isUp = false;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {},
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 14),
child: content(context),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.08),
)
],
),
);
}
Widget lfAvtar(context) {
return Container(
margin: const EdgeInsets.only(top: 5),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.03)),
),
child: NetworkImgLayer(
src: replyItem!.member!.avatar,
width: 34,
height: 34,
type: 'avatar',
),
);
}
Widget content(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 头像、昵称
Row(
// 两端对齐
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
GestureDetector(
// onTap: () =>
// Get.toNamed('/member/${reply.userName}', parameters: {
// 'memberAvatar': reply.avatar,
// 'heroTag': reply.userName + heroTag,
// }),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
lfAvtar(context),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
replyItem!.member!.uname!,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: Theme.of(context)
.textTheme
.titleSmall!
.copyWith(
color: isUp!
? Theme.of(context).colorScheme.primary
: null,
),
),
const SizedBox(width: 6),
Image.asset(
'assets/images/lv/lv${replyItem!.member!.level}.png',
height: 13,
),
],
),
Text(
Utils.dateFormat(replyItem!.ctime),
style: Theme.of(context).textTheme.labelSmall!.copyWith(
color: Theme.of(context).colorScheme.outline),
),
],
)
],
),
),
// SizedBox(
// width: 35,
// height: 35,
// child: IconButton(
// padding: const EdgeInsets.all(2.0),
// icon: const Icon(Icons.more_horiz_outlined, size: 18.0),
// onPressed: () {},
// ),
// )
],
),
// title
Container(
margin: const EdgeInsets.only(top: 6, left: 45, right: 8),
child: SelectionArea(
child: Text(
replyItem!.content!.message!,
style: const TextStyle(height: 1.8),
),
),
),
bottonAction(context),
// Text(replyItem!.replies!.length.toString()),
if (replyItem!.replies!.isNotEmpty)
ReplyItemRow(
replies: replyItem!.replies,
replyControl: replyItem!.replyControl,
)
],
);
}
// 感谢、回复、复制
Widget bottonAction(context) {
var color = Theme.of(context).colorScheme.outline;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
const SizedBox(width: 42),
SizedBox(
height: 35,
child: TextButton(
child: Row(
children: [
Icon(
Icons.thumb_up_alt_outlined,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
replyItem!.like.toString(),
style: TextStyle(
color: color,
fontSize:
Theme.of(context).textTheme.labelSmall!.fontSize),
),
],
),
onPressed: () {},
),
),
const SizedBox(width: 5)
],
);
}
}
class ReplyItemRow extends StatelessWidget {
ReplyItemRow({super.key, this.replies, this.replyControl});
List? replies;
var replyControl;
@override
Widget build(BuildContext context) {
bool isShow = replyControl.isShow;
int extraRow = replyControl != null && isShow ? 1 : 0;
return Container(
margin: const EdgeInsets.only(left: 45, right: 10),
padding: const EdgeInsets.only(top: 4, bottom: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
),
child: Material(
color: Theme.of(context).colorScheme.onInverseSurface.withOpacity(0.7),
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: replies!.length + extraRow,
itemBuilder: (context, index) {
if (extraRow == 1 && index == replies!.length) {
return ListTile(
onTap: () {},
dense: true,
contentPadding: const EdgeInsets.only(left: 10, right: 6),
title: Text.rich(
TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize:
Theme.of(context).textTheme.titleSmall!.fontSize,
),
children: [
if (replyControl.upReply) const TextSpan(text: 'up回复了'),
if (replyControl.isUpTop) const TextSpan(text: 'up点赞了'),
TextSpan(text: replyControl.entryText)
],
),
),
);
} else {
return ListTile(
onTap: () {},
dense: true,
contentPadding: const EdgeInsets.only(left: 10, right: 6),
title: Text.rich(
overflow: TextOverflow.ellipsis,
maxLines: 2,
TextSpan(
children: [
TextSpan(
text: replies![index].member.uname + '',
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.titleSmall!
.fontSize,
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {print('跳转至用户主页')}),
TextSpan(
text: replies![index].content.message,
style: TextStyle(
fontSize:
Theme.of(context).textTheme.titleSmall!.fontSize,
),
)
],
),
),
);
}
},
),
),
);
}
}

View File

@ -1,10 +1,11 @@
class ReplyContent {
ReplyContent({
this.message,
this.atNameToMid, // @的用户的mid
this.memebers, // 被@的用户List 如果有的话
this.emote, // 表情包 如果有的话
this.jumpUrl,
this.atNameToMid, // @的用户的mid null
this.memebers, // 被@的用户List 如果有的话 []
this.emote, // 表情包 如果有的话 null
this.jumpUrl, // {}
this.pictures, // {}
});
String? message;
@ -12,12 +13,14 @@ class ReplyContent {
List? memebers;
Map? emote;
Map? jumpUrl;
List? pictures;
ReplyContent.fromJson(Map<String, dynamic> json) {
message = json['message'];
atNameToMid = json['at_name_to_mid'];
memebers = json['memebers'];
emote = json['emote'];
jumpUrl = json['jumpUrl'];
atNameToMid = json['at_name_to_mid'] ?? {};
memebers = json['memebers'] ?? [];
emote = json['emote'] ?? {};
jumpUrl = json['jumpUrl'] ?? {};
pictures = json['pictures'] ?? [];
}
}

View File

@ -15,18 +15,22 @@ class ReplyData {
ReplyPage? page;
ReplyConfig? config;
late List? replies;
late List? topReplies;
late List<ReplyItemModel>? replies;
late List<ReplyItemModel>? topReplies;
ReplyUpper? upper;
ReplyData.fromJson(Map<String, dynamic> json) {
page = ReplyPage.fromJson(json['page']);
config = ReplyConfig.fromJson(json['config']);
replies =
json['replies'].map((item) => ReplyItemModel.fromJson(item)).toList();
replies = json['replies']
.map<ReplyItemModel>(
(item) => ReplyItemModel.fromJson(item, json['upper']['mid']))
.toList();
topReplies = json['top_replies'] != null
? json['top_replies']
.map((item) => ReplyItemModel.fromJson(item))
.map<ReplyItemModel>((item) => ReplyItemModel.fromJson(
item, json['upper']['mid'],
isTopStatus: true))
.toList()
: [];
upper = ReplyUpper.fromJson(json['upper']);

View File

@ -28,6 +28,8 @@ class ReplyItemModel {
this.upAction,
this.invisible,
this.replyControl,
this.isUp,
this.isTop,
});
int? rpid;
@ -55,8 +57,11 @@ class ReplyItemModel {
UpAction? upAction;
bool? invisible;
ReplyControl? replyControl;
bool? isUp;
bool? isTop = false;
ReplyItemModel.fromJson(Map<String, dynamic> json) {
ReplyItemModel.fromJson(Map<String, dynamic> json, upperMid,
{isTopStatus = false}) {
rpid = json['rpid'];
oid = json['oid'];
type = json['type'];
@ -78,7 +83,9 @@ class ReplyItemModel {
member = ReplyMember.fromJson(json['member']);
content = ReplyContent.fromJson(json['content']);
replies = json['replies'] != null
? json['replies'].map((item) => ReplyItemModel.fromJson(item)).toList()
? json['replies']
.map((item) => ReplyItemModel.fromJson(item, upperMid))
.toList()
: [];
assist = json['assist'];
upAction = UpAction.fromJson(json['up_action']);
@ -86,6 +93,8 @@ class ReplyItemModel {
replyControl = json['reply_control'] == null
? null
: ReplyControl.fromJson(json['reply_control']);
isUp = upperMid.toString() == json['member']['mid'];
isTop = isTopStatus;
}
}
@ -126,7 +135,7 @@ class ReplyControl {
upReply = json['up_reply'] ?? false;
isUpTop = json['is_up_top'] ?? false;
upLike = json['up_like'] ?? false;
if (json['sub_reply_entry_text'] == null) {
if (json['sub_reply_entry_text'] != null) {
final RegExp regex = RegExp(r"\d+");
final RegExpMatch match = regex.firstMatch(
json['sub_reply_entry_text'] == null

View File

@ -12,7 +12,7 @@ class ReplyUpper {
ReplyUpper.fromJson(Map<String, dynamic> json) {
mid = json['mid'];
top = json['top'] != null
? ReplyItemModel.fromJson(json['top'])
? ReplyItemModel.fromJson(json['top'], json['mid'])
: ReplyItemModel();
}
}

View File

@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/skeleton/video_card_h.dart';
import 'package:pilipala/common/widgets/http_error.dart';
import 'package:pilipala/common/widgets/reply_item.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'controller.dart';
import 'widgets/reply_item.dart';
class VideoReplyPanel extends StatefulWidget {
const VideoReplyPanel({super.key});
@ -28,8 +29,24 @@ class _VideoReplyPanelState extends State<VideoReplyPanel>
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.data['status']) {
List<dynamic> replies = snapshot.data['data'].replies;
replies.addAll(snapshot.data['data'].topReplies);
List<ReplyItemModel> replies = snapshot.data['data'].replies;
// 添加置顶回复
if (snapshot.data['data'].upper.top != null) {
bool flag = false;
for (var i = 0;
i < snapshot.data['data'].topReplies.length;
i++) {
if (snapshot.data['data'].topReplies[i].rpid ==
snapshot.data['data'].upper.top.rpid) {
flag = true;
}
}
if (!flag) {
replies.insert(0, snapshot.data['data'].upper.top);
}
}
replies.insertAll(0, snapshot.data['data'].topReplies);
// 请求成功
return SliverList(
delegate: SliverChildBuilderDelegate((context, index) {

View File

@ -0,0 +1,448 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/video/reply/item.dart';
import 'package:pilipala/utils/utils.dart';
class ReplyItem extends StatelessWidget {
ReplyItem({super.key, this.replyItem, required this.isUp});
ReplyItemModel? replyItem;
bool isUp = false;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {},
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 8, 0),
child: content(context),
),
Divider(
height: 1,
color: Theme.of(context).dividerColor.withOpacity(0.08),
)
],
),
);
}
Widget lfAvtar(context) {
return Container(
margin: const EdgeInsets.only(top: 5),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(0.03)),
),
child: NetworkImgLayer(
src: replyItem!.member!.avatar,
width: 30,
height: 30,
type: 'avatar',
),
);
}
Widget content(context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// 头像、昵称
Row(
// 两端对齐
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
GestureDetector(
// onTap: () =>
// Get.toNamed('/member/${reply.userName}', parameters: {
// 'memberAvatar': reply.avatar,
// 'heroTag': reply.userName + heroTag,
// }),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
lfAvtar(context),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
replyItem!.member!.uname!,
style: Theme.of(context)
.textTheme
.titleSmall!
.copyWith(
color: replyItem!.isUp!
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
),
),
const SizedBox(width: 6),
Image.asset(
'assets/images/lv/lv${replyItem!.member!.level}.png',
height: 13,
),
],
),
],
)
],
),
),
],
),
// title
Container(
margin: const EdgeInsets.only(top: 0, left: 45, right: 6),
child: SelectableRegion(
magnifierConfiguration: const TextMagnifierConfiguration(),
focusNode: FocusNode(),
selectionControls: MaterialTextSelectionControls(),
child: Text.rich(
style: const TextStyle(height: 1.6),
TextSpan(
children: [
buildContent(context, replyItem!.content!),
],
),
),
),
),
// 操作区域
bottonAction(context, replyItem!.replyControl),
if (replyItem!.replies!.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(top: 2, bottom: 12),
child: ReplyItemRow(
replies: replyItem!.replies,
replyControl: replyItem!.replyControl,
),
),
],
],
);
}
// 感谢、回复、复制
Widget bottonAction(context, replyControl) {
var color = Theme.of(context).colorScheme.outline;
return Row(
children: [
const SizedBox(width: 48),
Text(
Utils.dateFormat(replyItem!.ctime),
style: Theme.of(context)
.textTheme
.labelMedium!
.copyWith(color: Theme.of(context).colorScheme.outline),
),
if (replyItem!.isTop!) ...[
Text(
' • 置顶',
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.primary,
),
),
],
if (replyControl!.isUpTop!) ...[
Text(
' • 超赞',
style: TextStyle(
fontSize: Theme.of(context).textTheme.labelMedium!.fontSize,
color: Theme.of(context).colorScheme.primary,
),
),
// const SizedBox(width: 4),
],
const Spacer(),
SizedBox(
height: 35,
child: TextButton(
child: Row(
children: [
Icon(
Icons.thumb_up_alt_outlined,
size: 16,
color: color,
),
const SizedBox(width: 4),
Text(
replyItem!.like.toString(),
style: TextStyle(
color: color,
fontSize:
Theme.of(context).textTheme.labelSmall!.fontSize),
),
],
),
onPressed: () {},
),
),
const SizedBox(width: 5)
],
);
}
}
// ignore: must_be_immutable
class ReplyItemRow extends StatelessWidget {
ReplyItemRow({
super.key,
this.replies,
this.replyControl,
});
List? replies;
ReplyControl? replyControl;
@override
Widget build(BuildContext context) {
bool isShow = replyControl!.isShow!;
int extraRow = replyControl != null && isShow ? 1 : 0;
return Container(
margin: const EdgeInsets.only(left: 42, right: 4, top: 0),
child: Material(
color: Theme.of(context).colorScheme.onInverseSurface.withOpacity(0.7),
borderRadius: BorderRadius.circular(6),
clipBehavior: Clip.hardEdge,
animationDuration: Duration.zero,
child: ListView.builder(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: replies!.length + extraRow,
itemBuilder: (context, index) {
if (extraRow == 1 && index == replies!.length) {
// 有楼中楼回复,在最后显示
return InkWell(
onTap: () {},
child: Padding(
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
child: Text.rich(
TextSpan(
style: TextStyle(
fontSize:
Theme.of(context).textTheme.labelMedium!.fontSize,
),
children: [
if (replyControl!.upReply!)
const TextSpan(text: 'up主等人 '),
TextSpan(
text: replyControl!.entryText!,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
),
)
],
),
),
),
);
} else {
return InkWell(
onTap: () {},
child: Padding(
padding: EdgeInsets.fromLTRB(8, index == 0 ? 8 : 4, 8, 4),
child: Text.rich(
overflow: TextOverflow.ellipsis,
maxLines: 2,
TextSpan(
children: [
if (replies![index].isUp)
TextSpan(
text: 'UP • ',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: Theme.of(context)
.textTheme
.labelMedium!
.fontSize,
color: Theme.of(context).colorScheme.primary,
),
),
TextSpan(
text: replies![index].member.uname + ' ',
style: TextStyle(
fontSize: Theme.of(context)
.textTheme
.titleSmall!
.fontSize,
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('跳转至用户主页'),
},
),
buildContent(context, replies![index].content),
],
),
),
));
}
},
),
),
);
}
}
InlineSpan buildContent(BuildContext context, content) {
if (content.emote.isEmpty &&
content.atNameToMid.isEmpty &&
content.jumpUrl.isEmpty &&
content.pictures.isEmpty) {
return TextSpan(text: content.message);
}
List<InlineSpan> spanChilds = [];
// if (content.atNameToMid.isNotEmpty) {
// print(content.message);
// content.atNameToMid.forEach((key, value) {
// key = '@' + key;
// int lastIndex = content.message.indexOf(key);
// int endIndex = (lastIndex + key.length).toInt();
// if (lastIndex >= 0) {
// spanChilds.add(TextSpan(
// text: '@' + key,
// style: TextStyle(color: Theme.of(context).colorScheme.primary)));
// content.message = content.message.replaceRange(lastIndex, endIndex, '');
// spanChilds.add(TextSpan(text: content.message));
// }
// spanChilds.add(TextSpan(text: content.message.substring(lastIndex)));
// });
// // return TextSpan(children: spanChilds);
// }
// if (content.emote.isNotEmpty) {
// content.emote.forEach((key, value) {
// int lastIndex = content.message.indexOf(key);
// int endIndex = content.message.indexOf(key) + key.length;
// if (lastIndex >= 0) {
// content.message = content.message.replaceRange(lastIndex, endIndex, '');
// spanChilds.add(TextSpan(text: content.message.substring(0, lastIndex)));
// }
// spanChilds.add(WidgetSpan(
// child: NetworkImgLayer(
// src: value["url"],
// width: 20,
// height: 20,
// )));
// });
// // return TextSpan(children: spanChilds);
// }
// if (content.pictures.isNotEmpty) {
// spanChilds.add(TextSpan(text: content.message));
// spanChilds.add(const WidgetSpan(
// child: SizedBox(
// height: 4,
// )));
// for (var i = 0; i < content.pictures.length; i++) {
// spanChilds.add(
// WidgetSpan(
// child: SizedBox(
// height: 180,
// child: NetworkImgLayer(
// src: content.pictures[i]['img_src'],
// width: 200,
// height: 200 *
// content.pictures[i]['img_height'] /
// content.pictures[i]['img_width'],
// ),
// ),
// ),
// );
// }
// return TextSpan(children: spanChilds);
// }
content.message.splitMapJoin(
RegExp(r"\[.*?\]"),
onMatch: (Match match) {
String matchStr = match[0]!;
if (content.emote.isNotEmpty) {
if (content.emote.keys.contains(matchStr)) {
spanChilds.add(
WidgetSpan(
child: NetworkImgLayer(
src: content.emote[matchStr]['url'],
width: 20,
height: 20,
),
),
);
} else {
spanChilds.add(TextSpan(text: matchStr));
return matchStr;
}
}
return matchStr;
},
onNonMatch: (String str) {
try {
if (content.atNameToMid.isNotEmpty) {
return str.splitMapJoin(
RegExp(r"@.*:"),
onMatch: (Match match) {
if (match[0] != null) {
content.atNameToMid.forEach((key, value) {
spanChilds.add(
TextSpan(
text: '@$key ',
style: TextStyle(
fontSize:
Theme.of(context).textTheme.titleSmall!.fontSize,
color: Theme.of(context).colorScheme.primary,
),
recognizer: TapGestureRecognizer()
..onTap = () => {
print('跳转至用户主页'),
},
),
);
});
}
return match[0]!;
},
onNonMatch: (String str) {
spanChilds.add(TextSpan(text: str));
return str;
},
);
} else {
spanChilds.add(TextSpan(text: str));
return str;
}
} catch (e) {
spanChilds.add(TextSpan(text: str));
return str;
}
},
);
if (content.pictures.isNotEmpty) {
spanChilds.add(const WidgetSpan(
child: SizedBox(
height: 4,
)));
for (var i = 0; i < content.pictures.length; i++) {
spanChilds.add(
WidgetSpan(
child: SizedBox(
height: 180,
child: NetworkImgLayer(
src: content.pictures[i]['img_src'],
width: 200,
height: 200 *
content.pictures[i]['img_height'] /
content.pictures[i]['img_width'],
),
),
),
);
}
}
return TextSpan(children: spanChilds);
}