feat(apps/home): 新增 HomeScreen 录音交互与导航组件
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/home/ui/controllers/home_message_viewport_controller.dart';
|
||||
|
||||
void main() {
|
||||
ViewportEvent buildEvent({
|
||||
required ViewportEventType type,
|
||||
required String conversationId,
|
||||
required int eventSeq,
|
||||
required double distanceToBottomPx,
|
||||
bool isFirstEnter = false,
|
||||
bool hasSavedViewport = false,
|
||||
bool hasAnchor = false,
|
||||
int deltaCount = 0,
|
||||
ViewportTriggerSource source = ViewportTriggerSource.system,
|
||||
}) {
|
||||
return ViewportEvent(
|
||||
type: type,
|
||||
conversationId: conversationId,
|
||||
eventSeq: eventSeq,
|
||||
triggerSource: source,
|
||||
deltaCount: deltaCount,
|
||||
anchor: const ViewportAnchor(messageId: null, offsetPx: null),
|
||||
timestamp: 1000 + eventSeq,
|
||||
viewportContext: ViewportContext(
|
||||
distanceToBottomPx: distanceToBottomPx,
|
||||
isFirstEnter: isFirstEnter,
|
||||
hasSavedViewport: hasSavedViewport,
|
||||
hasAnchor: hasAnchor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
test('distance<=96 and new message => animateBottom', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 80,
|
||||
deltaCount: 1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.animateBottom);
|
||||
});
|
||||
|
||||
test('distance>96 and new message => showUnreadBadge', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.userScrollStateChanged,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 200,
|
||||
source: ViewportTriggerSource.user,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 2,
|
||||
distanceToBottomPx: 200,
|
||||
deltaCount: 1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.showUnreadBadge);
|
||||
expect(controller.unreadCount, 1);
|
||||
});
|
||||
|
||||
test('stale event is dropped by sequence', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyInitialLoaded,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 10,
|
||||
distanceToBottomPx: 0,
|
||||
isFirstEnter: true,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 9,
|
||||
distanceToBottomPx: 0,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.none);
|
||||
expect(decision.reason, 'stale-event');
|
||||
});
|
||||
|
||||
test('different conversations keep independent sequence', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyInitialLoaded,
|
||||
conversationId: 'A',
|
||||
eventSeq: 10,
|
||||
distanceToBottomPx: 0,
|
||||
isFirstEnter: true,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyInitialLoaded,
|
||||
conversationId: 'B',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 0,
|
||||
isFirstEnter: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.jumpBottom);
|
||||
});
|
||||
|
||||
test('refresh keeps reading history position when far from bottom', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.userScrollStateChanged,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 180,
|
||||
source: ViewportTriggerSource.user,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.sessionRefreshCompleted,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 2,
|
||||
distanceToBottomPx: 180,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.none);
|
||||
});
|
||||
|
||||
test('resume with saved viewport restores anchor', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.screenResumedFromSubRoute,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 180,
|
||||
hasSavedViewport: true,
|
||||
hasAnchor: true,
|
||||
),
|
||||
);
|
||||
|
||||
expect(decision.action, ViewportAction.restoreAnchor);
|
||||
expect(decision.reason, 'resume-restore-saved-viewport');
|
||||
});
|
||||
|
||||
test('prepend finish without anchor exits restoring state', () {
|
||||
final controller = HomeMessageViewportController();
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyPagePrependStarted,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 1,
|
||||
distanceToBottomPx: 200,
|
||||
hasAnchor: true,
|
||||
),
|
||||
);
|
||||
controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.historyPagePrependFinished,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 2,
|
||||
distanceToBottomPx: 200,
|
||||
hasAnchor: false,
|
||||
),
|
||||
);
|
||||
|
||||
final decision = controller.apply(
|
||||
buildEvent(
|
||||
type: ViewportEventType.newMessageAppended,
|
||||
conversationId: 'c1',
|
||||
eventSeq: 3,
|
||||
distanceToBottomPx: 200,
|
||||
deltaCount: 1,
|
||||
),
|
||||
);
|
||||
expect(decision.action, ViewportAction.showUnreadBadge);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/features/home/ui/navigation/home_return_policy.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveHomeReturnAction', () {
|
||||
test('business route with back stack prefers pop', () {
|
||||
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: false);
|
||||
expect(action, HomeReturnAction.pop);
|
||||
});
|
||||
|
||||
test('business route without back stack falls back to go home', () {
|
||||
final action = resolveHomeReturnAction(canPop: false, isAuthEntry: false);
|
||||
expect(action, HomeReturnAction.goHome);
|
||||
});
|
||||
|
||||
test('auth entry always goes home directly', () {
|
||||
final action = resolveHomeReturnAction(canPop: true, isAuthEntry: true);
|
||||
expect(action, HomeReturnAction.goHome);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:social_app/core/api/i_api_client.dart';
|
||||
import 'package:social_app/core/di/injection.dart';
|
||||
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/home/data/voice_recorder.dart';
|
||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||
import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart';
|
||||
@@ -127,6 +128,18 @@ void main() {
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
List<ChatListItem> buildMessages(int count) {
|
||||
final base = DateTime(2026, 1, 1, 9, 0);
|
||||
return List<ChatListItem>.generate(count, (index) {
|
||||
return TextMessageItem(
|
||||
id: 'msg_$index',
|
||||
content: 'message $index',
|
||||
timestamp: base.add(Duration(minutes: index)),
|
||||
sender: index.isEven ? MessageSender.user : MessageSender.ai,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'home screen shows floating header, conversation stage, and bottom input stack',
|
||||
(tester) async {
|
||||
@@ -292,4 +305,68 @@ void main() {
|
||||
expect(recorder.stopCalls, 1);
|
||||
expect(transcribeCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'shows unread badge when new message arrives during history reading',
|
||||
(tester) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
final initialItems = buildMessages(30);
|
||||
chatBloc.emit(const ChatState().copyWith(items: initialItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable))
|
||||
.position;
|
||||
position.jumpTo(0);
|
||||
await tester.pump(const Duration(milliseconds: 220));
|
||||
|
||||
final nextItems = [
|
||||
...initialItems,
|
||||
...buildMessages(1).map(
|
||||
(e) => (e as TextMessageItem).copyWith(
|
||||
id: 'new_1',
|
||||
content: 'new message',
|
||||
),
|
||||
),
|
||||
];
|
||||
chatBloc.emit(const ChatState().copyWith(items: nextItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.textContaining('有1条新消息'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('tap unread badge scrolls bottom and clears badge', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHomeScreen(tester);
|
||||
|
||||
final initialItems = buildMessages(30);
|
||||
chatBloc.emit(const ChatState().copyWith(items: initialItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.byType(Scrollable))
|
||||
.position;
|
||||
position.jumpTo(0);
|
||||
await tester.pump(const Duration(milliseconds: 220));
|
||||
|
||||
final nextItems = [
|
||||
...initialItems,
|
||||
...buildMessages(1).map(
|
||||
(e) => (e as TextMessageItem).copyWith(
|
||||
id: 'new_2',
|
||||
content: 'new message 2',
|
||||
),
|
||||
),
|
||||
];
|
||||
chatBloc.emit(const ChatState().copyWith(items: nextItems));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
await tester.tap(find.textContaining('有1条新消息'));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.textContaining('有1条新消息'), findsNothing);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user