feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持

This commit is contained in:
qzl
2026-03-27 14:05:03 +08:00
parent b1f0eb8921
commit c592cc7854
178 changed files with 10748 additions and 5764 deletions
@@ -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),
],
),
),
),
),
);
}
}
@@ -0,0 +1,229 @@
// ignore_for_file: invalid_use_of_protected_member
part of 'home_screen.dart';
extension _HomeScreenInteractions on _HomeScreenState {
void _onHoldToSpeakCancel() {
if (_isRecordingStarting) {
_shouldStopWhenStartCompletes = false;
_shouldCancelWhenStartCompletes = true;
return;
}
_cancelRecording(showToast: false);
}
Future<void> _cancelRecording({bool showToast = true}) async {
try {
await _voiceRecorder.stop();
_listeningAnimationController.stop();
} catch (_) {}
if (!mounted) return;
setState(() {
_isRecording = false;
_isCancelGestureActive = false;
});
if (showToast) {
Toast.show(
context,
context.l10n.homeRecordingCanceled,
type: ToastType.info,
);
}
}
Future<void> _sendMessage(
BuildContext context, {
String? overrideContent,
}) async {
if (_isSendingMessage) {
return;
}
final content = (overrideContent ?? _messageController.text).trim();
if (content.isEmpty && _selectedImages.isEmpty) return;
final images = List<XFile>.from(_selectedImages);
final currentFocus = FocusManager.instance.primaryFocus;
currentFocus?.unfocus();
_messageController.clear();
setState(() {
_isSendingMessage = true;
_selectedImages.clear();
});
try {
await _chatBloc.sendMessage(content, images: images);
} finally {
if (mounted) {
setState(() {
_isSendingMessage = false;
});
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: _scrollDurationMs),
curve: Curves.easeOut,
);
}
});
}
Future<void> _onStopGenerating() async {
final canceled = await _chatBloc.cancelCurrentRun();
if (!mounted) {
return;
}
if (canceled) {
Toast.show(context, context.l10n.homeStopRequested, type: ToastType.info);
}
}
Future<void> _startRecording() async {
if (_isRecording || _isRecordingStarting) {
return;
}
if (mounted) {
setState(() {
_isRecordingStarting = true;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
});
}
try {
await _voiceRecorder.start();
_listeningAnimationController.repeat();
if (!mounted) {
return;
}
if (_shouldStopWhenStartCompletes || _shouldCancelWhenStartCompletes) {
final shouldCancelAfterStart =
_shouldCancelWhenStartCompletes || _isCancelGestureActive;
setState(() {
_isRecordingStarting = false;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
_isRecording = true;
_isCancelGestureActive = false;
});
if (shouldCancelAfterStart) {
await _cancelRecording(showToast: false);
return;
}
await _stopRecording(autoSendAfterTranscribe: true);
return;
}
setState(() {
_isRecordingStarting = false;
_isRecording = true;
_isCancelGestureActive = false;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_isRecordingStarting = false;
_shouldCancelWhenStartCompletes = false;
_shouldStopWhenStartCompletes = false;
});
Toast.show(context, _readableError(error), type: ToastType.error);
}
}
Future<void> _stopRecording({bool autoSendAfterTranscribe = false}) async {
String? audioPath;
try {
audioPath = await _voiceRecorder.stop();
_listeningAnimationController.stop();
if (!mounted) {
return;
}
setState(() {
_isRecording = false;
_isTranscribing = true;
_isCancelGestureActive = false;
});
if (audioPath == null || audioPath.isEmpty) {
throw StateError(context.l10n.errorGenericSafe);
}
final transcript = await _transcribeAudio(audioPath);
if (!mounted) {
return;
}
final normalizedTranscript = transcript.trim();
if (normalizedTranscript.isEmpty) {
Toast.show(
context,
context.l10n.homeNoValidSpeech,
type: ToastType.error,
);
return;
}
_messageController.text = normalizedTranscript;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: normalizedTranscript.length),
);
if (autoSendAfterTranscribe) {
setState(() {
_isTranscribing = false;
});
await _sendMessage(context);
}
} catch (error) {
if (!mounted) {
return;
}
Toast.show(context, _readableError(error), type: ToastType.error);
} finally {
try {
if (audioPath != null) {
final file = File(audioPath);
if (await file.exists()) {
await file.delete();
}
}
} catch (_) {
// Ignore temp file cleanup errors to avoid blocking UI state recovery.
}
if (mounted) {
setState(() {
_isTranscribing = false;
});
}
}
}
String _readableError(Object error) {
if (error is ApiException) {
return error.message;
}
final raw = error.toString();
if (raw.startsWith('Instance of')) {
return context.l10n.errorGenericSafe;
}
return raw.replaceFirst('Bad state: ', '');
}
void _showBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) => HomeSheet(
onImagesSelected: (images) {
setState(() {
final remaining = 3 - _selectedImages.length;
if (remaining > 0) {
_selectedImages.addAll(images.take(remaining));
}
});
},
),
);
}
}
@@ -0,0 +1,134 @@
import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/theme/design_tokens.dart';
class HomeSheet extends StatelessWidget {
final Function(List<XFile>) onImagesSelected;
const HomeSheet({super.key, required this.onImagesSelected});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Container(
color: const Color(0x4D0F172A),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
GestureDetector(
onTap: () {},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: Column(
children: [
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: AppColors.slate300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
_buildSheetContent(context),
],
),
),
),
],
),
),
);
}
Widget _buildSheetContent(BuildContext context) {
return SizedBox(
height: 280,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildOptionCard(
context: context,
icon: LucideIcons.camera,
label: context.l10n.homeSheetTakePhoto,
onTap: () => _handleCameraTap(context),
),
const SizedBox(width: 24),
_buildOptionCard(
context: context,
icon: LucideIcons.image,
label: context.l10n.homeSheetPhotoLibrary,
onTap: () => _handlePhotoTap(context),
),
],
),
);
}
Widget _buildOptionCard({
required BuildContext context,
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.blue50,
borderRadius: BorderRadius.circular(16),
),
child: Icon(icon, size: 32, color: AppColors.blue500),
),
const SizedBox(height: 12),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
],
),
);
}
Future<void> _handleCameraTap(BuildContext context) async {
final picker = ImagePicker();
final image = await picker.pickImage(
source: ImageSource.camera,
imageQuality: 80,
);
if (image != null) {
onImagesSelected([image]);
}
if (context.mounted) {
Navigator.of(context).pop();
}
}
Future<void> _handlePhotoTap(BuildContext context) async {
final picker = ImagePicker();
final images = await picker.pickMultiImage(imageQuality: 80, limit: 3);
if (images.isNotEmpty) {
onImagesSelected(images);
}
if (context.mounted) {
Navigator.of(context).pop();
}
}
}