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/api/i_api_client.dart';
|
||||||
import 'package:social_app/core/di/injection.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/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/data/voice_recorder.dart';
|
||||||
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
import 'package:social_app/features/home/ui/screens/home_screen.dart';
|
||||||
import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart';
|
import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart';
|
||||||
@@ -127,6 +128,18 @@ void main() {
|
|||||||
await tester.pump();
|
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(
|
testWidgets(
|
||||||
'home screen shows floating header, conversation stage, and bottom input stack',
|
'home screen shows floating header, conversation stage, and bottom input stack',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
@@ -292,4 +305,68 @@ void main() {
|
|||||||
expect(recorder.stopCalls, 1);
|
expect(recorder.stopCalls, 1);
|
||||||
expect(transcribeCalls, 0);
|
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