Skip to content

Commit 7ac655c

Browse files
committed
feat: AI对话页面新增消息长按菜单功能
- 新增 MessagePopoverMenu 组件,实现微信风格的长按弹出菜单 - 文字消息支持复制和删除操作 - 记账成功卡片支持删除操作 - 菜单自动跟随路由状态,手势返回时自动关闭 - 优化菜单位置计算,避免遮挡顶部返回按钮 - 新增国际化文本:复制、已复制、删除消息确认等
1 parent a97cd20 commit 7ac655c

File tree

8 files changed

+493
-52
lines changed

8 files changed

+493
-52
lines changed

lib/l10n/app_en.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1702,6 +1702,10 @@
17021702
"aiChatInputHint": "e.g.: Bought a coffee for $35",
17031703
"aiChatThinking": "Thinking...",
17041704
"aiChatHistoryCleared": "Conversation history cleared",
1705+
"aiChatCopy": "Copy",
1706+
"aiChatCopied": "Copied to clipboard",
1707+
"aiChatDeleteMessageConfirm": "Are you sure you want to delete this message?",
1708+
"aiChatMessageDeleted": "Message deleted",
17051709
"aiChatUndone": "Undone",
17061710
"aiChatUndoFailed": "Undo failed",
17071711
"aiChatTransactionNotFound": "Transaction not found",

