import 'dart:io'; import 'package:flutter/material.dart'; 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:social_app/core/chat/agent_stage.dart'; import '../../../../data/network/api_exception.dart'; import '../../../../app/di/injection.dart'; import '../../../../app/router/app_route_observer.dart'; import '../../../../app/router/app_routes.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../features/messages/data/repositories/inbox_repository.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../data/voice_recorder.dart'; import '../controllers/home_keyboard_inset_calculator.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/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/full_screen_loading.dart'; import 'home_sheet.dart'; import '../widgets/home_background_field.dart'; import '../widgets/home_chat_item_renderer.dart'; import '../widgets/home_conversation_chrome.dart'; import '../widgets/home_floating_header.dart'; import '../widgets/home_input_host.dart'; import '../widgets/home_recording_overlay.dart'; import '../widgets/home_unread_badge.dart'; part 'home_screen_interactions.dart'; /// Layout constants. const _defaultPadding = 20.0; const _itemSpacing = 16.0; const _cancelThreshold = -(AppSpacing.xxl + AppSpacing.xxl); const _scrollDurationMs = 300; const _rippleDurationMs = 1200; const _bottomStackReservedHeight = 116.0; const _pullRefreshMinVisibleMs = 450; const _waitingIndicatorReservedHeight = 42.0; const homeConversationStageKey = ValueKey('home_conversation_stage'); const homeBottomInputStackKey = ValueKey('home_bottom_input_stack'); const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient'); class HomeScreen extends StatefulWidget { final VoiceRecorder? voiceRecorder; final Future Function(String filePath)? onTranscribeAudio; final ChatBloc? chatBloc; final bool autoLoadHistory; final List initialSelectedImages; const HomeScreen({ super.key, this.voiceRecorder, this.onTranscribeAudio, this.chatBloc, this.autoLoadHistory = true, this.initialSelectedImages = const [], }); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State with SingleTickerProviderStateMixin, RouteAware { final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); late final ChatBloc _chatBloc; late final VoiceRecorder _voiceRecorder; late final InboxRepository _inboxRepository; late final Future Function(String filePath) _transcribeAudio; late final AnimationController _listeningAnimationController; bool _isRecording = false; bool _isRecordingStarting = false; bool _isTranscribing = false; bool _isCancelGestureActive = false; bool _shouldCancelWhenStartCompletes = false; bool _shouldStopWhenStartCompletes = false; bool _isSendingMessage = false; bool _isPullRefreshing = false; bool _isHistoryPaginationInFlight = false; int _unreadCount = 0; int _chatUnreadBadgeCount = 0; final List _selectedImages = []; final HomeViewportCoordinator _viewportCoordinator = HomeViewportCoordinator( HomeMessageViewportController(), ); bool _initialHistoryHandled = false; int _previousItemCount = 0; bool _previousIsLoadingHistory = false; bool _routeAwareSubscribed = false; double? _historyViewportPixels; double? _historyViewportMaxExtent; final GlobalKey _inputHostKey = GlobalKey(); @override void initState() { super.initState(); final providedChatBloc = widget.chatBloc; if (providedChatBloc != null) { _chatBloc = providedChatBloc; } else { _chatBloc = context.read(); } _voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder(); _inboxRepository = sl(); _transcribeAudio = widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile; _listeningAnimationController = AnimationController( vsync: this, duration: const Duration(milliseconds: _rippleDurationMs), ); _selectedImages.addAll(widget.initialSelectedImages); if (widget.autoLoadHistory && _chatBloc.state.items.isEmpty) { _chatBloc.loadHistory(); } _scrollController.addListener(_handleScrollChanged); _previousItemCount = _chatBloc.state.items.length; _previousIsLoadingHistory = _chatBloc.state.isLoadingHistory; _loadUnreadCount(); } Future _loadUnreadCount() async { try { final messages = await _inboxRepository.getMessages(isRead: false); if (mounted) { setState(() => _unreadCount = messages.length); } } catch (_) { // Ignore errors } } @override void dispose() { _messageController.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( value: _chatBloc, child: BlocConsumer( listener: (context, state) { if (state.error != null) { Toast.show(context, state.error!, type: ToastType.error); } final loadingFinished = _previousIsLoadingHistory && !state.isLoadingHistory; if (loadingFinished && !_isHistoryPaginationInFlight && !_initialHistoryHandled && state.items.isNotEmpty) { _initialHistoryHandled = true; _applyViewportDecision( _dispatchViewportEvent( type: ViewportEventType.historyInitialLoaded, source: ViewportTriggerSource.system, deltaCount: 0, isFirstEnter: true, ), ); } 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) { final colorScheme = Theme.of(context).colorScheme; return Scaffold( backgroundColor: colorScheme.surface, resizeToAvoidBottomInset: false, body: SafeArea( maintainBottomViewPadding: true, child: Stack( children: [ Positioned.fill(child: HomeBackgroundField()), GestureDetector( behavior: HitTestBehavior.translucent, onTap: _dismissKeyboard, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context), Expanded(child: _buildChatArea(context, state)), ], ), ), if (_chatUnreadBadgeCount > 0) _buildUnreadBadge(), _buildBottomInputStack(context, state), if (_isRecording) HomeRecordingOverlay( isCancel: _isCancelGestureActive, listeningAnimation: _listeningAnimationController, ), ], ), ), ); }, ), ); } Widget _buildHeader(BuildContext context) { return HomeFloatingHeader( unreadCount: _unreadCount, onTapSettings: () => context.push(AppRoutes.settingsMain), onTapCalendar: () => context.push('${AppRoutes.calendarDayWeek}?from=home'), onTapMessages: () => context.push(AppRoutes.messageInviteList), ); } Widget _buildChatArea(BuildContext context, ChatState state) { final showWaitingIndicator = _isAgentWaiting(state); final inputBottomInset = _effectiveKeyboardInset(context); if (state.isLoadingHistory && state.items.isEmpty) { return const FullScreenLoading(); } return Padding( padding: EdgeInsets.fromLTRB( _defaultPadding, 0, _defaultPadding, _bottomStackReservedHeight + inputBottomInset, ), child: KeyedSubtree( key: homeConversationStageKey, child: Stack( children: [ if (state.items.isEmpty) const Positioned.fill(child: _HomeEmptyStateAmbient()) else Positioned.fill( child: RefreshIndicator.noSpinner( onRefresh: () => _onRefresh(context), child: ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: EdgeInsets.only( top: AppSpacing.sm, bottom: showWaitingIndicator ? _waitingIndicatorReservedHeight : AppSpacing.none, ), itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0), itemBuilder: (context, index) { if (index == 0 && state.hasEarlierHistory) { return HomeLoadMoreButton( isLoading: state.isLoadingHistory, onTap: () => _onLoadMore(context), ); } final itemIndex = state.hasEarlierHistory ? index - 1 : index; final item = state.items[itemIndex]; final showDateDivider = itemIndex == 0 || !_isSameDay( state.items[itemIndex - 1].timestamp, item.timestamp, ); return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (showDateDivider) HomeDateDivider(date: item.timestamp), Padding( padding: const EdgeInsets.only( bottom: _itemSpacing, ), child: HomeChatItemRenderer.build(context, item), ), ], ); }, ), ), ), if (showWaitingIndicator) Align( alignment: Alignment.bottomLeft, child: HomeWaitingIndicator(label: _agentWaitingLabel(state)), ), Align( alignment: Alignment.topCenter, child: AppPullRefreshFeedback(visible: _isPullRefreshing), ), ], ), ), ); } Widget _buildUnreadBadge() { final inputBottomInset = _effectiveKeyboardInset(context); return Positioned( right: _defaultPadding, bottom: _bottomStackReservedHeight + AppSpacing.md + inputBottomInset, child: HomeUnreadBadge( count: _chatUnreadBadgeCount, onTap: () { _scheduleAutoScroll(animated: true); if (mounted) { setState(() => _chatUnreadBadgeCount = 0); } }, ), ); } bool _isSameDay(DateTime a, DateTime b) { return a.year == b.year && a.month == b.month && a.day == b.day; } Future _onRefresh(BuildContext context) async { if (_isPullRefreshing) { return; } final chatBloc = context.read(); if (chatBloc.state.isLoadingHistory || _isHistoryPaginationInFlight) { return; } final hasEarlierHistory = chatBloc.state.hasEarlierHistory; if (mounted) { setState(() => _isPullRefreshing = true); } final startedAt = DateTime.now(); try { if (hasEarlierHistory) { await _loadMoreHistoryPreservingViewport(chatBloc); } _applyViewportDecision( _dispatchViewportEvent( type: ViewportEventType.sessionRefreshCompleted, source: ViewportTriggerSource.user, deltaCount: 0, ), ); } finally { final elapsed = DateTime.now().difference(startedAt); final minDuration = const Duration( milliseconds: _pullRefreshMinVisibleMs, ); if (elapsed < minDuration) { await Future.delayed(minDuration - elapsed); } if (mounted) { setState(() => _isPullRefreshing = false); } } } Future _onLoadMore(BuildContext context) async { final chatBloc = context.read(); await _loadMoreHistoryPreservingViewport(chatBloc); } Future _loadMoreHistoryPreservingViewport(ChatBloc chatBloc) async { if (_isHistoryPaginationInFlight) { return; } _captureHistoryViewportAnchor(); _applyViewportDecision( _dispatchViewportEvent( type: ViewportEventType.historyPagePrependStarted, source: ViewportTriggerSource.user, deltaCount: 0, hasAnchor: _historyViewportPixels != null, ), ); if (mounted) { setState(() { _isHistoryPaginationInFlight = true; }); } try { await chatBloc.loadMoreHistory(); } finally { final hasAnchor = _historyViewportPixels != null; _applyViewportDecision( _dispatchViewportEvent( type: ViewportEventType.historyPagePrependFinished, source: ViewportTriggerSource.system, deltaCount: 0, hasAnchor: hasAnchor, ), ); if (mounted) { setState(() { _isHistoryPaginationInFlight = false; }); } } } void _captureHistoryViewportAnchor() { if (!_scrollController.hasClients) { _historyViewportPixels = null; _historyViewportMaxExtent = null; return; } final position = _scrollController.position; _historyViewportPixels = position.pixels; _historyViewportMaxExtent = position.maxScrollExtent; } void _restoreHistoryViewportAnchor() { final previousPixels = _historyViewportPixels; final previousMaxExtent = _historyViewportMaxExtent; _historyViewportPixels = null; _historyViewportMaxExtent = null; if (previousPixels == null || previousMaxExtent == null) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients) { return; } final position = _scrollController.position; final extentDelta = position.maxScrollExtent - previousMaxExtent; final targetOffset = (previousPixels + extentDelta) .clamp(position.minScrollExtent, position.maxScrollExtent) .toDouble(); _scrollController.jumpTo(targetOffset); }); } bool _isAgentWaiting(ChatState state) { return state.isWaitingFirstToken || state.isStreaming || state.isCancelling; } String _agentWaitingLabel(ChatState state) { if (state.isWaitingFirstToken && !state.hasSeenStep) { return context.l10n.agentStageRequesting; } return stageLabel(state.currentStage); } 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 keyboardInset = _effectiveKeyboardInset(context); return (position.maxScrollExtent - position.pixels - keyboardInset) .clamp(0, double.infinity) .toDouble(); } double _effectiveKeyboardInset(BuildContext context) { final mediaQuery = MediaQuery.of(context); return HomeKeyboardInsetCalculator.compute( rawViewInsetBottom: mediaQuery.viewInsets.bottom, bottomViewPadding: mediaQuery.viewPadding.bottom, ); } void _dismissKeyboard() { _inputHostKey.currentState?.unfocusInput(); final focus = FocusManager.instance.primaryFocus; focus?.unfocus(); } 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; if (animated) { _scrollController.animateTo( maxExtent, duration: const Duration(milliseconds: _scrollDurationMs), curve: Curves.easeOut, ); return; } _scrollController.jumpTo(maxExtent); }); } Widget _buildBottomInputStack(BuildContext context, ChatState state) { final isWaitingAgent = _isSendingMessage || _isAgentWaiting(state); final inputBottomInset = _effectiveKeyboardInset(context); return HomeInputHost( key: _inputHostKey, selectedImages: _selectedImages, onRemoveImage: _removeImage, isRecording: _isRecording, isCancelGestureActive: _isCancelGestureActive, isTranscribing: _isTranscribing, isWaitingAgent: isWaitingAgent, messageController: _messageController, onTapPlus: _isRecording ? () => _stopRecording(autoSendAfterTranscribe: false) : () => _showBottomSheet(context), onStopGenerating: _onStopGenerating, onHoldToSpeakStart: _onHoldToSpeakStart, onHoldToSpeakEnd: _onHoldToSpeakEnd, onHoldToSpeakMoveUpdate: _onHoldToSpeakMoveUpdate, onHoldToSpeakCancel: _onHoldToSpeakCancel, onSubmitText: (text) => _sendMessage(context, overrideContent: text), keyboardInset: inputBottomInset, ); } void _removeImage(int index) { setState(() { _selectedImages.removeAt(index); }); } void _onHoldToSpeakStart() { HapticFeedback.selectionClick(); setState(() { _isCancelGestureActive = false; }); _startRecording(); } void _onHoldToSpeakEnd() { if (_isRecordingStarting) { _shouldCancelWhenStartCompletes = false; _shouldStopWhenStartCompletes = true; return; } if (!_isRecording) { return; } if (_isCancelGestureActive) { HapticFeedback.selectionClick(); _cancelRecording(showToast: false); return; } HapticFeedback.selectionClick(); _stopRecording(autoSendAfterTranscribe: true); } void _onHoldToSpeakMoveUpdate(LongPressMoveUpdateDetails details) { final willCancel = details.offsetFromOrigin.dy < _cancelThreshold; if (willCancel != _isCancelGestureActive && mounted) { HapticFeedback.selectionClick(); setState(() { _isCancelGestureActive = willCancel; }); } } } class _HomeEmptyStateAmbient extends StatelessWidget { const _HomeEmptyStateAmbient(); @override Widget build(BuildContext context) { return const SizedBox.shrink(key: homeEmptyStateAmbientKey); } }