Files
social-app/apps/lib/features/home/presentation/screens/home_screen.dart
T

697 lines
22 KiB
Dart

import 'dart:async';
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 '../../../../core/inbox/inbox_sync_store.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<String> Function(String filePath)? onTranscribeAudio;
final ChatBloc? chatBloc;
final bool autoLoadHistory;
final List<XFile> initialSelectedImages;
const HomeScreen({
super.key,
this.voiceRecorder,
this.onTranscribeAudio,
this.chatBloc,
this.autoLoadHistory = true,
this.initialSelectedImages = const [],
});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc;
late final VoiceRecorder _voiceRecorder;
late final InboxSyncStore _inboxSyncStore;
late final Future<String> 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<XFile> _selectedImages = [];
final HomeViewportCoordinator _viewportCoordinator = HomeViewportCoordinator(
HomeMessageViewportController(),
);
bool _initialHistoryHandled = false;
int _previousItemCount = 0;
bool _previousIsLoadingHistory = false;
bool _routeAwareSubscribed = false;
double? _historyViewportPixels;
double? _historyViewportMaxExtent;
final GlobalKey<HomeInputHostState> _inputHostKey =
GlobalKey<HomeInputHostState>();
@override
void initState() {
super.initState();
final providedChatBloc = widget.chatBloc;
if (providedChatBloc != null) {
_chatBloc = providedChatBloc;
} else {
_chatBloc = context.read<ChatBloc>();
}
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_inboxSyncStore = sl<InboxSyncStore>();
_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.state.isLoadingHistory) {
_chatBloc.loadHistory();
}
_scrollController.addListener(_handleScrollChanged);
_previousItemCount = _chatBloc.state.items.length;
_previousIsLoadingHistory = _chatBloc.state.isLoadingHistory;
_inboxSyncStore.addListener(_handleInboxStateChanged);
unawaited(_inboxSyncStore.ensureStarted());
_handleInboxStateChanged();
}
void _handleInboxStateChanged() {
if (!mounted) {
return;
}
setState(() {
_unreadCount = _inboxSyncStore.unreadCount;
});
}
@override
void dispose() {
_messageController.dispose();
_scrollController.removeListener(_handleScrollChanged);
_scrollController.dispose();
_listeningAnimationController.dispose();
_voiceRecorder.dispose();
_inboxSyncStore.removeListener(_handleInboxStateChanged);
if (_routeAwareSubscribed) {
appRouteObserver.unsubscribe(this);
_routeAwareSubscribed = false;
}
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final route = ModalRoute.of(context);
if (!_routeAwareSubscribed && route is PageRoute<dynamic>) {
appRouteObserver.subscribe(this, route);
_routeAwareSubscribed = true;
}
}
@override
void didPopNext() {
unawaited(_inboxSyncStore.refreshSnapshot());
_applyViewportDecision(
_dispatchViewportEvent(
type: ViewportEventType.screenResumedFromSubRoute,
source: ViewportTriggerSource.route,
deltaCount: 0,
),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _chatBloc,
child: BlocConsumer<ChatBloc, ChatState>(
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<void> _onRefresh(BuildContext context) async {
if (_isPullRefreshing) {
return;
}
final chatBloc = context.read<ChatBloc>();
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<void> _onLoadMore(BuildContext context) async {
final chatBloc = context.read<ChatBloc>();
await _loadMoreHistoryPreservingViewport(chatBloc);
}
Future<void> _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<void>.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);
}
}