feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -0,0 +1,714 @@
|
||||
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 '../../../../core/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 '../../../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 '../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');
|
||||
|
||||
/// Color constants.
|
||||
const _chatBgColor = AppColors.slate50;
|
||||
|
||||
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 InboxApi _inboxApi;
|
||||
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();
|
||||
_inboxApi = sl<InboxApi>();
|
||||
_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<void> _loadUnreadCount() async {
|
||||
try {
|
||||
final messages = await _inboxApi.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<dynamic>) {
|
||||
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<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) {
|
||||
return Scaffold(
|
||||
backgroundColor: _chatBgColor,
|
||||
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: stageLabel(state.currentStage),
|
||||
),
|
||||
),
|
||||
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);
|
||||
} else {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.homeNoEarlierHistory,
|
||||
type: ToastType.info,
|
||||
);
|
||||
}
|
||||
_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;
|
||||
}
|
||||
|
||||
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 Center(
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
key: homeEmptyStateAmbientKey,
|
||||
width: double.infinity,
|
||||
height: AppSpacing.xxl * 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xxl),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.12),
|
||||
AppColors.homeBackgroundGlow.withValues(alpha: 0.08),
|
||||
AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user