import 'dart:async'; import 'dart:typed_data'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:social_app/core/chat/chat_api.dart'; import 'package:social_app/core/logging/logger.dart'; import 'package:social_app/core/chat/agent_stage.dart'; import 'package:social_app/core/chat/ag_ui_event.dart'; import 'package:social_app/core/chat/ag_ui_service.dart'; import 'package:social_app/core/chat/chat_list_item.dart'; import 'package:social_app/core/chat/chat_orchestrator.dart'; import 'package:social_app/core/chat/chat_history_repository.dart'; import 'package:social_app/core/chat/chat_timeline_reconciler.dart'; import 'package:social_app/core/l10n/l10n.dart'; import 'chat_bloc_recovery_utils.dart'; part 'chat_bloc_events.dart'; part 'chat_bloc_send.dart'; part 'chat_bloc_history.dart'; part 'chat_bloc_attachments.dart'; class ChatState implements ChatOrchestratorState { @override final List items; @override final bool isSending; @override final bool isWaitingFirstToken; @override final bool isStreaming; @override final bool isCancelling; @override final bool isLoadingHistory; @override final String? currentMessageId; @override final String? error; @override final DateTime? oldestLoadedDate; @override final bool hasEarlierHistory; @override final AgentStage? currentStage; final bool hasSeenStep; const ChatState({ this.items = const [], this.isSending = false, this.isWaitingFirstToken = false, this.isStreaming = false, this.isCancelling = false, this.isLoadingHistory = false, this.currentMessageId, this.error, this.oldestLoadedDate, this.hasEarlierHistory = false, this.currentStage, this.hasSeenStep = false, }); @override bool get isLoading => isSending || isWaitingFirstToken || isStreaming || isCancelling || isLoadingHistory; static const _unset = Object(); ChatState copyWith({ List? items, bool? isSending, bool? isWaitingFirstToken, bool? isStreaming, bool? isCancelling, bool? isLoadingHistory, Object? currentMessageId = _unset, Object? error = _unset, Object? oldestLoadedDate = _unset, bool? hasEarlierHistory, Object? currentStage = _unset, bool? hasSeenStep, }) { return ChatState( items: items ?? this.items, isSending: isSending ?? this.isSending, isWaitingFirstToken: isWaitingFirstToken ?? this.isWaitingFirstToken, isStreaming: isStreaming ?? this.isStreaming, isCancelling: isCancelling ?? this.isCancelling, isLoadingHistory: isLoadingHistory ?? this.isLoadingHistory, currentMessageId: currentMessageId == _unset ? this.currentMessageId : currentMessageId as String?, error: error == _unset ? this.error : error as String?, oldestLoadedDate: oldestLoadedDate == _unset ? this.oldestLoadedDate : oldestLoadedDate as DateTime?, hasEarlierHistory: hasEarlierHistory ?? this.hasEarlierHistory, currentStage: currentStage == _unset ? this.currentStage : currentStage as AgentStage?, hasSeenStep: hasSeenStep ?? this.hasSeenStep, ); } } class ChatBloc extends Cubit implements ChatOrchestrator { final Logger _logger = getLogger('features.chat.bloc'); ChatBloc({ AgUiService? service, required ChatApi chatApi, ChatHistoryRepository? historyRepository, Future Function()? onCalendarMutated, Duration recoveryPollInterval = const Duration(milliseconds: 700), Duration recoveryTimeout = const Duration(seconds: 20), }) : _service = service ?? AgUiService(chatApi: chatApi, historyRepository: historyRepository), _onCalendarMutated = onCalendarMutated, _recoveryPollInterval = recoveryPollInterval, _recoveryTimeout = recoveryTimeout, super(const ChatState()) { _service.onEvent = _handleEvent; } final AgUiService _service; final Future Function()? _onCalendarMutated; final Duration _recoveryPollInterval; final Duration _recoveryTimeout; String? _activeUserId; int _sessionEpoch = 0; final Map _attachmentPreviewCache = {}; final Map> _attachmentPreviewInflight = >{}; /// Common state reset for run completion (success/error/cancel) ChatState _resetRunState({String? error, String? currentMessageId}) { return state.copyWith( isSending: false, isWaitingFirstToken: false, isStreaming: false, isCancelling: false, currentMessageId: currentMessageId, error: error, currentStage: null, hasSeenStep: false, ); } @override Future sendMessage(String content, {List? images}) { return _sendMessage(content, images: images); } @override Future loadHistory() { return _loadHistory(); } @override Future loadMoreHistory() { return _loadMoreHistory(); } @override Future transcribeAudioFile(String filePath) { return _service.transcribeAudio(filePath); } @override Future cancelCurrentRun() async { if (!(state.isWaitingFirstToken || state.isStreaming || state.isCancelling)) { return false; } emit(state.copyWith(isCancelling: true, error: null)); try { await _service.cancelCurrentRun(); emit( state.copyWith( isSending: false, isWaitingFirstToken: false, isStreaming: false, isCancelling: false, currentMessageId: null, ), ); return true; } catch (error) { emit(state.copyWith(isCancelling: false, error: error.toString())); return false; } } @override void clearError() { emit(state.copyWith(error: null)); } Future loadAttachmentPreview(String previewPath) { return _loadAttachmentPreview(previewPath); } Future switchUser(String? userId) async { final normalizedUserId = userId?.trim(); if (_activeUserId == normalizedUserId) { return; } final epoch = ++_sessionEpoch; _activeUserId = normalizedUserId; await _service.setUserContext(normalizedUserId); if (epoch != _sessionEpoch) { return; } _attachmentPreviewCache.clear(); _attachmentPreviewInflight.clear(); emit(const ChatState()); if (normalizedUserId != null && epoch == _sessionEpoch) { try { await _loadHistory(); } catch (error) { emit(state.copyWith(error: error.toString())); } } } bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) { final name = event.toolName.trim().toLowerCase(); final status = event.status.trim().toLowerCase(); if (name != 'calendar_write') { return false; } return status == 'success' || status == 'partial'; } Future _refreshCalendarAfterToolMutation() async { final callback = _onCalendarMutated; if (callback == null) { return; } try { await callback(); } catch (error) { emit(state.copyWith(error: error.toString())); } } }