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,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,
),
),
),
);
}
}