01c36eb32e
- 删除 mock_api_client、mock_calendar_service、mock_history_service - 新增 fixed_length_code_input、link_button、message_composer 共享组件 - 优化登录/注册/密码重置页面使用新组件 - 简化 injection.dart 移除 mock 分支 - 更新 env.dart 配置(BACKEND_URL 替换 API_URL) - 后端 agentscope 工具和测试更新 - 重构 AGENTS.md 文档结构 - 新增 deploy/ 目录和 protocol 文档
249 lines
7.7 KiB
Dart
249 lines
7.7 KiB
Dart
import 'package:flutter/gestures.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons/lucide_icons.dart';
|
|
|
|
import '../../core/theme/design_tokens.dart';
|
|
|
|
enum MessageComposerMode { text, holdToSpeak }
|
|
|
|
enum MessageComposerProcess { idle, recording, transcribing }
|
|
|
|
const messageComposerContainerKey = ValueKey('message_composer_container');
|
|
const messageComposerPlusButtonKey = ValueKey('message_composer_plus_button');
|
|
const messageComposerRightButtonKey = ValueKey('message_composer_right_button');
|
|
const messageComposerHoldAreaKey = ValueKey('message_composer_hold_area');
|
|
const messageComposerRecordingHintKey = ValueKey(
|
|
'message_composer_recording_hint',
|
|
);
|
|
const _holdActivateDurationMs = 120;
|
|
|
|
class MessageComposer extends StatelessWidget {
|
|
const MessageComposer({
|
|
super.key,
|
|
required this.mode,
|
|
required this.process,
|
|
required this.hasMessage,
|
|
required this.isWaitingAgent,
|
|
required this.iconSize,
|
|
required this.composerMinHeight,
|
|
required this.onTapPlus,
|
|
required this.onTapRightAction,
|
|
required this.onHoldToSpeakStart,
|
|
required this.onHoldToSpeakEnd,
|
|
required this.onHoldToSpeakMoveUpdate,
|
|
required this.onHoldToSpeakCancel,
|
|
required this.textInputChild,
|
|
required this.recordingAnimation,
|
|
this.holdToSpeakText = '按住说话',
|
|
this.recordingText = '松手发送',
|
|
this.transcribingText = '语音识别中...',
|
|
this.recordingHintText = '松开发送,上滑取消',
|
|
this.showRecordingInlineFeedback = true,
|
|
});
|
|
|
|
final MessageComposerMode mode;
|
|
final MessageComposerProcess process;
|
|
final bool hasMessage;
|
|
final bool isWaitingAgent;
|
|
final double iconSize;
|
|
final double composerMinHeight;
|
|
final VoidCallback onTapPlus;
|
|
final VoidCallback onTapRightAction;
|
|
final VoidCallback onHoldToSpeakStart;
|
|
final VoidCallback onHoldToSpeakEnd;
|
|
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
|
final VoidCallback onHoldToSpeakCancel;
|
|
final Widget textInputChild;
|
|
final Widget recordingAnimation;
|
|
final String holdToSpeakText;
|
|
final String recordingText;
|
|
final String transcribingText;
|
|
final String recordingHintText;
|
|
final bool showRecordingInlineFeedback;
|
|
|
|
bool get _isHoldMode => mode == MessageComposerMode.holdToSpeak;
|
|
|
|
bool get _isRecording => process == MessageComposerProcess.recording;
|
|
|
|
bool get _isTranscribing => process == MessageComposerProcess.transcribing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
key: messageComposerContainerKey,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.md,
|
|
vertical: AppSpacing.xs,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.white,
|
|
borderRadius: BorderRadius.circular(AppRadius.full),
|
|
border: Border.all(color: AppColors.slate200),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: AppColors.slate200,
|
|
blurRadius: AppRadius.lg,
|
|
offset: Offset(AppSpacing.none, AppSpacing.xs),
|
|
),
|
|
BoxShadow(
|
|
color: AppColors.white,
|
|
blurRadius: AppRadius.md,
|
|
offset: Offset(AppSpacing.none, -AppSpacing.xs),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IgnorePointer(
|
|
ignoring: _isRecording && _isHoldMode,
|
|
child: Opacity(
|
|
opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1,
|
|
child: IconButton(
|
|
key: messageComposerPlusButtonKey,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onTapPlus,
|
|
icon: Icon(
|
|
LucideIcons.plus,
|
|
size: iconSize,
|
|
color: AppColors.slate500,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
Expanded(child: _buildCenterArea()),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
IconButton(
|
|
key: messageComposerRightButtonKey,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onTapRightAction,
|
|
icon: _isTranscribing
|
|
? const SizedBox(
|
|
width: AppSpacing.lg,
|
|
height: AppSpacing.lg,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: AppSpacing.xs / 2,
|
|
color: AppColors.blue600,
|
|
),
|
|
)
|
|
: Icon(
|
|
_resolveRightIcon(),
|
|
size: iconSize,
|
|
color: _resolveRightIconColor(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCenterArea() {
|
|
return SizedBox(
|
|
height: composerMinHeight,
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 180),
|
|
switchInCurve: Curves.easeOut,
|
|
switchOutCurve: Curves.easeOut,
|
|
child: _isHoldMode
|
|
? _buildHoldToSpeakArea(key: const ValueKey('hold_mode'))
|
|
: _buildTextInputArea(key: const ValueKey('text_mode')),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTextInputArea({required Key key}) {
|
|
return SizedBox(key: key, height: composerMinHeight, child: textInputChild);
|
|
}
|
|
|
|
Widget _buildHoldToSpeakArea({required Key key}) {
|
|
return RawGestureDetector(
|
|
key: messageComposerHoldAreaKey,
|
|
behavior: HitTestBehavior.opaque,
|
|
gestures: {
|
|
LongPressGestureRecognizer:
|
|
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
|
() => LongPressGestureRecognizer(
|
|
duration: const Duration(milliseconds: _holdActivateDurationMs),
|
|
),
|
|
(instance) {
|
|
instance.onLongPressStart = (_) => onHoldToSpeakStart();
|
|
instance.onLongPressEnd = (_) => onHoldToSpeakEnd();
|
|
instance.onLongPressMoveUpdate = onHoldToSpeakMoveUpdate;
|
|
instance.onLongPressCancel = onHoldToSpeakCancel;
|
|
},
|
|
),
|
|
},
|
|
child: Container(
|
|
key: key,
|
|
width: double.infinity,
|
|
height: composerMinHeight,
|
|
alignment: Alignment.center,
|
|
child: _buildHoldToSpeakContent(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHoldToSpeakContent() {
|
|
if (_isRecording) {
|
|
if (!showRecordingInlineFeedback) {
|
|
return Align(
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
recordingText,
|
|
style: const TextStyle(color: AppColors.slate700),
|
|
),
|
|
);
|
|
}
|
|
return Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
recordingAnimation,
|
|
const SizedBox(height: AppSpacing.xs),
|
|
Text(
|
|
recordingHintText,
|
|
key: messageComposerRecordingHintKey,
|
|
style: const TextStyle(color: AppColors.slate500),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (_isTranscribing) {
|
|
return Align(
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
transcribingText,
|
|
style: const TextStyle(color: AppColors.slate500),
|
|
),
|
|
);
|
|
}
|
|
|
|
return Align(
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
holdToSpeakText,
|
|
style: const TextStyle(color: AppColors.slate500),
|
|
),
|
|
);
|
|
}
|
|
|
|
IconData _resolveRightIcon() {
|
|
if (isWaitingAgent) {
|
|
return LucideIcons.square;
|
|
}
|
|
if (hasMessage) {
|
|
return LucideIcons.send;
|
|
}
|
|
return _isHoldMode ? LucideIcons.keyboard : LucideIcons.mic;
|
|
}
|
|
|
|
Color _resolveRightIconColor() {
|
|
if (isWaitingAgent || hasMessage) {
|
|
return AppColors.blue600;
|
|
}
|
|
return AppColors.slate500;
|
|
}
|
|
}
|