feat(apps/home): 新增 HomeScreen 录音交互与导航组件

This commit is contained in:
zl-q
2026-03-19 00:51:52 +08:00
parent 14ccf2cb28
commit 039e8b73d6
13 changed files with 1840 additions and 847 deletions
@@ -0,0 +1,247 @@
enum ViewportStatus { atBottom, readingHistory, restoringAnchor }
enum ViewportAction {
none,
jumpBottom,
animateBottom,
restoreAnchor,
showUnreadBadge,
}
enum ViewportEventType {
historyInitialLoaded,
historyPagePrependStarted,
historyPagePrependFinished,
newMessageAppended,
screenResumedFromSubRoute,
userScrollStateChanged,
sessionRefreshCompleted,
}
enum ViewportTriggerSource { user, system, route }
class ViewportAnchor {
final String? messageId;
final double? offsetPx;
const ViewportAnchor({required this.messageId, required this.offsetPx});
}
class ViewportContext {
final double distanceToBottomPx;
final bool isFirstEnter;
final bool hasSavedViewport;
final bool hasAnchor;
const ViewportContext({
required this.distanceToBottomPx,
required this.isFirstEnter,
required this.hasSavedViewport,
required this.hasAnchor,
});
}
class ViewportEvent {
final ViewportEventType type;
final String conversationId;
final int eventSeq;
final ViewportTriggerSource triggerSource;
final int deltaCount;
final ViewportAnchor anchor;
final int timestamp;
final ViewportContext viewportContext;
const ViewportEvent({
required this.type,
required this.conversationId,
required this.eventSeq,
required this.triggerSource,
required this.deltaCount,
required this.anchor,
required this.timestamp,
required this.viewportContext,
});
}
class ViewportDecision {
final ViewportAction action;
final String reason;
final Map<String, Object> debugMeta;
const ViewportDecision(this.action, this.reason, {this.debugMeta = const {}});
}
class HomeMessageViewportController {
static const double bottomThresholdPx = 96;
final Map<String, int> _lastAppliedSeqByConversation = <String, int>{};
final Map<String, ViewportStatus> _statusByConversation =
<String, ViewportStatus>{};
final Map<String, int> _unreadByConversation = <String, int>{};
int get unreadCount => _unreadByConversation.values.fold(0, (a, b) => a + b);
ViewportStatus _statusOf(String conversationId) {
return _statusByConversation[conversationId] ?? ViewportStatus.atBottom;
}
void _setStatus(String conversationId, ViewportStatus status) {
_statusByConversation[conversationId] = status;
}
int _unreadOf(String conversationId) {
return _unreadByConversation[conversationId] ?? 0;
}
void _setUnread(String conversationId, int value) {
if (value <= 0) {
_unreadByConversation.remove(conversationId);
return;
}
_unreadByConversation[conversationId] = value;
}
ViewportDecision apply(ViewportEvent event) {
final lastSeq = _lastAppliedSeqByConversation[event.conversationId] ?? -1;
if (event.eventSeq <= lastSeq) {
return const ViewportDecision(ViewportAction.none, 'stale-event');
}
final debugMeta = <String, Object>{};
final seqGap = event.eventSeq - lastSeq;
if (lastSeq >= 0 && seqGap > 1) {
debugMeta['seqGap'] = seqGap;
}
_lastAppliedSeqByConversation[event.conversationId] = event.eventSeq;
final currentStatus = _statusOf(event.conversationId);
switch (event.type) {
case ViewportEventType.historyInitialLoaded:
if (event.viewportContext.isFirstEnter) {
_setStatus(event.conversationId, ViewportStatus.atBottom);
_setUnread(event.conversationId, 0);
return ViewportDecision(
ViewportAction.jumpBottom,
'initial-load-first-enter',
debugMeta: debugMeta,
);
}
return ViewportDecision(
ViewportAction.none,
'initial-load-non-first-enter',
debugMeta: debugMeta,
);
case ViewportEventType.userScrollStateChanged:
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
_setStatus(event.conversationId, ViewportStatus.atBottom);
_setUnread(event.conversationId, 0);
return ViewportDecision(
ViewportAction.none,
'entered-at-bottom',
debugMeta: debugMeta,
);
}
_setStatus(event.conversationId, ViewportStatus.readingHistory);
return ViewportDecision(
ViewportAction.none,
'entered-reading-history',
debugMeta: debugMeta,
);
case ViewportEventType.historyPagePrependStarted:
if (event.viewportContext.hasAnchor) {
_setStatus(event.conversationId, ViewportStatus.restoringAnchor);
return ViewportDecision(
ViewportAction.none,
'prepend-start-capture-anchor',
debugMeta: debugMeta,
);
}
return ViewportDecision(
ViewportAction.none,
'prepend-start-no-anchor',
debugMeta: debugMeta,
);
case ViewportEventType.historyPagePrependFinished:
if (currentStatus == ViewportStatus.restoringAnchor &&
event.viewportContext.hasAnchor) {
_setStatus(event.conversationId, ViewportStatus.readingHistory);
return ViewportDecision(
ViewportAction.restoreAnchor,
'prepend-finish-restore-anchor',
debugMeta: debugMeta,
);
}
_setStatus(
event.conversationId,
event.viewportContext.distanceToBottomPx <= bottomThresholdPx
? ViewportStatus.atBottom
: ViewportStatus.readingHistory,
);
return ViewportDecision(
ViewportAction.none,
'prepend-finish-no-restore',
debugMeta: debugMeta,
);
case ViewportEventType.newMessageAppended:
if (currentStatus == ViewportStatus.restoringAnchor) {
_setUnread(
event.conversationId,
_unreadOf(event.conversationId) +
(event.deltaCount > 0 ? event.deltaCount : 1),
);
return ViewportDecision(
ViewportAction.none,
'restoring-anchor-enqueue-unread',
debugMeta: debugMeta,
);
}
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
_setStatus(event.conversationId, ViewportStatus.atBottom);
return ViewportDecision(
ViewportAction.animateBottom,
'new-message-follow-bottom',
debugMeta: debugMeta,
);
}
_setStatus(event.conversationId, ViewportStatus.readingHistory);
_setUnread(
event.conversationId,
_unreadOf(event.conversationId) +
(event.deltaCount > 0 ? event.deltaCount : 1),
);
return ViewportDecision(
ViewportAction.showUnreadBadge,
'new-message-keep-reading-history',
debugMeta: debugMeta,
);
case ViewportEventType.screenResumedFromSubRoute:
if (event.viewportContext.hasSavedViewport) {
return ViewportDecision(
ViewportAction.restoreAnchor,
'resume-restore-saved-viewport',
debugMeta: debugMeta,
);
}
return ViewportDecision(
ViewportAction.none,
'resume-no-saved-viewport',
debugMeta: debugMeta,
);
case ViewportEventType.sessionRefreshCompleted:
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
_setStatus(event.conversationId, ViewportStatus.atBottom);
_setUnread(event.conversationId, 0);
return ViewportDecision(
ViewportAction.animateBottom,
'refresh-follow-bottom',
debugMeta: debugMeta,
);
}
return ViewportDecision(
ViewportAction.none,
'refresh-keep-position',
debugMeta: debugMeta,
);
}
}
}
@@ -0,0 +1,40 @@
import 'home_message_viewport_controller.dart';
class HomeViewportCoordinator {
HomeViewportCoordinator(this._controller);
final HomeMessageViewportController _controller;
int _eventSeq = 0;
int get unreadCount => _controller.unreadCount;
ViewportDecision dispatch({
required ViewportEventType type,
required ViewportTriggerSource source,
required int deltaCount,
required double distanceToBottomPx,
required bool hasSavedViewport,
required bool hasAnchor,
required double? anchorOffsetPx,
bool isFirstEnter = false,
}) {
_eventSeq += 1;
return _controller.apply(
ViewportEvent(
type: type,
conversationId: 'home-main',
eventSeq: _eventSeq,
triggerSource: source,
deltaCount: deltaCount,
anchor: ViewportAnchor(messageId: null, offsetPx: anchorOffsetPx),
timestamp: DateTime.now().millisecondsSinceEpoch,
viewportContext: ViewportContext(
distanceToBottomPx: distanceToBottomPx,
isFirstEnter: isFirstEnter,
hasSavedViewport: hasSavedViewport,
hasAnchor: hasAnchor,
),
),
);
}
}
@@ -0,0 +1,37 @@
import 'package:flutter/widgets.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/router/app_routes.dart';
enum HomeReturnAction { pop, goHome }
HomeReturnAction resolveHomeReturnAction({
required bool canPop,
required bool isAuthEntry,
}) {
if (isAuthEntry) {
return HomeReturnAction.goHome;
}
if (canPop) {
return HomeReturnAction.pop;
}
return HomeReturnAction.goHome;
}
void returnToHomePreserveState(
BuildContext context, {
bool isAuthEntry = false,
}) {
final action = resolveHomeReturnAction(
canPop: context.canPop(),
isAuthEntry: isAuthEntry,
);
switch (action) {
case HomeReturnAction.pop:
context.pop();
return;
case HomeReturnAction.goHome:
context.go(AppRoutes.homeMain);
return;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,217 @@
// ignore_for_file: invalid_use_of_protected_member
part of 'home_screen.dart';
extension _HomeScreenInteractions on _HomeScreenState {
void _onHoldToSpeakCancel() {
if (_isRecordingStarting) {
_shouldStopWhenStartCompletes = false;
_shouldCancelWhenStartCompletes = true;
return;
}
_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 {
if (_isSendingMessage) {
return;
}
final content = _messageController.text.trim();
if (content.isEmpty && _selectedImages.isEmpty) return;
final images = List<XFile>.from(_selectedImages);
FocusScope.of(context).unfocus();
_messageController.clear();
setState(() {
_isSendingMessage = true;
_selectedImages.clear();
});
try {
await _chatBloc.sendMessage(content, images: images);
} finally {
if (mounted) {
setState(() {
_isSendingMessage = false;
});
}
}
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);
}
}
Future<void> _startRecording() async {
if (_isRecording || _isRecordingStarting) {
return;
}
if (mounted) {
setState(() {
_isRecordingStarting = true;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
});
}
try {
await _voiceRecorder.start();
_listeningAnimationController.repeat();
if (!mounted) {
return;
}
if (_shouldStopWhenStartCompletes || _shouldCancelWhenStartCompletes) {
final shouldCancelAfterStart =
_shouldCancelWhenStartCompletes || _isCancelGestureActive;
setState(() {
_isRecordingStarting = false;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
_isRecording = true;
_isCancelGestureActive = false;
});
if (shouldCancelAfterStart) {
await _cancelRecording(showToast: false);
return;
}
await _stopRecording(autoSendAfterTranscribe: true);
return;
}
setState(() {
_isRecordingStarting = false;
_isRecording = true;
_isCancelGestureActive = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_isRecordingStarting = false;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
});
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 = normalizedTranscript;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: normalizedTranscript.length),
);
if (autoSendAfterTranscribe) {
setState(() {
_isTranscribing = false;
});
await _sendMessage(context);
}
} 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));
}
});
},
),
);
}
}
@@ -0,0 +1,303 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
const _messagePaddingH = 13.0;
const _messagePaddingV = 9.0;
const _cornerRadius = 12.0;
const _attachmentPreviewSize = 88.0;
const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _toolResultWidthFactor = 0.9;
const _iconSize = 24.0;
class HomeChatItemRenderer {
static Widget build(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);
}
}
static 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 ? AppColors.blue50 : 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,
),
),
],
);
}
static 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(),
);
}
static List<Map<String, dynamic>> _collectRenderableImageAttachments(
List<Map<String, dynamic>> attachments,
) {
return attachments.where(_isRenderableImageAttachment).toList();
}
static bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
final path = attachment['path'];
final url = attachment['url'];
final mimeType = attachment['mimeType'];
final hasRenderableSource =
(url is String && url.isNotEmpty) ||
(path is String && path.isNotEmpty);
return hasRenderableSource &&
mimeType is String &&
mimeType.startsWith('image/');
}
static Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
final path = attachment['path'];
final url = attachment['url'];
final isUploading = attachment['uploading'] == true;
final Widget image;
if (url is String && url.isNotEmpty) {
image = Image.network(
url,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return const Center(
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: 18,
strokeWidth: 2,
),
);
},
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
},
);
} else if (path is String && path.isNotEmpty) {
image = Image.file(
File(path),
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Center(
child: Icon(
LucideIcons.imageOff,
size: _iconSize,
color: AppColors.slate500,
),
);
},
);
} else {
return const SizedBox.shrink();
}
return ClipRRect(
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
child: Container(
width: _attachmentPreviewSize,
height: _attachmentPreviewSize,
color: AppColors.slate100,
child: Stack(
fit: StackFit.expand,
children: [
image,
if (isUploading)
ColoredBox(
color: AppColors.slate900.withValues(alpha: 0.2),
child: const Center(
child: AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: 18,
strokeWidth: 2,
color: AppColors.white,
trackColor: AppColors.slate200,
),
),
),
],
),
),
);
}
static 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(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.surfaceInfoLight,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderTertiary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderTertiary),
),
child: Icon(statusIcon, size: 14, color: statusColor),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.toolName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.slate800,
),
),
const SizedBox(height: AppSpacing.xs),
Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: statusColor,
),
),
],
),
),
],
),
);
}
static Widget _buildToolResultItem(ToolResultItem item) {
final rootNode = item.uiSchema['root'];
final appearance = rootNode is Map<String, dynamic>
? rootNode['appearance'] as String?
: null;
final needsOuterCard = appearance == null || appearance == 'plain';
final schemaContent = UiSchemaRenderer.renderSchema(item.uiSchema);
final wrappedContent = needsOuterCard
? Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.homeConversationBorder),
),
child: schemaContent,
)
: schemaContent;
return Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _toolResultWidthFactor,
child: wrappedContent,
),
);
}
}
@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
import '../../../../shared/widgets/message_composer.dart';
import 'home_attachment_strip.dart';
class HomeComposerStack extends StatelessWidget {
const HomeComposerStack({
super.key,
required this.selectedImages,
required this.onRemoveImage,
required this.isHoldToSpeakMode,
required this.isRecording,
required this.isCancelGestureActive,
required this.isTranscribing,
required this.isWaitingAgent,
required this.messageController,
required this.messageFocusNode,
required this.onTapPlus,
required this.onTapRightAction,
required this.onHoldToSpeakStart,
required this.onHoldToSpeakEnd,
required this.onHoldToSpeakMoveUpdate,
required this.onHoldToSpeakCancel,
required this.onTextFieldTap,
required this.onSubmit,
});
final List<XFile> selectedImages;
final ValueChanged<int> onRemoveImage;
final bool isHoldToSpeakMode;
final bool isRecording;
final bool isCancelGestureActive;
final bool isTranscribing;
final bool isWaitingAgent;
final TextEditingController messageController;
final FocusNode messageFocusNode;
final VoidCallback onTapPlus;
final VoidCallback onTapRightAction;
final VoidCallback onHoldToSpeakStart;
final VoidCallback onHoldToSpeakEnd;
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
final VoidCallback onHoldToSpeakCancel;
final VoidCallback onTextFieldTap;
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
final process = isRecording
? MessageComposerProcess.recording
: isTranscribing
? MessageComposerProcess.transcribing
: MessageComposerProcess.idle;
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: KeyedSubtree(
key: const ValueKey('home_bottom_input_stack'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
HomeAttachmentStrip(
images: selectedImages,
onRemove: onRemoveImage,
),
if (selectedImages.isNotEmpty)
const SizedBox(height: AppSpacing.sm),
ValueListenableBuilder<TextEditingValue>(
valueListenable: messageController,
builder: (context, value, child) {
final hasMessage = value.text.trim().isNotEmpty;
return MessageComposer(
mode: isHoldToSpeakMode
? MessageComposerMode.holdToSpeak
: MessageComposerMode.text,
process: process,
hasMessage: hasMessage,
isWaitingAgent: isWaitingAgent,
iconSize: 24,
composerMinHeight: AppSpacing.xxl + AppSpacing.lg,
onTapPlus: onTapPlus,
onTapRightAction: onTapRightAction,
onHoldToSpeakStart: onHoldToSpeakStart,
onHoldToSpeakEnd: onHoldToSpeakEnd,
onHoldToSpeakMoveUpdate: onHoldToSpeakMoveUpdate,
onHoldToSpeakCancel: onHoldToSpeakCancel,
textInputChild: _buildTextInputContent(),
recordingAnimation: const SizedBox.shrink(),
recordingText: isCancelGestureActive ? '松手取消' : '松手发送',
recordingHintText: isCancelGestureActive
? '松开取消'
: '松开发送,上滑取消',
showRecordingInlineFeedback: false,
);
},
),
],
),
),
),
);
}
Widget _buildTextInputContent() {
if (isTranscribing) {
return _buildTranscribingIndicator();
}
return SizedBox.expand(
child: Align(
alignment: Alignment.centerLeft,
child: TextField(
controller: messageController,
focusNode: messageFocusNode,
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,
),
onTap: onTextFieldTap,
onSubmitted: (_) => onSubmit(),
),
),
);
}
Widget _buildTranscribingIndicator() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 18,
height: 18,
child: const AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: 18,
strokeWidth: 2,
color: AppColors.blue600,
trackColor: AppColors.blue100,
),
),
const SizedBox(width: AppSpacing.sm),
_buildWaveDots(),
const SizedBox(width: AppSpacing.sm),
const Expanded(
child: Text(
'语音识别中...',
style: TextStyle(
fontSize: 14,
color: AppColors.blue600,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildWaveDots() {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(3, (index) {
return Container(
margin: const EdgeInsets.only(right: 3),
width: 3,
height: 6 + index * 2,
decoration: BoxDecoration(
color: AppColors.blue500,
borderRadius: BorderRadius.circular(2),
),
);
}),
);
}
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_loading_indicator.dart';
class HomeWaitingIndicator extends StatelessWidget {
const HomeWaitingIndicator({super.key, required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 18,
height: 18,
child: const AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: 18,
strokeWidth: 2,
color: AppColors.blue600,
trackColor: AppColors.blue100,
),
),
SizedBox(width: AppSpacing.sm),
Text(
label,
style: const TextStyle(fontSize: 14, color: AppColors.slate500),
),
],
),
);
}
}
class HomeDateDivider extends StatelessWidget {
const HomeDateDivider({super.key, required this.date});
final DateTime date;
@override
Widget build(BuildContext context) {
final now = DateTime.now();
final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
final weekday = weekdays[date.weekday - 1];
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),
),
);
}
}
class HomeLoadMoreButton extends StatelessWidget {
const HomeLoadMoreButton({
super.key,
required this.isLoading,
required this.onTap,
});
final bool isLoading;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: isLoading ? null : onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
alignment: Alignment.center,
child: isLoading
? const AppLoadingIndicator(
variant: AppLoadingVariant.inline,
size: 14,
strokeWidth: 1.5,
color: AppColors.slate400,
trackColor: AppColors.slate200,
)
: const Text(
'查看历史',
style: TextStyle(fontSize: 12, color: AppColors.slate400),
),
),
);
}
}
@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
const _recordingCancelTopColor = AppColors.warningBackground;
const _recordingCancelBottomColor = AppColors.red400;
const _recordingCancelLabelColor = AppColors.red600;
const _recordingActiveTopColor = AppColors.blue50;
const _recordingActiveBottomColor = AppColors.blue400;
const _recordingActiveLabelColor = AppColors.white;
class HomeRecordingOverlay extends StatelessWidget {
const HomeRecordingOverlay({
super.key,
required this.isCancel,
required this.listeningAnimation,
});
final bool isCancel;
final Animation<double> listeningAnimation;
@override
Widget build(BuildContext context) {
final topColor = isCancel
? _recordingCancelTopColor
: _recordingActiveTopColor;
final bottomColor = isCancel
? _recordingCancelBottomColor
: _recordingActiveBottomColor;
final labelColor = isCancel
? _recordingCancelLabelColor
: _recordingActiveLabelColor;
final label = isCancel ? '松手取消' : '松手发送,上移取消';
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),
_WaveDots(
listeningAnimation: listeningAnimation,
barColor: isCancel ? AppColors.red500 : AppColors.blue500,
),
],
),
),
),
);
}
}
class _WaveDots extends StatelessWidget {
const _WaveDots({required this.listeningAnimation, required this.barColor});
final Animation<double> listeningAnimation;
final Color barColor;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: listeningAnimation,
builder: (context, _) {
final t = listeningAnimation.value;
final barCount = (AppSpacing.xxl * 2).toInt();
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),
),
),
);
}),
),
);
},
);
}
}
@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/app_pressable.dart';
class HomeUnreadBadge extends StatelessWidget {
const HomeUnreadBadge({super.key, required this.count, required this.onTap});
final int count;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return AppPressable(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: AppColors.blue600,
borderRadius: BorderRadius.circular(AppRadius.full),
boxShadow: [
BoxShadow(
color: AppColors.slate900.withValues(alpha: 0.18),
blurRadius: AppRadius.md,
offset: const Offset(0, AppSpacing.xs),
),
],
),
child: Text(
'$count条新消息',
style: const TextStyle(
color: AppColors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
);
}
}