Add translation support to reply items (#1894)

* Add translation support to reply items

- Add translateState and showTranslate fields to ReplyControl
- Add translatedText field to ReplyInfo for translation results
- Implement TranslateReplyReq message and translateReply API method
- Add translation UI with loading state and result display in reply
  items
- Show translation button when showTranslate is true and translateState
  is 2

* refa

Signed-off-by: dom <githubaccount56556@proton.me>

---------

Co-authored-by: dom <githubaccount56556@proton.me>
This commit is contained in:
lesetong
2026-04-16 12:40:39 +08:00
committed by GitHub
parent 0b4ed25891
commit dd2492e04d
5 changed files with 389 additions and 31 deletions

View File

@@ -13,6 +13,8 @@ import 'package:PiliPlus/common/widgets/image_grid/image_grid_view.dart';
import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
import 'package:PiliPlus/grpc/bilibili/main/community/reply/v1.pb.dart'
show ReplyInfo, ReplyControl, Content, Url;
import 'package:PiliPlus/grpc/reply.dart';
import 'package:PiliPlus/http/loading_state.dart';
import 'package:PiliPlus/http/reply.dart';
import 'package:PiliPlus/http/video.dart';
import 'package:PiliPlus/models/common/badge_type.dart';
@@ -290,6 +292,7 @@ class ReplyItemGrpc extends StatelessWidget {
}
Widget _buildContent(BuildContext context, ThemeData theme) {
final replyControl = replyItem.replyControl;
final padding = EdgeInsets.only(left: replyLevel == 0 ? 6 : 45, right: 6);
return Column(
mainAxisSize: .min,
@@ -308,7 +311,7 @@ class ReplyItemGrpc extends StatelessWidget {
maxLines: replyLevel == 1 ? replyLengthLimit : null,
TextSpan(
children: [
if (replyItem.replyControl.isUpTop) ...[
if (replyControl.isUpTop) ...[
const WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: PBadge(
@@ -322,7 +325,14 @@ class ReplyItemGrpc extends StatelessWidget {
),
const TextSpan(text: ' '),
],
_buildMessage(context, theme, replyItem),
_buildMessage(
context,
theme,
replyControl.showTranslation
? replyItem.translatedContent
: replyItem.content,
replyControl,
),
],
),
),
@@ -347,7 +357,7 @@ class ReplyItemGrpc extends StatelessWidget {
],
if (replyLevel != 0) ...[
const SizedBox(height: 4),
buttonAction(context, theme, replyItem.replyControl),
buttonAction(context, theme, replyControl),
],
if (replyLevel == 1 && replyItem.count > Int64.ZERO) ...[
Padding(
@@ -359,20 +369,87 @@ class ReplyItemGrpc extends StatelessWidget {
);
}
Widget _buildTranslateBtn(
BuildContext context,
ThemeData theme,
ReplyControl replyControl,
TextStyle textStyle,
ButtonStyle buttonStyle,
) {
late bool isProcessing = false;
final color = replyControl.showTranslation
? theme.colorScheme.primary
: theme.colorScheme.outline.withValues(alpha: 0.8);
return SizedBox(
height: 32,
child: TextButton(
style: buttonStyle,
onPressed: () async {
if (replyControl.showTranslation) {
replyControl.showTranslation = false;
(context as Element).markNeedsBuild();
} else {
if (isProcessing) {
return;
}
if (replyItem.hasTranslatedContent()) {
replyControl.showTranslation = true;
(context as Element).markNeedsBuild();
return;
}
isProcessing = true;
final res = await ReplyGrpc.translateReply(
type: replyItem.type,
oid: replyItem.oid,
rpid: replyItem.id,
);
if (res case Success(:final response)) {
final item = response.translatedReply[replyItem.id];
if (item != null && item.hasTranslatedContent()) {
replyControl.showTranslation = true;
replyItem.translatedContent = item.translatedContent;
if (context.mounted) {
(context as Element).markNeedsBuild();
}
} else {
SmartDialog.showToast('翻译结果为空');
}
} else if (res case Error(:final errMsg)) {
SmartDialog.showToast('翻译失败: $errMsg');
}
isProcessing = false;
}
},
child: Row(
spacing: 3,
mainAxisSize: .min,
children: [
Icon(Icons.translate, size: 16, color: color),
Text(
replyControl.showTranslation ? '原文' : '翻译',
style: textStyle.copyWith(color: color),
),
],
),
),
);
}
Widget buttonAction(
BuildContext context,
ThemeData theme,
ReplyControl replyControl,
) {
final textStyle = TextStyle(
fontSize: theme.textTheme.labelMedium!.fontSize,
height: 1,
fontWeight: .normal,
color: theme.colorScheme.outline,
fontWeight: FontWeight.normal,
fontSize: theme.textTheme.labelMedium!.fontSize,
);
final buttonStyle = TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
const buttonStyle = ButtonStyle(
visualDensity: .compact,
tapTargetSize: .shrinkWrap,
padding: WidgetStatePropertyAll(.zero),
);
return Row(
children: [
@@ -386,20 +463,30 @@ class ReplyItemGrpc extends StatelessWidget {
onReply?.call(replyItem);
},
child: Row(
spacing: 3,
mainAxisSize: .min,
children: [
Icon(
Icons.reply,
size: 18,
color: theme.colorScheme.outline.withValues(alpha: 0.8),
),
const SizedBox(width: 3),
Text('回复', style: textStyle),
],
),
),
),
const SizedBox(width: 2),
if (replyItem.replyControl.cardLabels.isNotEmpty) ...[
if (replyControl.translationSwitch == 2) ...[
_buildTranslateBtn(
context,
theme,
replyControl,
textStyle,
buttonStyle,
),
const SizedBox(width: 2),
] else if (replyItem.replyControl.cardLabels.isNotEmpty) ...[
Text(
replyItem.replyControl.cardLabels
.map((e) => e.textContent)
@@ -536,7 +623,12 @@ class ReplyItemGrpc extends StatelessWidget {
? ''
: ' ',
),
_buildMessage(context, theme, childReply),
_buildMessage(
context,
theme,
childReply.content,
childReply.replyControl,
),
],
),
),
@@ -585,9 +677,9 @@ class ReplyItemGrpc extends StatelessWidget {
InlineSpan _buildMessage(
BuildContext context,
ThemeData theme,
ReplyInfo replyItem,
Content content,
ReplyControl replyControl,
) {
final Content content = replyItem.content;
final List<InlineSpan> spanChildren = <InlineSpan>[];
bool hasNote = false;
@@ -826,9 +918,7 @@ class ReplyItemGrpc extends StatelessWidget {
}
}
if (!hasNote &&
replyItem.replyControl.isNote &&
replyItem.replyControl.isNoteV2) {
if (!hasNote && replyControl.isNote && replyControl.isNoteV2) {
final Color color;
NoDeadlineTapGestureRecognizer? recognizer;