710 lines
23 KiB
Dart
710 lines
23 KiB
Dart
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/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/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';
|
|
|
|
/// 布局常量
|
|
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');
|
|
|
|
/// 颜色常量
|
|
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(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, '没有更早的历史记录了', 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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|