From 039e8b73d6cacf76e7cbf23d821c0ede47edaac8 Mon Sep 17 00:00:00 2001 From: zl-q Date: Thu, 19 Mar 2026 00:51:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(apps/home):=20=E6=96=B0=E5=A2=9E=20HomeScr?= =?UTF-8?q?een=20=E5=BD=95=E9=9F=B3=E4=BA=A4=E4=BA=92=E4=B8=8E=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home_message_viewport_controller.dart | 247 ++++ .../home_viewport_coordinator.dart | 40 + .../ui/navigation/home_return_policy.dart | 37 + .../features/home/ui/screens/home_screen.dart | 1087 ++++------------- .../ui/screens/home_screen_interactions.dart | 217 ++++ .../ui/widgets/home_chat_item_renderer.dart | 303 +++++ .../home/ui/widgets/home_composer_stack.dart | 200 +++ .../ui/widgets/home_conversation_chrome.dart | 97 ++ .../ui/widgets/home_recording_overlay.dart | 121 ++ .../home/ui/widgets/home_unread_badge.dart | 43 + ...home_message_viewport_controller_test.dart | 197 +++ .../navigation/home_return_policy_test.dart | 21 + .../ui/widgets/home_screen_layout_test.dart | 77 ++ 13 files changed, 1840 insertions(+), 847 deletions(-) create mode 100644 apps/lib/features/home/ui/controllers/home_message_viewport_controller.dart create mode 100644 apps/lib/features/home/ui/controllers/home_viewport_coordinator.dart create mode 100644 apps/lib/features/home/ui/navigation/home_return_policy.dart create mode 100644 apps/lib/features/home/ui/screens/home_screen_interactions.dart create mode 100644 apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart create mode 100644 apps/lib/features/home/ui/widgets/home_composer_stack.dart create mode 100644 apps/lib/features/home/ui/widgets/home_conversation_chrome.dart create mode 100644 apps/lib/features/home/ui/widgets/home_recording_overlay.dart create mode 100644 apps/lib/features/home/ui/widgets/home_unread_badge.dart create mode 100644 apps/test/features/home/ui/controllers/home_message_viewport_controller_test.dart create mode 100644 apps/test/features/home/ui/navigation/home_return_policy_test.dart diff --git a/apps/lib/features/home/ui/controllers/home_message_viewport_controller.dart b/apps/lib/features/home/ui/controllers/home_message_viewport_controller.dart new file mode 100644 index 0000000..1c60009 --- /dev/null +++ b/apps/lib/features/home/ui/controllers/home_message_viewport_controller.dart @@ -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 debugMeta; + + const ViewportDecision(this.action, this.reason, {this.debugMeta = const {}}); +} + +class HomeMessageViewportController { + static const double bottomThresholdPx = 96; + + final Map _lastAppliedSeqByConversation = {}; + final Map _statusByConversation = + {}; + final Map _unreadByConversation = {}; + + 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 = {}; + 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, + ); + } + } +} diff --git a/apps/lib/features/home/ui/controllers/home_viewport_coordinator.dart b/apps/lib/features/home/ui/controllers/home_viewport_coordinator.dart new file mode 100644 index 0000000..4f28ec3 --- /dev/null +++ b/apps/lib/features/home/ui/controllers/home_viewport_coordinator.dart @@ -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, + ), + ), + ); + } +} diff --git a/apps/lib/features/home/ui/navigation/home_return_policy.dart b/apps/lib/features/home/ui/navigation/home_return_policy.dart new file mode 100644 index 0000000..8da767d --- /dev/null +++ b/apps/lib/features/home/ui/navigation/home_return_policy.dart @@ -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; + } +} diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 1c4be65..8852a14 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -6,44 +6,39 @@ import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/api/api_exception.dart'; import '../../../../core/di/injection.dart'; +import '../../../../core/router/app_route_observer.dart'; +import '../../../../core/router/app_routes.dart'; import '../../../../core/theme/design_tokens.dart'; -import '../../../chat/data/models/chat_list_item.dart'; import '../../../chat/presentation/bloc/agent_stage.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; -import '../../../chat/ui/widgets/ui_schema_renderer.dart'; -import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../controllers/home_message_viewport_controller.dart'; +import '../controllers/home_viewport_coordinator.dart'; import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; -import '../../../../shared/widgets/message_composer.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../../shared/widgets/full_screen_loading.dart'; import 'home_sheet.dart'; -import '../widgets/home_attachment_strip.dart'; import '../widgets/home_background_field.dart'; +import '../widgets/home_chat_item_renderer.dart'; +import '../widgets/home_composer_stack.dart'; +import '../widgets/home_conversation_chrome.dart'; import '../widgets/home_floating_header.dart'; +import '../widgets/home_recording_overlay.dart'; +import '../widgets/home_unread_badge.dart'; + +part 'home_screen_interactions.dart'; /// 布局常量 const _defaultPadding = 20.0; const _itemSpacing = 16.0; -const _iconSize = 24.0; -const _messagePaddingH = 13.0; -const _messagePaddingV = 9.0; -const _cornerRadius = 12.0; -const _inputMinHeight = AppSpacing.xxl + AppSpacing.lg; const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl); const _scrollDurationMs = 300; const _rippleDurationMs = 1200; -const _transcribingSpinnerSize = 18.0; -const _transcribingStrokeWidth = 2.0; -const _attachmentPreviewSize = 88.0; -const _attachmentPreviewRadius = 10.0; -const _attachmentPreviewGap = 8.0; const _bottomStackReservedHeight = 116.0; -const _toolResultWidthFactor = 0.9; const _pullRefreshMinVisibleMs = 450; const _waitingIndicatorReservedHeight = 42.0; @@ -53,15 +48,6 @@ const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient'); /// 颜色常量 const _chatBgColor = AppColors.slate50; -const _userBubbleColor = AppColors.blue50; - -/// 录制状态颜色 -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 HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; @@ -84,7 +70,7 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, RouteAware { final TextEditingController _messageController = TextEditingController(); final FocusNode _messageFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); @@ -104,9 +90,15 @@ class _HomeScreenState extends State bool _isPullRefreshing = false; bool _isHistoryPaginationInFlight = false; int _unreadCount = 0; + int _chatUnreadBadgeCount = 0; final List _selectedImages = []; - int _lastObservedItemCount = 0; - bool _lastObservedWaiting = false; + final HomeViewportCoordinator _viewportCoordinator = HomeViewportCoordinator( + HomeMessageViewportController(), + ); + bool _initialHistoryHandled = false; + int _previousItemCount = 0; + bool _previousIsLoadingHistory = false; + bool _routeAwareSubscribed = false; double? _historyViewportPixels; double? _historyViewportMaxExtent; @@ -128,11 +120,12 @@ class _HomeScreenState extends State duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); - if (widget.autoLoadHistory) { + if (widget.autoLoadHistory && _chatBloc.state.items.isEmpty) { _chatBloc.loadHistory(); } - _lastObservedItemCount = _chatBloc.state.items.length; - _lastObservedWaiting = _isAgentWaiting(_chatBloc.state); + _scrollController.addListener(_handleScrollChanged); + _previousItemCount = _chatBloc.state.items.length; + _previousIsLoadingHistory = _chatBloc.state.isLoadingHistory; _loadUnreadCount(); } @@ -151,12 +144,38 @@ class _HomeScreenState extends State void dispose() { _messageController.dispose(); _messageFocusNode.dispose(); + _scrollController.removeListener(_handleScrollChanged); _scrollController.dispose(); _listeningAnimationController.dispose(); _voiceRecorder.dispose(); + if (_routeAwareSubscribed) { + appRouteObserver.unsubscribe(this); + _routeAwareSubscribed = false; + } super.dispose(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (!_routeAwareSubscribed && route is PageRoute) { + appRouteObserver.subscribe(this, route); + _routeAwareSubscribed = true; + } + } + + @override + void didPopNext() { + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.screenResumedFromSubRoute, + source: ViewportTriggerSource.route, + deltaCount: 0, + ), + ); + } + @override Widget build(BuildContext context) { return BlocProvider.value( @@ -166,19 +185,39 @@ class _HomeScreenState extends State if (state.error != null) { Toast.show(context, state.error!, type: ToastType.error); } - final isWaitingNow = _isAgentWaiting(state); - final hasItemCountChanged = - state.items.length != _lastObservedItemCount; - final waitingStateChanged = isWaitingNow != _lastObservedWaiting; - final shouldAutoScroll = + + final loadingFinished = + _previousIsLoadingHistory && !state.isLoadingHistory; + if (loadingFinished && !_isHistoryPaginationInFlight && - !state.isLoadingHistory && - (hasItemCountChanged || waitingStateChanged); - if (shouldAutoScroll) { - _scheduleAutoScroll(animated: hasItemCountChanged); + !_initialHistoryHandled && + state.items.isNotEmpty) { + _initialHistoryHandled = true; + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.historyInitialLoaded, + source: ViewportTriggerSource.system, + deltaCount: 0, + isFirstEnter: true, + ), + ); } - _lastObservedItemCount = state.items.length; - _lastObservedWaiting = isWaitingNow; + + final itemDelta = state.items.length - _previousItemCount; + if (!_isHistoryPaginationInFlight && + !state.isLoadingHistory && + itemDelta > 0) { + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.newMessageAppended, + source: ViewportTriggerSource.system, + deltaCount: itemDelta, + ), + ); + } + + _previousItemCount = state.items.length; + _previousIsLoadingHistory = state.isLoadingHistory; }, builder: (context, state) { return Scaffold( @@ -194,8 +233,13 @@ class _HomeScreenState extends State Expanded(child: _buildChatArea(context, state)), ], ), + if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(), _buildBottomInputStack(context, state), - if (_isRecording) _buildRecordingGestureOverlay(), + if (_isRecording) + HomeRecordingOverlay( + isCancel: _isCancelGestureActive, + listeningAnimation: _listeningAnimationController, + ), ], ), ), @@ -208,9 +252,10 @@ class _HomeScreenState extends State Widget _buildHeader(BuildContext context) { return HomeFloatingHeader( unreadCount: _unreadCount, - onTapSettings: () => context.push('/settings'), - onTapCalendar: () => context.push('/calendar/dayweek?from=home'), - onTapMessages: () => context.push('/messages/invites'), + onTapSettings: () => context.push(AppRoutes.settingsMain), + onTapCalendar: () => + context.push('${AppRoutes.calendarDayWeek}?from=home'), + onTapMessages: () => context.push(AppRoutes.messageInviteList), ); } @@ -218,9 +263,7 @@ class _HomeScreenState extends State final showWaitingIndicator = _isAgentWaiting(state); if (state.isLoadingHistory && state.items.isEmpty) { - return const Center( - child: AppLoadingIndicator(variant: AppLoadingVariant.surface), - ); + return const FullScreenLoading(); } return Padding( @@ -253,9 +296,9 @@ class _HomeScreenState extends State state.items.length + (state.hasEarlierHistory ? 1 : 0), itemBuilder: (context, index) { if (index == 0 && state.hasEarlierHistory) { - return _buildLoadMoreButton( - context, - state.isLoadingHistory, + return HomeLoadMoreButton( + isLoading: state.isLoadingHistory, + onTap: () => _onLoadMore(context), ); } @@ -275,12 +318,12 @@ class _HomeScreenState extends State crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showDateDivider) - _buildDateDivider(item.timestamp), + HomeDateDivider(date: item.timestamp), Padding( padding: const EdgeInsets.only( bottom: _itemSpacing, ), - child: _buildChatItem(item), + child: HomeChatItemRenderer.build(item), ), ], ); @@ -291,7 +334,9 @@ class _HomeScreenState extends State if (showWaitingIndicator) Align( alignment: Alignment.bottomLeft, - child: _buildWaitingIndicator(currentStage: state.currentStage), + child: HomeWaitingIndicator( + label: stageLabel(state.currentStage), + ), ), Align( alignment: Alignment.topCenter, @@ -303,35 +348,18 @@ class _HomeScreenState extends State ); } - Widget _buildWaitingIndicator({required AgentStage? currentStage}) { - final label = stageLabel(currentStage); - return Padding( - padding: const EdgeInsets.fromLTRB( - _defaultPadding, - 0, - _defaultPadding, - _defaultPadding, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - SizedBox( - width: _transcribingSpinnerSize, - height: _transcribingSpinnerSize, - child: const AppLoadingIndicator( - variant: AppLoadingVariant.inline, - size: _transcribingSpinnerSize, - strokeWidth: _transcribingStrokeWidth, - color: AppColors.blue600, - trackColor: AppColors.blue100, - ), - ), - SizedBox(width: AppSpacing.sm), - Text( - label, - style: TextStyle(fontSize: 14, color: AppColors.slate500), - ), - ], + Widget _buildUnreadBadge() { + return Positioned( + right: _defaultPadding, + bottom: _bottomStackReservedHeight + AppSpacing.md, + child: HomeUnreadBadge( + count: _chatUnreadBadgeCount, + onTap: () { + _scheduleAutoScroll(animated: true); + if (mounted) { + setState(() => _chatUnreadBadgeCount = 0); + } + }, ), ); } @@ -340,49 +368,6 @@ class _HomeScreenState extends State return a.year == b.year && a.month == b.month && a.day == b.day; } - Widget _buildDateDivider(DateTime date) { - final now = DateTime.now(); - final weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; - final weekday = weekdays[date.weekday - 1]; - - // For all dates (today/yesterday/this year), use the same format - // Only add year prefix for dates from previous years - final label = date.year == now.year - ? '${date.month}月${date.day}日 $weekday' - : '${date.year}年${date.month}月${date.day}日 $weekday'; - - return Container( - padding: const EdgeInsets.symmetric(vertical: 12), - alignment: Alignment.center, - child: Text( - label, - style: const TextStyle(fontSize: 12, color: AppColors.slate400), - ), - ); - } - - Widget _buildLoadMoreButton(BuildContext context, bool isLoading) { - return GestureDetector( - onTap: isLoading ? null : () => _onLoadMore(context), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - alignment: Alignment.center, - child: isLoading - ? const AppLoadingIndicator( - variant: AppLoadingVariant.inline, - size: 14, - strokeWidth: 1.5, - color: AppColors.slate400, - trackColor: AppColors.slate200, - ) - : const Text( - '查看历史', - style: TextStyle(fontSize: 12, color: AppColors.slate400), - ), - ), - ); - } - Future _onRefresh(BuildContext context) async { if (_isPullRefreshing) { return; @@ -402,6 +387,13 @@ class _HomeScreenState extends State } else { Toast.show(context, '没有更早的历史记录了', type: ToastType.info); } + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.sessionRefreshCompleted, + source: ViewportTriggerSource.user, + deltaCount: 0, + ), + ); } finally { final elapsed = DateTime.now().difference(startedAt); final minDuration = const Duration( @@ -426,6 +418,14 @@ class _HomeScreenState extends State return; } _captureHistoryViewportAnchor(); + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.historyPagePrependStarted, + source: ViewportTriggerSource.user, + deltaCount: 0, + hasAnchor: _historyViewportPixels != null, + ), + ); if (mounted) { setState(() { _isHistoryPaginationInFlight = true; @@ -434,7 +434,15 @@ class _HomeScreenState extends State try { await chatBloc.loadMoreHistory(); } finally { - _restoreHistoryViewportAnchor(); + final hasAnchor = _historyViewportPixels != null; + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.historyPagePrependFinished, + source: ViewportTriggerSource.system, + deltaCount: 0, + hasAnchor: hasAnchor, + ), + ); if (mounted) { setState(() { _isHistoryPaginationInFlight = false; @@ -479,9 +487,91 @@ class _HomeScreenState extends State return state.isWaitingFirstToken || state.isStreaming || state.isCancelling; } + void _handleScrollChanged() { + if (!_scrollController.hasClients) { + return; + } + _applyViewportDecision( + _dispatchViewportEvent( + type: ViewportEventType.userScrollStateChanged, + source: ViewportTriggerSource.user, + deltaCount: 0, + ), + ); + } + + ViewportDecision _dispatchViewportEvent({ + required ViewportEventType type, + required ViewportTriggerSource source, + required int deltaCount, + bool? isFirstEnter, + bool? hasAnchor, + }) { + return _viewportCoordinator.dispatch( + type: type, + source: source, + deltaCount: deltaCount, + distanceToBottomPx: _distanceToBottom(), + hasSavedViewport: _historyViewportPixels != null, + hasAnchor: hasAnchor ?? (_historyViewportPixels != null), + anchorOffsetPx: _historyViewportPixels, + isFirstEnter: isFirstEnter ?? false, + ); + } + + double _distanceToBottom() { + if (!_scrollController.hasClients) { + return 0; + } + final position = _scrollController.position; + final bottomInset = MediaQuery.viewInsetsOf(context).bottom; + return (position.maxScrollExtent - position.pixels - bottomInset) + .clamp(0, double.infinity) + .toDouble(); + } + + void _applyViewportDecision(ViewportDecision decision) { + switch (decision.action) { + case ViewportAction.jumpBottom: + _scheduleAutoScroll(animated: false); + if (mounted) { + setState(() => _chatUnreadBadgeCount = 0); + } + return; + case ViewportAction.animateBottom: + _scheduleAutoScroll(animated: true); + if (mounted) { + setState(() => _chatUnreadBadgeCount = 0); + } + return; + case ViewportAction.restoreAnchor: + _restoreHistoryViewportAnchor(); + return; + case ViewportAction.showUnreadBadge: + if (mounted) { + setState(() { + _chatUnreadBadgeCount = _viewportCoordinator.unreadCount; + }); + } + return; + case ViewportAction.none: + if (decision.reason == 'entered-at-bottom' && mounted) { + setState(() => _chatUnreadBadgeCount = 0); + } + return; + } + } + void _scheduleAutoScroll({required bool animated}) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) { + Future.delayed(const Duration(milliseconds: 16), () { + if (!_scrollController.hasClients) { + return; + } + final maxExtent = _scrollController.position.maxScrollExtent; + _scrollController.jumpTo(maxExtent); + }); return; } final maxExtent = _scrollController.position.maxScrollExtent; @@ -497,311 +587,28 @@ class _HomeScreenState extends State }); } - Widget _buildChatItem(ChatListItem item) { - switch (item.type) { - case ChatItemType.message: - return _buildMessageItem(item as TextMessageItem); - case ChatItemType.toolCall: - return _buildToolCallItem(item as ToolCallItem); - case ChatItemType.toolResult: - return _buildToolResultItem(item as ToolResultItem); - } - } - - Widget _buildMessageItem(TextMessageItem item) { - final isUser = item.sender == MessageSender.user; - final imageAttachments = _collectRenderableImageAttachments( - item.attachments, - ); - final hasRenderableAttachments = imageAttachments.isNotEmpty; - return Column( - crossAxisAlignment: isUser - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: isUser - ? MainAxisAlignment.end - : MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: _messagePaddingH, - vertical: _messagePaddingV, - ), - decoration: BoxDecoration( - color: isUser ? _userBubbleColor : AppColors.white, - borderRadius: BorderRadius.only( - topLeft: const Radius.circular(_cornerRadius), - topRight: const Radius.circular(_cornerRadius), - bottomLeft: Radius.circular(isUser ? _cornerRadius : 0), - bottomRight: Radius.circular(isUser ? 0 : _cornerRadius), - ), - border: isUser ? null : Border.all(color: AppColors.slate300), - ), - child: Text( - item.content, - style: const TextStyle( - fontSize: 14, - color: AppColors.slate900, - ), - ), - ), - ), - ], - ), - if (hasRenderableAttachments) - Padding( - padding: const EdgeInsets.only(top: _attachmentPreviewGap), - child: _buildHistoryAttachmentPreviews( - item.attachments, - imageAttachments: imageAttachments, - ), - ), - ], - ); - } - - Widget _buildHistoryAttachmentPreviews( - List> attachments, { - List>? imageAttachments, - }) { - final renderableAttachments = - imageAttachments ?? _collectRenderableImageAttachments(attachments); - if (renderableAttachments.isEmpty) { - return const SizedBox.shrink(); - } - return Wrap( - spacing: _attachmentPreviewGap, - runSpacing: _attachmentPreviewGap, - crossAxisAlignment: WrapCrossAlignment.start, - children: renderableAttachments.map(_buildHistoryAttachmentTile).toList(), - ); - } - - List> _collectRenderableImageAttachments( - List> attachments, - ) { - return attachments.where(_isRenderableImageAttachment).toList(); - } - - bool _isRenderableImageAttachment(Map 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/'); - } - - Widget _buildHistoryAttachmentTile(Map 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: _transcribingSpinnerSize, - strokeWidth: _transcribingStrokeWidth, - ), - ); - }, - 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: _transcribingSpinnerSize, - strokeWidth: _transcribingStrokeWidth, - color: AppColors.white, - trackColor: AppColors.slate200, - ), - ), - ), - ], - ), - ), - ); - } - - 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, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildToolResultItem(ToolResultItem item) { - final rootNode = item.uiSchema['root']; - final appearance = rootNode is Map - ? 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, - ), - ); - } - Widget _buildBottomInputStack(BuildContext context, ChatState state) { - return Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: KeyedSubtree( - key: homeBottomInputStackKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - HomeAttachmentStrip( - images: _selectedImages, - onRemove: _removeImage, - ), - if (_selectedImages.isNotEmpty) - const SizedBox(height: AppSpacing.sm), - _buildInputContainer(context, state), - ], - ), - ), - ), + final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); + return HomeComposerStack( + selectedImages: _selectedImages, + onRemoveImage: _removeImage, + isHoldToSpeakMode: _isHoldToSpeakMode, + isRecording: _isRecording, + isCancelGestureActive: _isCancelGestureActive, + isTranscribing: _isTranscribing, + isWaitingAgent: isWaitingAgent, + messageController: _messageController, + messageFocusNode: _messageFocusNode, + onTapPlus: _isRecording + ? () => _stopRecording(autoSendAfterTranscribe: false) + : () => _showBottomSheet(context), + onTapRightAction: () => _onRightActionTap(context, state), + onHoldToSpeakStart: _onHoldToSpeakStart, + onHoldToSpeakEnd: _onHoldToSpeakEnd, + onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, + onHoldToSpeakCancel: _onHoldToSpeakCancel, + onTextFieldTap: _onTextFieldTap, + onSubmit: () => _sendMessage(context), ); } @@ -811,94 +618,6 @@ class _HomeScreenState extends State }); } - Widget _buildInputContainer(BuildContext context, ChatState state) { - final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); - return ValueListenableBuilder( - valueListenable: _messageController, - builder: (context, value, child) { - final hasMessage = value.text.trim().isNotEmpty; - return Container( - padding: EdgeInsets.zero, - child: MessageComposer( - mode: _isHoldToSpeakMode - ? MessageComposerMode.holdToSpeak - : MessageComposerMode.text, - process: _composerProcess, - hasMessage: hasMessage, - isWaitingAgent: isWaitingAgent, - iconSize: _iconSize, - composerMinHeight: _inputMinHeight, - onTapPlus: _isRecording - ? () => _stopRecording(autoSendAfterTranscribe: false) - : () => _showBottomSheet(context), - onTapRightAction: () => _onRightActionTap(context, state), - onHoldToSpeakStart: _onHoldToSpeakStart, - onHoldToSpeakEnd: _onHoldToSpeakEnd, - onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, - onHoldToSpeakCancel: _onHoldToSpeakCancel, - textInputChild: _buildTextInputContent(context), - recordingAnimation: const SizedBox.shrink(), - recordingText: _isCancelGestureActive ? '松手取消' : '松手发送', - recordingHintText: _isCancelGestureActive ? '松开取消' : '松开发送,上滑取消', - showRecordingInlineFeedback: false, - ), - ); - }, - ); - } - - MessageComposerProcess get _composerProcess { - if (_isRecording) { - return MessageComposerProcess.recording; - } - if (_isTranscribing) { - return MessageComposerProcess.transcribing; - } - return MessageComposerProcess.idle; - } - - Widget _buildTextInputContent(BuildContext context) { - if (_isTranscribing) { - return _buildTranscribingIndicator(); - } - return SizedBox.expand( - child: Align( - alignment: Alignment.centerLeft, - child: TextField( - controller: _messageController, - 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: (_) => _sendMessage(context), - ), - ), - ); - } - void _onTextFieldTap() { final alreadyFocused = _messageFocusNode.hasFocus; if (!alreadyFocused) { @@ -982,332 +701,6 @@ class _HomeScreenState extends State }); } } - - void _onHoldToSpeakCancel() { - if (_isRecordingStarting) { - _shouldStopWhenStartCompletes = false; - _shouldCancelWhenStartCompletes = true; - return; - } - _cancelRecording(showToast: false); - } - - Future _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 _sendMessage(BuildContext context) async { - if (_isSendingMessage) { - return; - } - - final content = _messageController.text.trim(); - if (content.isEmpty && _selectedImages.isEmpty) return; - - final images = List.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 _onStopGenerating() async { - final canceled = await _chatBloc.cancelCurrentRun(); - if (!mounted) { - return; - } - if (canceled) { - Toast.show(context, '已停止等待回复', type: ToastType.info); - } - } - - Widget _buildWaveDots() { - return AnimatedBuilder( - animation: _listeningAnimationController, - builder: (context, _) { - final t = _listeningAnimationController.value; - final barCount = (AppSpacing.xxl * 2).toInt(); - final barColor = _isCancelGestureActive - ? AppColors.red500 - : AppColors.blue500; - - return SizedBox( - height: AppSpacing.lg, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: List.generate(barCount, (index) { - final phase = (index / barCount + t) % 1; - final active = (1 - ((phase - 0.5).abs() * 2)).clamp(0.0, 1.0); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: Container( - width: AppSpacing.xs / 2, - height: AppSpacing.sm + AppSpacing.xs * active, - decoration: BoxDecoration( - color: barColor.withValues(alpha: 0.35 + active * 0.65), - borderRadius: BorderRadius.circular(AppRadius.full), - ), - ), - ); - }), - ), - ); - }, - ); - } - - Widget _buildRecordingGestureOverlay() { - final isCancel = _isCancelGestureActive; - 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), - _buildWaveDots(), - ], - ), - ), - ), - ); - } - - Widget _buildTranscribingIndicator() { - return TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 180), - builder: (context, value, child) { - return Opacity(opacity: value, child: child); - }, - child: const SizedBox( - key: ValueKey('transcribing_indicator'), - height: _inputMinHeight, - child: Align( - alignment: Alignment.centerLeft, - child: Text( - '语音识别中...', - style: TextStyle(fontSize: 14, color: AppColors.slate500), - ), - ), - ), - ); - } - - Future _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 _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)); - } - }); - }, - ), - ); - } } class _HomeEmptyStateAmbient extends StatelessWidget { diff --git a/apps/lib/features/home/ui/screens/home_screen_interactions.dart b/apps/lib/features/home/ui/screens/home_screen_interactions.dart new file mode 100644 index 0000000..80bb01c --- /dev/null +++ b/apps/lib/features/home/ui/screens/home_screen_interactions.dart @@ -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 _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 _sendMessage(BuildContext context) async { + if (_isSendingMessage) { + return; + } + + final content = _messageController.text.trim(); + if (content.isEmpty && _selectedImages.isEmpty) return; + + final images = List.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 _onStopGenerating() async { + final canceled = await _chatBloc.cancelCurrentRun(); + if (!mounted) { + return; + } + if (canceled) { + Toast.show(context, '已停止等待回复', type: ToastType.info); + } + } + + Future _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 _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)); + } + }); + }, + ), + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart b/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart new file mode 100644 index 0000000..75a6599 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_chat_item_renderer.dart @@ -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> attachments, { + List>? 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> _collectRenderableImageAttachments( + List> attachments, + ) { + return attachments.where(_isRenderableImageAttachment).toList(); + } + + static bool _isRenderableImageAttachment(Map 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 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 + ? 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, + ), + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_composer_stack.dart b/apps/lib/features/home/ui/widgets/home_composer_stack.dart new file mode 100644 index 0000000..492b033 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_composer_stack.dart @@ -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 selectedImages; + final ValueChanged 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 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( + 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), + ), + ); + }), + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_conversation_chrome.dart b/apps/lib/features/home/ui/widgets/home_conversation_chrome.dart new file mode 100644 index 0000000..df2ca6f --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_conversation_chrome.dart @@ -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), + ), + ), + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_recording_overlay.dart b/apps/lib/features/home/ui/widgets/home_recording_overlay.dart new file mode 100644 index 0000000..b977bde --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_recording_overlay.dart @@ -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 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 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), + ), + ), + ); + }), + ), + ); + }, + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_unread_badge.dart b/apps/lib/features/home/ui/widgets/home_unread_badge.dart new file mode 100644 index 0000000..bbb2ea3 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_unread_badge.dart @@ -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, + ), + ), + ), + ); + } +} diff --git a/apps/test/features/home/ui/controllers/home_message_viewport_controller_test.dart b/apps/test/features/home/ui/controllers/home_message_viewport_controller_test.dart new file mode 100644 index 0000000..5f57187 --- /dev/null +++ b/apps/test/features/home/ui/controllers/home_message_viewport_controller_test.dart @@ -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); + }); +} diff --git a/apps/test/features/home/ui/navigation/home_return_policy_test.dart b/apps/test/features/home/ui/navigation/home_return_policy_test.dart new file mode 100644 index 0000000..7f06678 --- /dev/null +++ b/apps/test/features/home/ui/navigation/home_return_policy_test.dart @@ -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); + }); + }); +} diff --git a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart index ae386f9..637f8ff 100644 --- a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart +++ b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart @@ -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 buildMessages(int count) { + final base = DateTime(2026, 1, 1, 9, 0); + return List.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(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(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); + }); }