lib/l10n/app_localizations.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7656,6 +7656,30 @@ abstract class AppLocalizations {
76567656
/// **'Conversation history cleared'**
76577657
String get aiChatHistoryCleared;
76587658

7659+
/// No description provided for @aiChatCopy.
7660+
///
7661+
/// In en, this message translates to:
7662+
/// **'Copy'**
7663+
String get aiChatCopy;
7664+
7665+
/// No description provided for @aiChatCopied.
7666+
///
7667+
/// In en, this message translates to:
7668+
/// **'Copied to clipboard'**
7669+
String get aiChatCopied;
7670+
7671+
/// No description provided for @aiChatDeleteMessageConfirm.
7672+
///
7673+
/// In en, this message translates to:
7674+
/// **'Are you sure you want to delete this message?'**
7675+
String get aiChatDeleteMessageConfirm;
7676+
7677+
/// No description provided for @aiChatMessageDeleted.
7678+
///
7679+
/// In en, this message translates to:
7680+
/// **'Message deleted'**
7681+
String get aiChatMessageDeleted;
7682+
76597683
/// No description provided for @aiChatUndone.
76607684
///
76617685
/// In en, this message translates to:

lib/l10n/app_localizations_en.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4004,6 +4004,18 @@ class AppLocalizationsEn extends AppLocalizations {
40044004
@override
40054005
String get aiChatHistoryCleared => 'Conversation history cleared';
40064006

4007+
@override
4008+
String get aiChatCopy => 'Copy';
4009+
4010+
@override
4011+
String get aiChatCopied => 'Copied to clipboard';
4012+
4013+
@override
4014+
String get aiChatDeleteMessageConfirm => 'Are you sure you want to delete this message?';
4015+
4016+
@override
4017+
String get aiChatMessageDeleted => 'Message deleted';
4018+
40074019
@override
40084020
String get aiChatUndone => 'Undone';
40094021

lib/l10n/app_localizations_zh.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4004,6 +4004,18 @@ class AppLocalizationsZh extends AppLocalizations {
40044004
@override
40054005
String get aiChatHistoryCleared => '对话历史已清空';
40064006

4007+
@override
4008+
String get aiChatCopy => '复制';
4009+
4010+
@override
4011+
String get aiChatCopied => '已复制到剪贴板';
4012+
4013+
@override
4014+
String get aiChatDeleteMessageConfirm => '确定要删除这条消息吗?';
4015+
4016+
@override
4017+
String get aiChatMessageDeleted => '消息已删除';
4018+
40074019
@override
40084020
String get aiChatUndone => '已撤销';
40094021

lib/l10n/app_zh.arb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1641,6 +1641,10 @@
16411641
"aiChatInputHint": "例如: 买了杯咖啡35块",
16421642
"aiChatThinking": "思考中...",
16431643
"aiChatHistoryCleared": "对话历史已清空",
1644+
"aiChatCopy": "复制",
1645+
"aiChatCopied": "已复制到剪贴板",
1646+
"aiChatDeleteMessageConfirm": "确定要删除这条消息吗?",
1647+
"aiChatMessageDeleted": "消息已删除",
16441648
"aiChatUndone": "已撤销",
16451649
"aiChatUndoFailed": "撤销失败",
16461650
"aiChatTransactionNotFound": "交易记录不存在",

lib/pages/ai/ai_chat_page.dart

Lines changed: 141 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'dart:convert';
22
import 'dart:io';
33

44
import 'package:flutter/material.dart';
5+
import 'package:flutter/services.dart';
56
import 'package:flutter_riverpod/flutter_riverpod.dart';
67
import 'package:drift/drift.dart' hide Column;
78

@@ -352,19 +353,25 @@ class _AIChatPageState extends ConsumerState<AIChatPage>
352353
final isUndone = metadata['isUndone'] == true;
353354
final billInfo = BillInfo.fromJson(metadata['billInfo'] ?? metadata);
354355

355-
return BillCardWidget(
356-
billInfo: billInfo,
357-
transactionId: message.transactionId,
358-
isUndone: isUndone,
359-
onUndo: message.transactionId != null && !isUndone
360-
? () => _handleUndo(message.id, message.transactionId!)
361-
: null,
362-
onEdit: message.transactionId != null && !isUndone
363-
? () => _handleEdit(message.transactionId!)
364-
: null,
365-
onChangeLedger: message.transactionId != null && !isUndone
366-
? () => _handleChangeLedger(message.id, message.transactionId!)
367-
: null,
356+
return GestureDetector(
357+
onLongPressStart: (details) => _showBillCardMenu(
358+
details.globalPosition,
359+
message,
360+
),
361+
child: BillCardWidget(
362+
billInfo: billInfo,
363+
transactionId: message.transactionId,
364+
isUndone: isUndone,
365+
onUndo: message.transactionId != null && !isUndone
366+
? () => _handleUndo(message.id, message.transactionId!)
367+
: null,
368+
onEdit: message.transactionId != null && !isUndone
369+
? () => _handleEdit(message.transactionId!)
370+
: null,
371+
onChangeLedger: message.transactionId != null && !isUndone
372+
? () => _handleChangeLedger(message.id, message.transactionId!)
373+
: null,
374+
),
368375
);
369376
}
370377

@@ -383,49 +390,56 @@ class _AIChatPageState extends ConsumerState<AIChatPage>
383390
],
384391
// 消息气泡
385392
Flexible(
386-
child: Container(
387-
margin: EdgeInsets.only(
388-
left: isUser ? 60.0.scaled(context, ref) : 0,
389-
right: isUser ? 0 : 60.0.scaled(context, ref),
390-
),
391-
padding: EdgeInsets.symmetric(
392-
horizontal: 12.0.scaled(context, ref),
393-
vertical: 10.0.scaled(context, ref),
393+
child: GestureDetector(
394+
onLongPressStart: (details) => _showTextMessageMenu(
395+
details.globalPosition,
396+
message,
397+
isUser,
394398
),
395-
decoration: BoxDecoration(
396-
color: isUser
397-
? ref.watch(primaryColorProvider).withOpacity(0.1)
398-
: BeeTokens.surface(context),
399-
borderRadius: BorderRadius.circular(12.0.scaled(context, ref)),
400-
border: Border.all(
399+
child: Container(
400+
margin: EdgeInsets.only(
401+
left: isUser ? 60.0.scaled(context, ref) : 0,
402+
right: isUser ? 0 : 60.0.scaled(context, ref),
403+
),
404+
padding: EdgeInsets.symmetric(
405+
horizontal: 12.0.scaled(context, ref),
406+
vertical: 10.0.scaled(context, ref),
407+
),
408+
decoration: BoxDecoration(
401409
color: isUser
402-
? ref.watch(primaryColorProvider).withOpacity(0.3)
403-
: BeeTokens.border(context),
410+
? ref.watch(primaryColorProvider).withOpacity(0.1)
411+
: BeeTokens.surface(context),
412+
borderRadius: BorderRadius.circular(12.0.scaled(context, ref)),
413+
border: Border.all(
414+
color: isUser
415+
? ref.watch(primaryColorProvider).withOpacity(0.3)
416+
: BeeTokens.border(context),
417+
),
404418
),
405-
),
406-
child: TypewriterText(
407-
text: message.content,
408-
animate: shouldAnimate, // 只对标记的消息启用动画
409-
onTextChange: shouldAnimate
410-
? () {
411-
// 每次文本更新时滚动到底部
412-
_scrollToBottomSmooth();
413-
}
414-
: null,
415-
onComplete: shouldAnimate
416-
? () {
417-
// 动画完成后清除标记
418-
if (mounted) {
419-
setState(() {
420-
_animatingMessageId = null;
421-
});
419+
child: TypewriterText(
420+
text: message.content,
421+
animate: shouldAnimate, // 只对标记的消息启用动画
422+
onTextChange: shouldAnimate
423+
? () {
424+
// 每次文本更新时滚动到底部
425+
_scrollToBottomSmooth();
422426
}
423-
}
424-
: null,
425-
style: TextStyle(
426-
color: BeeTokens.textPrimary(context),
427-
fontSize: 14.0.scaled(context, ref),
428-
height: 1.5,
427+
: null,
428+
onComplete: shouldAnimate
429+
? () {
430+
// 动画完成后清除标记
431+
if (mounted) {
432+
setState(() {
433+
_animatingMessageId = null;
434+
});
435+
}
436+
}
437+
: null,
438+
style: TextStyle(
439+
color: BeeTokens.textPrimary(context),
440+
fontSize: 14.0.scaled(context, ref),
441+
height: 1.5,
442+
),
429443
),
430444
),
431445
),
@@ -1002,6 +1016,81 @@ class _AIChatPageState extends ConsumerState<AIChatPage>
10021016
}
10031017
}
10041018

1019+
/// 显示文字消息的长按菜单
1020+
void _showTextMessageMenu(Offset position, Message message, bool isUser) {
1021+
final l10n = AppLocalizations.of(context);
1022+
final primaryColor = ref.read(primaryColorProvider);
1023+
1024+
MessagePopoverMenu.show(
1025+
context: context,
1026+
globalPosition: position,
1027+
primaryColor: primaryColor,
1028+
items: [
1029+
PopoverMenuItem(
1030+
icon: Icons.copy,
1031+
label: l10n.aiChatCopy,
1032+
onTap: () {
1033+
Clipboard.setData(ClipboardData(text: message.content));
1034+
showToast(context, l10n.aiChatCopied);
1035+
},
1036+
),
1037+
PopoverMenuItem(
1038+
icon: Icons.delete_outline,
1039+
label: l10n.commonDelete,
1040+
color: Colors.red,
1041+
onTap: () => _deleteMessage(message),
1042+
),
1043+
],
1044+
);
1045+
}
1046+
1047+
/// 显示记账卡片的长按菜单
1048+
void _showBillCardMenu(Offset position, Message message) {
1049+
final l10n = AppLocalizations.of(context);
1050+
final primaryColor = ref.read(primaryColorProvider);
1051+
1052+
MessagePopoverMenu.show(
1053+
context: context,
1054+
globalPosition: position,
1055+
primaryColor: primaryColor,
1056+
items: [
1057+
PopoverMenuItem(
1058+
icon: Icons.delete_outline,
1059+
label: l10n.commonDelete,
1060+
color: Colors.red,
1061+
onTap: () => _deleteMessage(message),
1062+
),
1063+
],
1064+
);
1065+
}
1066+
1067+
/// 删除单条消息
1068+
Future<void> _deleteMessage(Message message) async {
1069+
final l10n = AppLocalizations.of(context);
1070+
1071+
// 确认删除
1072+
final confirmed = await AppDialog.confirm<bool>(
1073+
context,
1074+
title: l10n.commonDelete,
1075+
message: l10n.aiChatDeleteMessageConfirm,
1076+
);
1077+
1078+
if (confirmed != true) return;
1079+
1080+
try {
1081+
final repo = ref.read(repositoryProvider);
1082+
await repo.deleteMessage(message.id);
1083+
1084+
if (mounted) {
1085+
showToast(context, l10n.aiChatMessageDeleted);
1086+
}
1087+
} catch (e) {
1088+
if (mounted) {
1089+
showToast(context, l10n.commonFailed);
1090+
}
1091+
}
1092+
}
1093+
10051094
@override
10061095
void dispose() {
10071096
WidgetsBinding.instance.removeObserver(this);

0 commit comments

Comments
 (0)