Files
social-app/apps/lib/features/home/ui/screens/home_screen.dart
T
qzl 3273d63b23 feat: 重构 Home Screen 视觉设计与消息输入组件
- 新增 Home Screen 视觉设计 token (背景、工具栏、对话区、输入框等)
- 重构首页布局为浮动式底部输入栈结构
- 新增 HomeBackgroundField、HomeFloatingHeader、HomeAttachmentStrip 组件
- 优化 MessageComposer 视觉样式为悬浮 shell 设计
- 添加相关测试用例
2026-03-13 17:25:29 +08:00

1049 lines
32 KiB
Dart

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/api/api_exception.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart';
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';
import '../widgets/home_attachment_strip.dart';
import '../widgets/home_background_field.dart';
import '../widgets/home_floating_header.dart';
/// 布局常量
const _defaultPadding = 20.0;
const _itemSpacing = 16.0;
const _iconSize = 24.0;
const _messagePaddingH = 13.0;
const _messagePaddingV = 9.0;
const _cornerRadius = 12.0;
const _inputMinHeight = AppSpacing.xxl + AppSpacing.lg;
const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl);
const _scrollDurationMs = 300;
const _rippleDurationMs = 1200;
const _transcribingSpinnerSize = 18.0;
const _transcribingStrokeWidth = 2.0;
const _attachmentPreviewSize = 88.0;
const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _bottomStackReservedHeight = 140.0;
const homeConversationStageKey = ValueKey('home_conversation_stage');
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient');
/// 颜色常量
const _chatBgColor = AppColors.slate50;
const _userBubbleColor = AppColors.blue50;
class HomeScreen extends StatefulWidget {
final VoiceRecorder? voiceRecorder;
final Future<String> Function(String filePath)? onTranscribeAudio;
final Future<void> Function(String transcript)? onAutoSendTranscript;
final ChatBloc? chatBloc;
final bool autoLoadHistory;
final List<XFile> initialSelectedImages;
const HomeScreen({
super.key,
this.voiceRecorder,
this.onTranscribeAudio,
this.onAutoSendTranscript,
this.chatBloc,
this.autoLoadHistory = true,
this.initialSelectedImages = const [],
});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc;
late final VoiceRecorder _voiceRecorder;
late final InboxApi _inboxApi;
late final Future<String> Function(String filePath) _transcribeAudio;
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isHoldToSpeakMode = false;
bool _isTranscribing = false;
bool _isCancelGestureActive = false;
int _unreadCount = 0;
final List<XFile> _selectedImages = [];
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@override
void initState() {
super.initState();
_messageController.addListener(_onMessageChanged);
_chatBloc = widget.chatBloc ?? ChatBloc(apiClient: sl());
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_inboxApi = sl<InboxApi>();
_transcribeAudio =
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
_listeningAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: _rippleDurationMs),
);
_selectedImages.addAll(widget.initialSelectedImages);
if (widget.autoLoadHistory) {
_chatBloc.loadHistory();
}
_loadUnreadCount();
}
Future<void> _loadUnreadCount() async {
try {
final messages = await _inboxApi.getMessages(isRead: false);
if (mounted) {
setState(() => _unreadCount = messages.length);
}
} catch (_) {
// Ignore errors
}
}
@override
void dispose() {
_messageController.removeListener(_onMessageChanged);
_messageController.dispose();
_scrollController.dispose();
_listeningAnimationController.dispose();
_voiceRecorder.dispose();
if (widget.chatBloc == null) {
_chatBloc.close();
}
RouteNavigationTool.instance.clearNavigator();
super.dispose();
}
void _onMessageChanged() {
setState(() {});
}
@override
Widget build(BuildContext context) {
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
if (!mounted) {
return;
}
if (replace) {
context.go(target);
} else {
context.push(target);
}
});
return BlocProvider.value(
value: _chatBloc,
child: BlocConsumer<ChatBloc, ChatState>(
listener: (context, state) {
if (state.error != null) {
Toast.show(context, state.error!, type: ToastType.error);
}
},
builder: (context, state) {
return Scaffold(
backgroundColor: _chatBgColor,
body: SafeArea(
child: Stack(
children: [
const Positioned.fill(child: HomeBackgroundField()),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(context),
Expanded(child: _buildChatArea(context, state)),
],
),
_buildBottomInputStack(context, state),
if (_isRecording) _buildRecordingGestureOverlay(),
],
),
),
);
},
),
);
}
Widget _buildHeader(BuildContext context) {
return HomeFloatingHeader(
unreadCount: _unreadCount,
onTapSettings: () => context.push('/settings'),
onTapCalendar: () => context.push('/calendar/dayweek?from=home'),
onTapMessages: () => context.push('/messages/invites'),
);
}
Widget _buildChatArea(BuildContext context, ChatState state) {
final showWaitingIndicator =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
if (state.isLoadingHistory && state.items.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return Padding(
padding: const EdgeInsets.fromLTRB(
_defaultPadding,
0,
_defaultPadding,
_bottomStackReservedHeight,
),
child: KeyedSubtree(
key: homeConversationStageKey,
child: Stack(
children: [
if (state.items.isEmpty)
const Positioned.fill(child: _HomeEmptyStateAmbient())
else
Positioned.fill(
child: RefreshIndicator(
onRefresh: () => _onRefresh(context),
child: ListView.builder(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: AppSpacing.sm),
itemCount:
state.items.length + (state.hasEarlierHistory ? 1 : 0),
itemBuilder: (context, index) {
if (index == 0 && state.hasEarlierHistory) {
return _buildLoadMoreButton(
context,
state.isLoadingHistory,
);
}
final itemIndex = state.hasEarlierHistory
? index - 1
: index;
final item = state.items[itemIndex];
final showDateDivider =
itemIndex == 0 ||
!_isSameDay(
state.items[itemIndex - 1].timestamp,
item.timestamp,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (showDateDivider)
_buildDateDivider(item.timestamp),
Padding(
padding: const EdgeInsets.only(
bottom: _itemSpacing,
),
child: _buildChatItem(item),
),
],
);
},
),
),
),
if (showWaitingIndicator)
Align(
alignment: Alignment.bottomLeft,
child: _buildWaitingIndicator(currentStage: state.currentStage),
),
],
),
),
);
}
Widget _buildWaitingIndicator({required AgentStage? currentStage}) {
final label = switch (currentStage) {
AgentStage.intent => '意图识别中',
AgentStage.execution => '任务执行中',
AgentStage.report => '结果总结中',
null => '正在思考...',
};
return Padding(
padding: const EdgeInsets.fromLTRB(
_defaultPadding,
0,
_defaultPadding,
_defaultPadding,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
color: AppColors.blue600,
),
),
SizedBox(width: AppSpacing.sm),
Text(
label,
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
],
),
);
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
Widget _buildDateDivider(DateTime date) {
final now = DateTime.now();
final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final weekday = weekdays[date.weekday - 1];
// For all dates (today/yesterday/this year), use the same format
// Only add year prefix for dates from previous years
final label = date.year == now.year
? '${date.month}${date.day}$weekday'
: '${date.year}${date.month}${date.day}$weekday';
return Container(
padding: const EdgeInsets.symmetric(vertical: 12),
alignment: Alignment.center,
child: Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.slate400),
),
);
}
Widget _buildLoadMoreButton(BuildContext context, bool isLoading) {
return GestureDetector(
onTap: isLoading ? null : () => _onLoadMore(context),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
alignment: Alignment.center,
child: isLoading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: AppColors.slate400,
),
)
: const Text(
'查看历史',
style: TextStyle(fontSize: 12, color: AppColors.slate400),
),
),
);
}
Future<void> _onRefresh(BuildContext context) async {
await context.read<ChatBloc>().loadMoreHistory();
}
void _onLoadMore(BuildContext context) {
context.read<ChatBloc>().loadMoreHistory();
}
Widget _buildChatItem(ChatListItem item) {
switch (item.type) {
case ChatItemType.message:
return _buildMessageItem(item as TextMessageItem);
case ChatItemType.toolCall:
return _buildToolCallItem(item as ToolCallItem);
case ChatItemType.toolResult:
return _buildToolResultItem(item as ToolResultItem);
}
}
Widget _buildMessageItem(TextMessageItem item) {
final isUser = item.sender == MessageSender.user;
final imageAttachments = _collectRenderableImageAttachments(
item.attachments,
);
final hasRenderableAttachments = imageAttachments.isNotEmpty;
return Column(
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: _messagePaddingH,
vertical: _messagePaddingV,
),
decoration: BoxDecoration(
color: isUser ? _userBubbleColor : AppColors.white,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(_cornerRadius),
topRight: const Radius.circular(_cornerRadius),
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
),
border: isUser ? null : Border.all(color: AppColors.slate300),
),
child: Text(
item.content,
style: const TextStyle(
fontSize: 14,
color: AppColors.slate900,
),
),
),
),
],
),
if (hasRenderableAttachments)
Padding(
padding: const EdgeInsets.only(top: _attachmentPreviewGap),
child: _buildHistoryAttachmentPreviews(
item.attachments,
imageAttachments: imageAttachments,
),
),
],
);
}
Widget _buildHistoryAttachmentPreviews(
List<Map<String, dynamic>> attachments, {
List<Map<String, dynamic>>? imageAttachments,
}) {
final renderableAttachments =
imageAttachments ?? _collectRenderableImageAttachments(attachments);
if (renderableAttachments.isEmpty) {
return const SizedBox.shrink();
}
return Wrap(
spacing: _attachmentPreviewGap,
runSpacing: _attachmentPreviewGap,
crossAxisAlignment: WrapCrossAlignment.start,
children: renderableAttachments.map(_buildHistoryAttachmentTile).toList(),
);
}
List<Map<String, dynamic>> _collectRenderableImageAttachments(
List<Map<String, dynamic>> attachments,
) {
return attachments.where(_isRenderableImageAttachment).toList();
}
bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
final url = attachment['url'];
final mimeType = attachment['mimeType'];
return url is String &&
url.isNotEmpty &&
mimeType is String &&
mimeType.startsWith('image/');
}
Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
final url = attachment['url'];
if (url is! String || url.isEmpty) {
return const SizedBox.shrink();
}
return ClipRRect(
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
child: Container(
width: _attachmentPreviewSize,
height: _attachmentPreviewSize,
color: AppColors.slate100,
child: Image.network(
url,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(
child: SizedBox(
width: _transcribingSpinnerSize,
height: _transcribingSpinnerSize,
child: CircularProgressIndicator(
strokeWidth: _transcribingStrokeWidth,
),
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
},
),
),
);
}
Widget _buildToolCallItem(ToolCallItem item) {
final (statusText, statusColor, statusIcon) = switch (item.status) {
ToolCallStatus.pending => (
'准备中...',
AppColors.slate500,
LucideIcons.clock,
),
ToolCallStatus.executing => (
'执行中...',
AppColors.blue600,
LucideIcons.loader,
),
ToolCallStatus.error => (
item.errorMessage ?? '执行失败',
AppColors.red600,
LucideIcons.alertCircle,
),
ToolCallStatus.completed => (
'已完成',
AppColors.emerald600,
LucideIcons.checkCircle,
),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: AppColors.blue50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.slate300),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(statusIcon, size: 16, color: statusColor),
const SizedBox(width: AppSpacing.sm),
Text(
item.toolName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
const SizedBox(width: AppSpacing.sm),
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
if (item.toolName == 'front.navigate_to_route' &&
item.status == ToolCallStatus.pending) ...[
const SizedBox(width: AppSpacing.sm),
GestureDetector(
onTap: () => _chatBloc.approveToolCall(item.callId),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.blue600,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'同意',
style: TextStyle(fontSize: 11, color: AppColors.white),
),
),
),
],
],
),
);
}
Widget _buildToolResultItem(ToolResultItem item) {
return UiSchemaRenderer.render(item.uiCard);
}
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: KeyedSubtree(
key: homeBottomInputStackKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
HomeAttachmentStrip(
images: _selectedImages,
onRemove: _removeImage,
),
if (_selectedImages.isNotEmpty)
const SizedBox(height: AppSpacing.sm),
_buildInputContainer(context, state),
],
),
),
),
);
}
void _removeImage(int index) {
setState(() {
_selectedImages.removeAt(index);
});
}
Widget _buildInputContainer(BuildContext context, ChatState state) {
final isWaitingAgent =
state.isWaitingFirstToken || state.isStreaming || state.isCancelling;
return Container(
padding: EdgeInsets.zero,
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,
),
);
}
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),
),
),
);
}
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.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) {
final willCancel = details.offsetFromOrigin.dy < _cancelThreshold;
if (willCancel != _isCancelGestureActive && mounted) {
HapticFeedback.selectionClick();
setState(() {
_isCancelGestureActive = willCancel;
});
}
}
void _onHoldToSpeakCancel() {
_cancelRecording(showToast: false);
}
Future<void> _cancelRecording({bool showToast = true}) async {
try {
await _voiceRecorder.stop();
_listeningAnimationController.stop();
} catch (_) {}
if (!mounted) return;
setState(() {
_isRecording = false;
_isCancelGestureActive = false;
});
if (showToast) {
Toast.show(context, '已取消', type: ToastType.info);
}
}
Future<void> _sendMessage(BuildContext context) async {
final content = _messageController.text.trim();
if (content.isEmpty && _selectedImages.isEmpty) return;
final images = List<XFile>.from(_selectedImages);
FocusScope.of(context).unfocus();
_messageController.clear();
setState(() {
_selectedImages.clear();
});
await context.read<ChatBloc>().sendMessage(content, images: images);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: _scrollDurationMs),
curve: Curves.easeOut,
);
}
});
}
Future<void> _onStopGenerating() async {
final canceled = await _chatBloc.cancelCurrentRun();
if (!mounted) {
return;
}
if (canceled) {
Toast.show(context, '已停止等待回复', type: ToastType.info);
}
}
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),
),
),
);
}),
),
);
},
);
}
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(),
],
),
),
),
);
}
Widget _buildTranscribingIndicator() {
return TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 180),
builder: (context, value, child) {
return Opacity(opacity: value, child: child);
},
child: const SizedBox(
key: ValueKey('transcribing_indicator'),
height: _inputMinHeight,
child: Align(
alignment: Alignment.centerLeft,
child: Text(
'语音识别中...',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
),
),
);
}
Future<void> _startRecording() async {
try {
await _voiceRecorder.start();
_listeningAnimationController.repeat();
if (!mounted) {
return;
}
setState(() {
_isRecording = true;
_isCancelGestureActive = false;
});
} catch (error) {
if (!mounted) {
return;
}
Toast.show(context, _readableError(error), type: ToastType.error);
}
}
Future<void> _stopRecording({bool autoSendAfterTranscribe = false}) async {
String? audioPath;
try {
audioPath = await _voiceRecorder.stop();
_listeningAnimationController.stop();
if (!mounted) {
return;
}
setState(() {
_isRecording = false;
_isTranscribing = true;
_isCancelGestureActive = false;
});
if (audioPath == null || audioPath.isEmpty) {
throw StateError('录音失败,请重试');
}
final transcript = await _transcribeAudio(audioPath);
if (!mounted) {
return;
}
final normalizedTranscript = transcript.trim();
if (normalizedTranscript.isEmpty) {
Toast.show(context, '未识别到有效语音,请靠近麦克风并连续说话后重试', type: ToastType.error);
return;
}
_messageController.text = transcript;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: transcript.length),
);
if (autoSendAfterTranscribe) {
_messageController.clear();
await _autoSendTranscript(normalizedTranscript);
}
} catch (error) {
if (!mounted) {
return;
}
Toast.show(context, _readableError(error), type: ToastType.error);
} finally {
try {
if (audioPath != null) {
final file = File(audioPath);
if (await file.exists()) {
await file.delete();
}
}
} catch (_) {
// Ignore temp file cleanup errors to avoid blocking UI state recovery.
}
if (mounted) {
setState(() {
_isTranscribing = false;
});
}
}
}
String _readableError(Object error) {
if (error is ApiException) {
return error.message;
}
final raw = error.toString();
if (raw.startsWith('Instance of')) {
return '请求失败,请稍后重试';
}
return raw.replaceFirst('Bad state: ', '');
}
void _showBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => HomeSheet(
onImagesSelected: (images) {
setState(() {
final remaining = 3 - _selectedImages.length;
if (remaining > 0) {
_selectedImages.addAll(images.take(remaining));
}
});
},
),
);
}
}
class _HomeEmptyStateAmbient extends StatelessWidget {
const _HomeEmptyStateAmbient();
@override
Widget build(BuildContext context) {
return Center(
child: IgnorePointer(
child: Container(
key: homeEmptyStateAmbientKey,
width: double.infinity,
height: AppSpacing.xxl * 6,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.12),
AppColors.homeBackgroundGlow.withValues(alpha: 0.08),
AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.12),
],
),
),
),
),
);
}
}