refactor: 移除前端 Mock API,新增共享组件,优化认证流程
- 删除 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 文档
This commit is contained in:
@@ -15,6 +15,7 @@ import '../../../chat/data/tools/route_navigation_tool.dart';
|
||||
import '../../../messages/data/inbox_api.dart';
|
||||
import '../../data/voice_recorder.dart';
|
||||
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
|
||||
import '../../../../shared/widgets/message_composer.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import 'home_sheet.dart';
|
||||
@@ -28,19 +29,15 @@ const _iconSize = 24.0;
|
||||
const _messagePaddingH = 13.0;
|
||||
const _messagePaddingV = 9.0;
|
||||
const _cornerRadius = 12.0;
|
||||
const _inputMinHeight = 48.0;
|
||||
const _inputRadius = 24.0;
|
||||
const _inputMinHeight = AppSpacing.xxl + AppSpacing.lg;
|
||||
const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl);
|
||||
const _scrollDurationMs = 300;
|
||||
const _rippleDurationMs = 1200;
|
||||
const _recordingDotSize = 10.0;
|
||||
const _transcribingSpinnerSize = 18.0;
|
||||
const _transcribingStrokeWidth = 2.0;
|
||||
const _attachmentPreviewSize = 88.0;
|
||||
const _attachmentPreviewRadius = 10.0;
|
||||
const _attachmentPreviewGap = 8.0;
|
||||
const _inputActionButtonKey = ValueKey('home_input_action_button');
|
||||
const _inputActionIconKey = ValueKey('home_input_action_icon');
|
||||
const _holdToSpeakKey = ValueKey('home_hold_to_speak_button');
|
||||
|
||||
/// 颜色常量
|
||||
const _chatBgColor = AppColors.slate50;
|
||||
@@ -79,6 +76,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
bool _isRecording = false;
|
||||
bool _isHoldToSpeakMode = false;
|
||||
bool _isTranscribing = false;
|
||||
bool _isCancelGestureActive = false;
|
||||
int _unreadCount = 0;
|
||||
final List<XFile> _selectedImages = [];
|
||||
|
||||
@@ -158,12 +156,17 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
return Scaffold(
|
||||
backgroundColor: _chatBgColor,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
child: Stack(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(child: _buildChatArea(context, state)),
|
||||
_buildImagePreview(),
|
||||
_buildInputContainer(context, state),
|
||||
Column(
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(child: _buildChatArea(context, state)),
|
||||
_buildImagePreview(),
|
||||
_buildInputContainer(context, state),
|
||||
],
|
||||
),
|
||||
if (_isRecording) _buildRecordingGestureOverlay(),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -712,191 +715,147 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
Widget _buildInputContainer(BuildContext context, ChatState state) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(_inputPadding),
|
||||
color: _chatBgColor,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: _isRecording
|
||||
? _stopRecording
|
||||
: () => _showBottomSheet(context),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Icon(
|
||||
_isRecording ? LucideIcons.square : LucideIcons.plus,
|
||||
size: 20,
|
||||
color: _isRecording ? AppColors.red600 : AppColors.slate500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _isHoldToSpeakMode
|
||||
? _buildHoldToSpeakButton()
|
||||
: _buildNormalInputField(state),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildRightActionButton(state),
|
||||
],
|
||||
),
|
||||
if (_isHoldToSpeakMode) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildHoldToSpeakHint(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHoldToSpeakButton() {
|
||||
return GestureDetector(
|
||||
key: _holdToSpeakKey,
|
||||
onLongPressStart: (_) => _onHoldToSpeakStart(),
|
||||
onLongPressEnd: (_) => _onHoldToSpeakEnd(),
|
||||
onLongPressMoveUpdate: (details) => _onHoldToSpeakMoveUpdate(details),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: _inputMinHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(_inputRadius),
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'按住说话',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNormalInputField(ChatState state) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: _inputMinHeight),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(_inputRadius),
|
||||
border: Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: _isRecording
|
||||
? _buildListeningIndicator()
|
||||
: _isTranscribing
|
||||
? _buildTranscribingIndicator()
|
||||
: TextField(
|
||||
controller: _messageController,
|
||||
minLines: 1,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRightActionButton(ChatState state) {
|
||||
final isWaitingAgent =
|
||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||
return GestureDetector(
|
||||
key: _inputActionButtonKey,
|
||||
onTap: _isTranscribing
|
||||
? null
|
||||
: isWaitingAgent
|
||||
? () => _onStopGenerating(context)
|
||||
: _hasMessage
|
||||
? () => _sendMessage(context)
|
||||
: _toggleHoldToSpeakMode,
|
||||
child: _isTranscribing
|
||||
? const SizedBox(
|
||||
width: _transcribingSpinnerSize,
|
||||
height: _transcribingSpinnerSize,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: _transcribingStrokeWidth,
|
||||
color: AppColors.blue600,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
key: _inputActionIconKey,
|
||||
isWaitingAgent
|
||||
? LucideIcons.square
|
||||
: _hasMessage
|
||||
? LucideIcons.send
|
||||
: _isHoldToSpeakMode
|
||||
? LucideIcons.keyboard
|
||||
: LucideIcons.activity,
|
||||
size: _iconSize,
|
||||
color: isWaitingAgent || _hasMessage
|
||||
? AppColors.blue600
|
||||
: AppColors.slate500,
|
||||
),
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
color: _chatBgColor,
|
||||
child: MessageComposer(
|
||||
mode: _isHoldToSpeakMode
|
||||
? MessageComposerMode.holdToSpeak
|
||||
: MessageComposerMode.text,
|
||||
process: _composerProcess,
|
||||
hasMessage: _hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
iconSize: _iconSize,
|
||||
composerMinHeight: _inputMinHeight,
|
||||
onTapPlus: _isRecording
|
||||
? () => _stopRecording(autoSendAfterTranscribe: false)
|
||||
: () => _showBottomSheet(context),
|
||||
onTapRightAction: () => _onRightActionTap(context, state),
|
||||
onHoldToSpeakStart: _onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: _onHoldToSpeakEnd,
|
||||
onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate,
|
||||
onHoldToSpeakCancel: _onHoldToSpeakCancel,
|
||||
textInputChild: _buildTextInputContent(context),
|
||||
recordingAnimation: const SizedBox.shrink(),
|
||||
recordingText: _isCancelGestureActive ? '松手取消' : '松手发送',
|
||||
recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消',
|
||||
showRecordingInlineFeedback: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHoldToSpeakHint() {
|
||||
return Column(
|
||||
children: [
|
||||
if (_isRecording) ...[
|
||||
_buildRecordingAnimation(),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'松开发送,上滑取消',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.slate500),
|
||||
MessageComposerProcess get _composerProcess {
|
||||
if (_isRecording) {
|
||||
return MessageComposerProcess.recording;
|
||||
}
|
||||
if (_isTranscribing) {
|
||||
return MessageComposerProcess.transcribing;
|
||||
}
|
||||
return MessageComposerProcess.idle;
|
||||
}
|
||||
|
||||
Widget _buildTextInputContent(BuildContext context) {
|
||||
if (_isTranscribing) {
|
||||
return _buildTranscribingIndicator();
|
||||
}
|
||||
return SizedBox.expand(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextField(
|
||||
controller: _messageController,
|
||||
minLines: 1,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: AppSpacing.lg,
|
||||
height: 1,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
],
|
||||
],
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
hintStyle: TextStyle(
|
||||
fontSize: AppSpacing.lg,
|
||||
height: 1,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordingAnimation() {
|
||||
return _buildListeningIndicator();
|
||||
void _onRightActionTap(BuildContext context, ChatState state) {
|
||||
if (_isTranscribing || _isRecording) {
|
||||
return;
|
||||
}
|
||||
final isWaitingAgent =
|
||||
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
|
||||
if (isWaitingAgent) {
|
||||
_onStopGenerating();
|
||||
return;
|
||||
}
|
||||
if (_hasMessage) {
|
||||
_sendMessage(context);
|
||||
return;
|
||||
}
|
||||
_toggleHoldToSpeakMode();
|
||||
}
|
||||
|
||||
void _toggleHoldToSpeakMode() {
|
||||
if (_isRecording || _isTranscribing) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isHoldToSpeakMode = !_isHoldToSpeakMode;
|
||||
});
|
||||
}
|
||||
|
||||
void _onHoldToSpeakStart() {
|
||||
HapticFeedback.lightImpact();
|
||||
HapticFeedback.heavyImpact();
|
||||
HapticFeedback.vibrate();
|
||||
setState(() {
|
||||
_isCancelGestureActive = false;
|
||||
});
|
||||
_startRecording();
|
||||
}
|
||||
|
||||
void _onHoldToSpeakEnd() {
|
||||
if (_isCancelGestureActive) {
|
||||
HapticFeedback.selectionClick();
|
||||
_cancelRecording(showToast: false);
|
||||
return;
|
||||
}
|
||||
HapticFeedback.mediumImpact();
|
||||
_stopRecording(autoSendAfterTranscribe: true);
|
||||
}
|
||||
|
||||
void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) {
|
||||
const cancelThreshold = -50.0;
|
||||
if (details.offsetFromOrigin.dy < cancelThreshold) {
|
||||
_cancelRecording();
|
||||
final willCancel = details.offsetFromOrigin.dy < _cancelThreshold;
|
||||
if (willCancel != _isCancelGestureActive && mounted) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
_isCancelGestureActive = willCancel;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelRecording() async {
|
||||
void _onHoldToSpeakCancel() {
|
||||
_cancelRecording(showToast: false);
|
||||
}
|
||||
|
||||
Future<void> _cancelRecording({bool showToast = true}) async {
|
||||
try {
|
||||
await _voiceRecorder.stop();
|
||||
_listeningAnimationController.stop();
|
||||
@@ -904,8 +863,11 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
_isCancelGestureActive = false;
|
||||
});
|
||||
Toast.show(context, '已取消', type: ToastType.info);
|
||||
if (showToast) {
|
||||
Toast.show(context, '已取消', type: ToastType.info);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(BuildContext context) async {
|
||||
@@ -933,8 +895,8 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _onStopGenerating(BuildContext context) async {
|
||||
final canceled = await context.read<ChatBloc>().cancelCurrentRun();
|
||||
Future<void> _onStopGenerating() async {
|
||||
final canceled = await _chatBloc.cancelCurrentRun();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
@@ -943,40 +905,94 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildListeningIndicator() {
|
||||
return SizedBox(
|
||||
height: _inputMinHeight,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _listeningAnimationController,
|
||||
builder: (context, _) {
|
||||
final t = _listeningAnimationController.value;
|
||||
final waveA =
|
||||
0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0));
|
||||
final waveB =
|
||||
0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0));
|
||||
final waveC =
|
||||
0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0));
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildWaveDot(scale: waveA),
|
||||
const SizedBox(width: 6),
|
||||
_buildWaveDot(scale: waveB),
|
||||
const SizedBox(width: 6),
|
||||
_buildWaveDot(scale: waveC),
|
||||
],
|
||||
Widget _buildWaveDots() {
|
||||
return AnimatedBuilder(
|
||||
animation: _listeningAnimationController,
|
||||
builder: (context, _) {
|
||||
final t = _listeningAnimationController.value;
|
||||
final barCount = (AppSpacing.xxl * 2).toInt();
|
||||
final barColor = _isCancelGestureActive
|
||||
? AppColors.red500
|
||||
: AppColors.blue500;
|
||||
|
||||
return SizedBox(
|
||||
height: AppSpacing.lg,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: List.generate(barCount, (index) {
|
||||
final phase = (index / barCount + t) % 1;
|
||||
final active = (1 - ((phase - 0.5).abs() * 2)).clamp(0.0, 1.0);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Container(
|
||||
width: AppSpacing.xs / 2,
|
||||
height: AppSpacing.sm + AppSpacing.xs * active,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor.withValues(alpha: 0.35 + active * 0.65),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text(
|
||||
'正在聆听...',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordingGestureOverlay() {
|
||||
final topColor = _isCancelGestureActive
|
||||
? AppColors.warningBackground
|
||||
: AppColors.blue50;
|
||||
final bottomColor = _isCancelGestureActive
|
||||
? AppColors.red400
|
||||
: AppColors.blue400;
|
||||
final labelColor = _isCancelGestureActive
|
||||
? AppColors.red600
|
||||
: AppColors.white;
|
||||
final label = _isCancelGestureActive ? '松手取消' : '松手发送,上移取消';
|
||||
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: AppSpacing.xxl * 7),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
),
|
||||
],
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppRadius.xxl),
|
||||
topRight: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [topColor, bottomColor],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: AppSpacing.xl,
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildWaveDots(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1002,20 +1018,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveDot({required double scale}) {
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Container(
|
||||
width: _recordingDotSize,
|
||||
height: _recordingDotSize,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.red600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startRecording() async {
|
||||
try {
|
||||
await _voiceRecorder.start();
|
||||
@@ -1025,6 +1027,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
_isCancelGestureActive = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
@@ -1045,6 +1048,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
setState(() {
|
||||
_isRecording = false;
|
||||
_isTranscribing = true;
|
||||
_isCancelGestureActive = false;
|
||||
});
|
||||
if (audioPath == null || audioPath.isEmpty) {
|
||||
throw StateError('录音失败,请重试');
|
||||
|
||||
Reference in New Issue
Block a user