feat(apps): 重构 UI 架构为 presentation 层并新增 l10n 国际化支持
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
class HomeKeyboardInsetCalculator {
|
||||
static double compute({
|
||||
required double rawViewInsetBottom,
|
||||
required double bottomViewPadding,
|
||||
}) {
|
||||
if (rawViewInsetBottom <= AppSpacing.xs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
final adjustedInset = rawViewInsetBottom - bottomViewPadding;
|
||||
if (adjustedInset <= AppSpacing.xs) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return adjustedInset;
|
||||
}
|
||||
}
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
enum ViewportStatus { atBottom, readingHistory, restoringAnchor }
|
||||
|
||||
enum ViewportAction {
|
||||
none,
|
||||
jumpBottom,
|
||||
animateBottom,
|
||||
restoreAnchor,
|
||||
showUnreadBadge,
|
||||
}
|
||||
|
||||
enum ViewportEventType {
|
||||
historyInitialLoaded,
|
||||
historyPagePrependStarted,
|
||||
historyPagePrependFinished,
|
||||
newMessageAppended,
|
||||
screenResumedFromSubRoute,
|
||||
userScrollStateChanged,
|
||||
sessionRefreshCompleted,
|
||||
}
|
||||
|
||||
enum ViewportTriggerSource { user, system, route }
|
||||
|
||||
class ViewportAnchor {
|
||||
final String? messageId;
|
||||
final double? offsetPx;
|
||||
|
||||
const ViewportAnchor({required this.messageId, required this.offsetPx});
|
||||
}
|
||||
|
||||
class ViewportContext {
|
||||
final double distanceToBottomPx;
|
||||
final bool isFirstEnter;
|
||||
final bool hasSavedViewport;
|
||||
final bool hasAnchor;
|
||||
|
||||
const ViewportContext({
|
||||
required this.distanceToBottomPx,
|
||||
required this.isFirstEnter,
|
||||
required this.hasSavedViewport,
|
||||
required this.hasAnchor,
|
||||
});
|
||||
}
|
||||
|
||||
class ViewportEvent {
|
||||
final ViewportEventType type;
|
||||
final String conversationId;
|
||||
final int eventSeq;
|
||||
final ViewportTriggerSource triggerSource;
|
||||
final int deltaCount;
|
||||
final ViewportAnchor anchor;
|
||||
final int timestamp;
|
||||
final ViewportContext viewportContext;
|
||||
|
||||
const ViewportEvent({
|
||||
required this.type,
|
||||
required this.conversationId,
|
||||
required this.eventSeq,
|
||||
required this.triggerSource,
|
||||
required this.deltaCount,
|
||||
required this.anchor,
|
||||
required this.timestamp,
|
||||
required this.viewportContext,
|
||||
});
|
||||
}
|
||||
|
||||
class ViewportDecision {
|
||||
final ViewportAction action;
|
||||
final String reason;
|
||||
final Map<String, Object> debugMeta;
|
||||
|
||||
const ViewportDecision(this.action, this.reason, {this.debugMeta = const {}});
|
||||
}
|
||||
|
||||
class HomeMessageViewportController {
|
||||
static const double bottomThresholdPx = 96;
|
||||
|
||||
final Map<String, int> _lastAppliedSeqByConversation = <String, int>{};
|
||||
final Map<String, ViewportStatus> _statusByConversation =
|
||||
<String, ViewportStatus>{};
|
||||
final Map<String, int> _unreadByConversation = <String, int>{};
|
||||
|
||||
int get unreadCount => _unreadByConversation.values.fold(0, (a, b) => a + b);
|
||||
|
||||
ViewportStatus _statusOf(String conversationId) {
|
||||
return _statusByConversation[conversationId] ?? ViewportStatus.atBottom;
|
||||
}
|
||||
|
||||
void _setStatus(String conversationId, ViewportStatus status) {
|
||||
_statusByConversation[conversationId] = status;
|
||||
}
|
||||
|
||||
int _unreadOf(String conversationId) {
|
||||
return _unreadByConversation[conversationId] ?? 0;
|
||||
}
|
||||
|
||||
void _setUnread(String conversationId, int value) {
|
||||
if (value <= 0) {
|
||||
_unreadByConversation.remove(conversationId);
|
||||
return;
|
||||
}
|
||||
_unreadByConversation[conversationId] = value;
|
||||
}
|
||||
|
||||
ViewportDecision apply(ViewportEvent event) {
|
||||
final lastSeq = _lastAppliedSeqByConversation[event.conversationId] ?? -1;
|
||||
if (event.eventSeq <= lastSeq) {
|
||||
return const ViewportDecision(ViewportAction.none, 'stale-event');
|
||||
}
|
||||
|
||||
final debugMeta = <String, Object>{};
|
||||
final seqGap = event.eventSeq - lastSeq;
|
||||
if (lastSeq >= 0 && seqGap > 1) {
|
||||
debugMeta['seqGap'] = seqGap;
|
||||
}
|
||||
_lastAppliedSeqByConversation[event.conversationId] = event.eventSeq;
|
||||
final currentStatus = _statusOf(event.conversationId);
|
||||
|
||||
switch (event.type) {
|
||||
case ViewportEventType.historyInitialLoaded:
|
||||
if (event.viewportContext.isFirstEnter) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
_setUnread(event.conversationId, 0);
|
||||
return ViewportDecision(
|
||||
ViewportAction.jumpBottom,
|
||||
'initial-load-first-enter',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'initial-load-non-first-enter',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.userScrollStateChanged:
|
||||
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
_setUnread(event.conversationId, 0);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'entered-at-bottom',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
_setStatus(event.conversationId, ViewportStatus.readingHistory);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'entered-reading-history',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.historyPagePrependStarted:
|
||||
if (event.viewportContext.hasAnchor) {
|
||||
_setStatus(event.conversationId, ViewportStatus.restoringAnchor);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'prepend-start-capture-anchor',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'prepend-start-no-anchor',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.historyPagePrependFinished:
|
||||
if (currentStatus == ViewportStatus.restoringAnchor &&
|
||||
event.viewportContext.hasAnchor) {
|
||||
_setStatus(event.conversationId, ViewportStatus.readingHistory);
|
||||
return ViewportDecision(
|
||||
ViewportAction.restoreAnchor,
|
||||
'prepend-finish-restore-anchor',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
_setStatus(
|
||||
event.conversationId,
|
||||
event.viewportContext.distanceToBottomPx <= bottomThresholdPx
|
||||
? ViewportStatus.atBottom
|
||||
: ViewportStatus.readingHistory,
|
||||
);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'prepend-finish-no-restore',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.newMessageAppended:
|
||||
if (currentStatus == ViewportStatus.restoringAnchor) {
|
||||
_setUnread(
|
||||
event.conversationId,
|
||||
_unreadOf(event.conversationId) +
|
||||
(event.deltaCount > 0 ? event.deltaCount : 1),
|
||||
);
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'restoring-anchor-enqueue-unread',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
return ViewportDecision(
|
||||
ViewportAction.animateBottom,
|
||||
'new-message-follow-bottom',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
_setStatus(event.conversationId, ViewportStatus.readingHistory);
|
||||
_setUnread(
|
||||
event.conversationId,
|
||||
_unreadOf(event.conversationId) +
|
||||
(event.deltaCount > 0 ? event.deltaCount : 1),
|
||||
);
|
||||
return ViewportDecision(
|
||||
ViewportAction.showUnreadBadge,
|
||||
'new-message-keep-reading-history',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.screenResumedFromSubRoute:
|
||||
if (event.viewportContext.hasSavedViewport) {
|
||||
return ViewportDecision(
|
||||
ViewportAction.restoreAnchor,
|
||||
'resume-restore-saved-viewport',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'resume-no-saved-viewport',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
case ViewportEventType.sessionRefreshCompleted:
|
||||
if (event.viewportContext.distanceToBottomPx <= bottomThresholdPx) {
|
||||
_setStatus(event.conversationId, ViewportStatus.atBottom);
|
||||
_setUnread(event.conversationId, 0);
|
||||
return ViewportDecision(
|
||||
ViewportAction.animateBottom,
|
||||
'refresh-follow-bottom',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
return ViewportDecision(
|
||||
ViewportAction.none,
|
||||
'refresh-keep-position',
|
||||
debugMeta: debugMeta,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'home_message_viewport_controller.dart';
|
||||
|
||||
class HomeViewportCoordinator {
|
||||
HomeViewportCoordinator(this._controller);
|
||||
|
||||
final HomeMessageViewportController _controller;
|
||||
int _eventSeq = 0;
|
||||
|
||||
int get unreadCount => _controller.unreadCount;
|
||||
|
||||
ViewportDecision dispatch({
|
||||
required ViewportEventType type,
|
||||
required ViewportTriggerSource source,
|
||||
required int deltaCount,
|
||||
required double distanceToBottomPx,
|
||||
required bool hasSavedViewport,
|
||||
required bool hasAnchor,
|
||||
required double? anchorOffsetPx,
|
||||
bool isFirstEnter = false,
|
||||
}) {
|
||||
_eventSeq += 1;
|
||||
return _controller.apply(
|
||||
ViewportEvent(
|
||||
type: type,
|
||||
conversationId: 'home-main',
|
||||
eventSeq: _eventSeq,
|
||||
triggerSource: source,
|
||||
deltaCount: deltaCount,
|
||||
anchor: ViewportAnchor(messageId: null, offsetPx: anchorOffsetPx),
|
||||
timestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
viewportContext: ViewportContext(
|
||||
distanceToBottomPx: distanceToBottomPx,
|
||||
isFirstEnter: isFirstEnter,
|
||||
hasSavedViewport: hasSavedViewport,
|
||||
hasAnchor: hasAnchor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
|
||||
enum HomeReturnAction { pop, goHome, goHomeForDock }
|
||||
|
||||
HomeReturnAction resolveHomeReturnAction({
|
||||
required bool canPop,
|
||||
required bool isAuthEntry,
|
||||
bool forceGoHome = false,
|
||||
}) {
|
||||
if (forceGoHome) {
|
||||
return HomeReturnAction.goHome;
|
||||
}
|
||||
if (isAuthEntry) {
|
||||
return HomeReturnAction.goHome;
|
||||
}
|
||||
if (canPop) {
|
||||
return HomeReturnAction.goHomeForDock;
|
||||
}
|
||||
return HomeReturnAction.goHome;
|
||||
}
|
||||
|
||||
void returnToHomePreserveState(
|
||||
BuildContext context, {
|
||||
bool isAuthEntry = false,
|
||||
bool forceGoHome = false,
|
||||
}) {
|
||||
final action = resolveHomeReturnAction(
|
||||
canPop: context.canPop(),
|
||||
isAuthEntry: isAuthEntry,
|
||||
forceGoHome: forceGoHome,
|
||||
);
|
||||
switch (action) {
|
||||
case HomeReturnAction.pop:
|
||||
context.pop();
|
||||
return;
|
||||
case HomeReturnAction.goHome:
|
||||
context.go(AppRoutes.homeMain);
|
||||
return;
|
||||
case HomeReturnAction.goHomeForDock:
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
return;
|
||||
}
|
||||
context.go(AppRoutes.homeMain);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
const homeAttachmentStripKey = ValueKey('home_attachment_strip');
|
||||
|
||||
class HomeAttachmentStrip extends StatelessWidget {
|
||||
const HomeAttachmentStrip({
|
||||
super.key,
|
||||
required this.images,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final List<XFile> images;
|
||||
final ValueChanged<int> onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (images.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
key: homeAttachmentStripKey,
|
||||
padding: const EdgeInsets.all(AppSpacing.sm),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.homeAttachmentSurface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||||
border: Border.all(color: AppColors.homeComposerBorder),
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: AppSpacing.sm,
|
||||
runSpacing: AppSpacing.sm,
|
||||
children: images.asMap().entries.map((entry) {
|
||||
return _AttachmentPreviewTile(
|
||||
image: entry.value,
|
||||
onRemove: () => onRemove(entry.key),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AttachmentPreviewTile extends StatelessWidget {
|
||||
const _AttachmentPreviewTile({required this.image, required this.onRemove});
|
||||
|
||||
final XFile image;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const previewExtent = AppSpacing.xxl * 3 + AppSpacing.sm;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Image.file(
|
||||
File(image.path),
|
||||
width: previewExtent,
|
||||
height: previewExtent,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: previewExtent,
|
||||
height: previewExtent,
|
||||
color: AppColors.white,
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
LucideIcons.image,
|
||||
size: AppSpacing.xl,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: AppSpacing.xs,
|
||||
right: AppSpacing.xs,
|
||||
child: GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Container(
|
||||
width: AppSpacing.lg + AppSpacing.sm,
|
||||
height: AppSpacing.lg + AppSpacing.sm,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.red500,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.x,
|
||||
size: AppSpacing.md,
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
const homeBackgroundFieldKey = ValueKey('home_background_field');
|
||||
const homeTopGlowKey = ValueKey('home_top_glow');
|
||||
const homeBottomGlowKey = ValueKey('home_bottom_glow');
|
||||
|
||||
class HomeBackgroundField extends StatelessWidget {
|
||||
const HomeBackgroundField({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
key: homeBackgroundFieldKey,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [AppColors.homeBackgroundTop, AppColors.homeBackgroundBottom],
|
||||
),
|
||||
),
|
||||
child: const Stack(children: [_HomeTopGlow(), _HomeBottomGlow()]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeTopGlow extends StatelessWidget {
|
||||
const _HomeTopGlow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: const Alignment(-0.25, -0.9),
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
key: homeTopGlowKey,
|
||||
width: AppSpacing.xxl * 10,
|
||||
height: AppSpacing.xxl * 7,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppSpacing.xxl * 3),
|
||||
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.28),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.28),
|
||||
blurRadius: AppSpacing.xxl * 3,
|
||||
spreadRadius: AppSpacing.xl,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeBottomGlow extends StatelessWidget {
|
||||
const _HomeBottomGlow();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Transform.translate(
|
||||
offset: const Offset(0, AppSpacing.lg),
|
||||
child: Container(
|
||||
key: homeBottomGlowKey,
|
||||
width: AppSpacing.xxl * 12,
|
||||
height: AppSpacing.xxl * 3,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.18),
|
||||
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.1),
|
||||
blurRadius: AppSpacing.xxl,
|
||||
spreadRadius: AppSpacing.sm,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../core/utils/tool_name_localizer.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../chat/data/models/chat_list_item.dart';
|
||||
import '../../../ui_schema/presentation/widgets/ui_schema_renderer.dart';
|
||||
|
||||
const _messagePaddingH = 13.0;
|
||||
const _messagePaddingV = 9.0;
|
||||
const _cornerRadius = 12.0;
|
||||
const _attachmentPreviewSize = 88.0;
|
||||
const _attachmentPreviewRadius = 10.0;
|
||||
const _attachmentPreviewGap = 8.0;
|
||||
const _toolResultWidthFactor = 0.9;
|
||||
const _iconSize = 24.0;
|
||||
|
||||
class HomeChatItemRenderer {
|
||||
static Widget build(BuildContext context, ChatListItem item) {
|
||||
switch (item.type) {
|
||||
case ChatItemType.message:
|
||||
return _buildMessageItem(item as TextMessageItem);
|
||||
case ChatItemType.toolCall:
|
||||
return _buildToolCallItem(context, item as ToolCallItem);
|
||||
case ChatItemType.toolResult:
|
||||
return _buildToolResultItem(item as ToolResultItem);
|
||||
}
|
||||
}
|
||||
|
||||
static Widget _buildMessageItem(TextMessageItem item) {
|
||||
final isUser = item.sender == MessageSender.user;
|
||||
final imageAttachments = _collectRenderableImageAttachments(
|
||||
item.attachments,
|
||||
);
|
||||
final hasRenderableAttachments = imageAttachments.isNotEmpty;
|
||||
return Column(
|
||||
crossAxisAlignment: isUser
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: isUser
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _messagePaddingH,
|
||||
vertical: _messagePaddingV,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? AppColors.blue50 : AppColors.white,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(_cornerRadius),
|
||||
topRight: const Radius.circular(_cornerRadius),
|
||||
bottomLeft: Radius.circular(isUser ? _cornerRadius : 0),
|
||||
bottomRight: Radius.circular(isUser ? 0 : _cornerRadius),
|
||||
),
|
||||
border: isUser ? null : Border.all(color: AppColors.slate300),
|
||||
),
|
||||
child: Text(
|
||||
item.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (hasRenderableAttachments)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: _attachmentPreviewGap),
|
||||
child: _buildHistoryAttachmentPreviews(
|
||||
item.attachments,
|
||||
imageAttachments: imageAttachments,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildHistoryAttachmentPreviews(
|
||||
List<Map<String, dynamic>> attachments, {
|
||||
List<Map<String, dynamic>>? imageAttachments,
|
||||
}) {
|
||||
final renderableAttachments =
|
||||
imageAttachments ?? _collectRenderableImageAttachments(attachments);
|
||||
if (renderableAttachments.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Wrap(
|
||||
spacing: _attachmentPreviewGap,
|
||||
runSpacing: _attachmentPreviewGap,
|
||||
crossAxisAlignment: WrapCrossAlignment.start,
|
||||
children: renderableAttachments.map(_buildHistoryAttachmentTile).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>> _collectRenderableImageAttachments(
|
||||
List<Map<String, dynamic>> attachments,
|
||||
) {
|
||||
return attachments.where(_isRenderableImageAttachment).toList();
|
||||
}
|
||||
|
||||
static bool _isRenderableImageAttachment(Map<String, dynamic> attachment) {
|
||||
final path = attachment['path'];
|
||||
final url = attachment['url'];
|
||||
final mimeType = attachment['mimeType'];
|
||||
final hasRenderableSource =
|
||||
(url is String && url.isNotEmpty) ||
|
||||
(path is String && path.isNotEmpty);
|
||||
return hasRenderableSource &&
|
||||
mimeType is String &&
|
||||
mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
static Widget _buildHistoryAttachmentTile(Map<String, dynamic> attachment) {
|
||||
final path = attachment['path'];
|
||||
final url = attachment['url'];
|
||||
final isUploading = attachment['uploading'] == true;
|
||||
|
||||
final Widget image;
|
||||
if (url is String && url.isNotEmpty) {
|
||||
image = Image.network(
|
||||
url,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(
|
||||
child: AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: 18,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (path is String && path.isNotEmpty) {
|
||||
image = Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Icon(
|
||||
LucideIcons.imageOff,
|
||||
size: _iconSize,
|
||||
color: AppColors.slate500,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(_attachmentPreviewRadius),
|
||||
child: Container(
|
||||
width: _attachmentPreviewSize,
|
||||
height: _attachmentPreviewSize,
|
||||
color: AppColors.slate100,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
image,
|
||||
if (isUploading)
|
||||
ColoredBox(
|
||||
color: AppColors.slate900.withValues(alpha: 0.2),
|
||||
child: const Center(
|
||||
child: AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: 18,
|
||||
strokeWidth: 2,
|
||||
color: AppColors.white,
|
||||
trackColor: AppColors.slate200,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildToolCallItem(BuildContext context, ToolCallItem item) {
|
||||
final l10n = context.l10n;
|
||||
final (statusText, statusColor, statusIcon) = switch (item.status) {
|
||||
ToolCallStatus.pending => (
|
||||
l10n.homeToolPreparing,
|
||||
AppColors.slate500,
|
||||
LucideIcons.clock,
|
||||
),
|
||||
ToolCallStatus.executing => (
|
||||
l10n.homeToolExecuting,
|
||||
AppColors.blue600,
|
||||
LucideIcons.loader,
|
||||
),
|
||||
ToolCallStatus.error => (
|
||||
item.errorMessage ?? l10n.homeToolExecutionFailed,
|
||||
AppColors.red600,
|
||||
LucideIcons.alertCircle,
|
||||
),
|
||||
ToolCallStatus.completed => (
|
||||
l10n.homeToolCompleted,
|
||||
AppColors.emerald600,
|
||||
LucideIcons.checkCircle,
|
||||
),
|
||||
};
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceInfoLight,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.borderTertiary),
|
||||
),
|
||||
child: Icon(statusIcon, size: 14, color: statusColor),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
localizeToolName(item.toolName),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _buildToolResultItem(ToolResultItem item) {
|
||||
final rootNode = item.uiSchema['root'];
|
||||
final appearance = rootNode is Map<String, dynamic>
|
||||
? rootNode['appearance'] as String?
|
||||
: null;
|
||||
final needsOuterCard = appearance == null || appearance == 'plain';
|
||||
final schemaContent = UiSchemaRenderer.renderSchema(item.uiSchema);
|
||||
final wrappedContent = needsOuterCard
|
||||
? Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.white,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: AppColors.homeConversationBorder),
|
||||
),
|
||||
child: schemaContent,
|
||||
)
|
||||
: schemaContent;
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: _toolResultWidthFactor,
|
||||
child: wrappedContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/message_composer.dart';
|
||||
import 'home_attachment_strip.dart';
|
||||
|
||||
class HomeComposerStack extends StatelessWidget {
|
||||
const HomeComposerStack({
|
||||
super.key,
|
||||
required this.selectedImages,
|
||||
required this.onRemoveImage,
|
||||
required this.isHoldToSpeakMode,
|
||||
required this.isRecording,
|
||||
required this.isCancelGestureActive,
|
||||
required this.isTranscribing,
|
||||
required this.isWaitingAgent,
|
||||
required this.messageController,
|
||||
required this.messageFocusNode,
|
||||
required this.onTapPlus,
|
||||
required this.onTapRightAction,
|
||||
required this.onHoldToSpeakStart,
|
||||
required this.onHoldToSpeakEnd,
|
||||
required this.onHoldToSpeakMoveUpdate,
|
||||
required this.onHoldToSpeakCancel,
|
||||
required this.onSubmit,
|
||||
required this.keyboardInset,
|
||||
});
|
||||
|
||||
final List<XFile> selectedImages;
|
||||
final ValueChanged<int> onRemoveImage;
|
||||
final bool isHoldToSpeakMode;
|
||||
final bool isRecording;
|
||||
final bool isCancelGestureActive;
|
||||
final bool isTranscribing;
|
||||
final bool isWaitingAgent;
|
||||
final TextEditingController messageController;
|
||||
final FocusNode messageFocusNode;
|
||||
final VoidCallback onTapPlus;
|
||||
final VoidCallback onTapRightAction;
|
||||
final VoidCallback onHoldToSpeakStart;
|
||||
final VoidCallback onHoldToSpeakEnd;
|
||||
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
||||
final VoidCallback onHoldToSpeakCancel;
|
||||
final VoidCallback onSubmit;
|
||||
final double keyboardInset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final process = isRecording
|
||||
? MessageComposerProcess.recording
|
||||
: isTranscribing
|
||||
? MessageComposerProcess.transcribing
|
||||
: MessageComposerProcess.idle;
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.lg + keyboardInset,
|
||||
),
|
||||
child: KeyedSubtree(
|
||||
key: const ValueKey('home_bottom_input_stack'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
HomeAttachmentStrip(
|
||||
images: selectedImages,
|
||||
onRemove: onRemoveImage,
|
||||
),
|
||||
if (selectedImages.isNotEmpty)
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: messageController,
|
||||
builder: (context, value, child) {
|
||||
final hasMessage = value.text.trim().isNotEmpty;
|
||||
return MessageComposer(
|
||||
mode: isHoldToSpeakMode
|
||||
? MessageComposerMode.holdToSpeak
|
||||
: MessageComposerMode.text,
|
||||
process: process,
|
||||
hasMessage: hasMessage,
|
||||
isWaitingAgent: isWaitingAgent,
|
||||
iconSize: 24,
|
||||
composerMinHeight: AppSpacing.xxl + AppSpacing.lg,
|
||||
onTapPlus: onTapPlus,
|
||||
onTapRightAction: onTapRightAction,
|
||||
onHoldToSpeakStart: onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: onHoldToSpeakEnd,
|
||||
onHoldToSpeakMoveUpdate: onHoldToSpeakMoveUpdate,
|
||||
onHoldToSpeakCancel: onHoldToSpeakCancel,
|
||||
textInputChild: _buildTextInputContent(context),
|
||||
recordingAnimation: const SizedBox.shrink(),
|
||||
recordingText: isCancelGestureActive
|
||||
? context.l10n.homeRecordingReleaseCancel
|
||||
: context.l10n.homeRecordingReleaseSend,
|
||||
recordingHintText: isCancelGestureActive
|
||||
? context.l10n.homeRecordingHintReleaseCancel
|
||||
: context.l10n.homeRecordingHintReleaseSend,
|
||||
showRecordingInlineFeedback: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextInputContent(BuildContext context) {
|
||||
if (isTranscribing) {
|
||||
return _buildTranscribingIndicator(context);
|
||||
}
|
||||
return SizedBox.expand(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextField(
|
||||
controller: messageController,
|
||||
focusNode: messageFocusNode,
|
||||
minLines: 1,
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: AppSpacing.lg,
|
||||
height: 1,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.homeInputHint,
|
||||
hintStyle: TextStyle(
|
||||
fontSize: AppSpacing.lg,
|
||||
height: 1,
|
||||
color: AppColors.slate400,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
disabledBorder: InputBorder.none,
|
||||
errorBorder: InputBorder.none,
|
||||
focusedErrorBorder: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
filled: false,
|
||||
),
|
||||
onSubmitted: (_) => onSubmit(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTranscribingIndicator(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: const AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: 18,
|
||||
strokeWidth: 2,
|
||||
color: AppColors.blue600,
|
||||
trackColor: AppColors.blue100,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_buildWaveDots(),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.homeTranscribing,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.blue600,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWaveDots() {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(3, (index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 3),
|
||||
width: 3,
|
||||
height: 6 + index * 2,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue500,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
|
||||
class HomeWaitingIndicator extends StatelessWidget {
|
||||
const HomeWaitingIndicator({super.key, required this.label});
|
||||
|
||||
final String label;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: const AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: 18,
|
||||
strokeWidth: 2,
|
||||
color: AppColors.blue600,
|
||||
trackColor: AppColors.blue100,
|
||||
),
|
||||
),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.slate500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeDateDivider extends StatelessWidget {
|
||||
const HomeDateDivider({super.key, required this.date});
|
||||
|
||||
final DateTime date;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final weekday = weekdays[date.weekday - 1];
|
||||
final label = date.year == now.year
|
||||
? context.l10n.homeDateLabelNoYear(date.month, date.day, weekday)
|
||||
: context.l10n.homeDateLabelWithYear(
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
weekday,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.slate400),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomeLoadMoreButton extends StatelessWidget {
|
||||
const HomeLoadMoreButton({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final bool isLoading;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: isLoading ? null : onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
alignment: Alignment.center,
|
||||
child: isLoading
|
||||
? const AppLoadingIndicator(
|
||||
variant: AppLoadingVariant.inline,
|
||||
size: 14,
|
||||
strokeWidth: 1.5,
|
||||
color: AppColors.slate400,
|
||||
trackColor: AppColors.slate200,
|
||||
)
|
||||
: Text(
|
||||
context.l10n.homeViewHistory,
|
||||
style: const TextStyle(fontSize: 12, color: AppColors.slate400),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
const homeFloatingHeaderKey = ValueKey('home_floating_header');
|
||||
const homeFloatingHeaderTitleKey = ValueKey('home_floating_header_title');
|
||||
|
||||
class HomeFloatingHeader extends StatelessWidget {
|
||||
const HomeFloatingHeader({
|
||||
super.key,
|
||||
required this.unreadCount,
|
||||
required this.onTapSettings,
|
||||
required this.onTapCalendar,
|
||||
required this.onTapMessages,
|
||||
});
|
||||
|
||||
final int unreadCount;
|
||||
final VoidCallback onTapSettings;
|
||||
final VoidCallback onTapCalendar;
|
||||
final VoidCallback onTapMessages;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: homeFloatingHeaderKey,
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xs,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xs,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.homeToolbarSurface,
|
||||
border: Border(bottom: BorderSide(color: AppColors.homeToolbarBorder)),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_HeaderIconButton(
|
||||
icon: LucideIcons.settings,
|
||||
onPressed: onTapSettings,
|
||||
),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_HeaderIconButton(
|
||||
icon: LucideIcons.calendar,
|
||||
onPressed: onTapCalendar,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
_MessagesButton(
|
||||
unreadCount: unreadCount,
|
||||
onPressed: onTapMessages,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const IgnorePointer(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: AppSpacing.xl * 3),
|
||||
child: Text(
|
||||
'Linksy',
|
||||
key: homeFloatingHeaderTitleKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: AppSpacing.lg + (AppSpacing.xs / 2),
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.slate800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderIconButton extends StatelessWidget {
|
||||
const _HeaderIconButton({required this.icon, required this.onPressed});
|
||||
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: AppSpacing.xxl + AppSpacing.lg,
|
||||
minHeight: AppSpacing.xxl + AppSpacing.lg,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: AppSpacing.xxl, color: AppColors.slate900),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MessagesButton extends StatelessWidget {
|
||||
const _MessagesButton({required this.unreadCount, required this.onPressed});
|
||||
|
||||
final int unreadCount;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.all(AppSpacing.xs),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: AppSpacing.xxl + AppSpacing.lg,
|
||||
minHeight: AppSpacing.xxl + AppSpacing.lg,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.messageSquare,
|
||||
size: AppSpacing.xxl,
|
||||
color: AppColors.slate900,
|
||||
),
|
||||
if (unreadCount > 0)
|
||||
Positioned(
|
||||
right: -AppSpacing.xs,
|
||||
top: -AppSpacing.xs,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.xs,
|
||||
vertical: AppSpacing.xs / 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.red500,
|
||||
borderRadius: BorderRadius.circular(AppSpacing.sm),
|
||||
),
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: AppSpacing.lg,
|
||||
minHeight: AppSpacing.lg,
|
||||
),
|
||||
child: Text(
|
||||
unreadCount > 99 ? '99+' : unreadCount.toString(),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: AppSpacing.sm + (AppSpacing.xs / 2),
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import 'home_composer_stack.dart';
|
||||
|
||||
class HomeInputHost extends StatefulWidget {
|
||||
const HomeInputHost({
|
||||
super.key,
|
||||
required this.selectedImages,
|
||||
required this.onRemoveImage,
|
||||
required this.isRecording,
|
||||
required this.isCancelGestureActive,
|
||||
required this.isTranscribing,
|
||||
required this.isWaitingAgent,
|
||||
required this.messageController,
|
||||
required this.onTapPlus,
|
||||
required this.onStopGenerating,
|
||||
required this.onHoldToSpeakStart,
|
||||
required this.onHoldToSpeakEnd,
|
||||
required this.onHoldToSpeakMoveUpdate,
|
||||
required this.onHoldToSpeakCancel,
|
||||
required this.onSubmitText,
|
||||
required this.keyboardInset,
|
||||
});
|
||||
|
||||
final List<XFile> selectedImages;
|
||||
final ValueChanged<int> onRemoveImage;
|
||||
final bool isRecording;
|
||||
final bool isCancelGestureActive;
|
||||
final bool isTranscribing;
|
||||
final bool isWaitingAgent;
|
||||
final TextEditingController messageController;
|
||||
final VoidCallback onTapPlus;
|
||||
final VoidCallback onStopGenerating;
|
||||
final VoidCallback onHoldToSpeakStart;
|
||||
final VoidCallback onHoldToSpeakEnd;
|
||||
final ValueChanged<LongPressMoveUpdateDetails> onHoldToSpeakMoveUpdate;
|
||||
final VoidCallback onHoldToSpeakCancel;
|
||||
final Future<void> Function(String text) onSubmitText;
|
||||
final double keyboardInset;
|
||||
|
||||
@override
|
||||
State<HomeInputHost> createState() => HomeInputHostState();
|
||||
}
|
||||
|
||||
class HomeInputHostState extends State<HomeInputHost> {
|
||||
final FocusNode _messageFocusNode = FocusNode();
|
||||
Timer? _keyboardShowFallbackTimer;
|
||||
bool _isHoldToSpeakMode = true;
|
||||
|
||||
void unfocusInput() {
|
||||
_messageFocusNode.unfocus();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_messageFocusNode.addListener(_handleMessageFocusChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_keyboardShowFallbackTimer?.cancel();
|
||||
_messageFocusNode.removeListener(_handleMessageFocusChanged);
|
||||
_messageFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HomeComposerStack(
|
||||
selectedImages: widget.selectedImages,
|
||||
onRemoveImage: widget.onRemoveImage,
|
||||
isHoldToSpeakMode: _isHoldToSpeakMode,
|
||||
isRecording: widget.isRecording,
|
||||
isCancelGestureActive: widget.isCancelGestureActive,
|
||||
isTranscribing: widget.isTranscribing,
|
||||
isWaitingAgent: widget.isWaitingAgent,
|
||||
messageController: widget.messageController,
|
||||
messageFocusNode: _messageFocusNode,
|
||||
onTapPlus: widget.onTapPlus,
|
||||
onTapRightAction: _onRightActionTap,
|
||||
onHoldToSpeakStart: widget.onHoldToSpeakStart,
|
||||
onHoldToSpeakEnd: widget.onHoldToSpeakEnd,
|
||||
onHoldToSpeakMoveUpdate: widget.onHoldToSpeakMoveUpdate,
|
||||
onHoldToSpeakCancel: widget.onHoldToSpeakCancel,
|
||||
onSubmit: _onSubmit,
|
||||
keyboardInset: widget.keyboardInset,
|
||||
);
|
||||
}
|
||||
|
||||
void _onRightActionTap() {
|
||||
if (widget.isTranscribing || widget.isRecording) {
|
||||
return;
|
||||
}
|
||||
if (widget.isWaitingAgent) {
|
||||
widget.onStopGenerating();
|
||||
return;
|
||||
}
|
||||
final draft = widget.messageController.text.trim();
|
||||
if (draft.isNotEmpty) {
|
||||
_onSubmit();
|
||||
return;
|
||||
}
|
||||
_toggleInputMode();
|
||||
}
|
||||
|
||||
void _toggleInputMode() {
|
||||
if (widget.isRecording || widget.isTranscribing) {
|
||||
return;
|
||||
}
|
||||
final switchToText = _isHoldToSpeakMode;
|
||||
setState(() {
|
||||
_isHoldToSpeakMode = !_isHoldToSpeakMode;
|
||||
});
|
||||
if (!switchToText) {
|
||||
_messageFocusNode.unfocus();
|
||||
_keyboardShowFallbackTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || _isHoldToSpeakMode) {
|
||||
return;
|
||||
}
|
||||
_messageFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _handleMessageFocusChanged() {
|
||||
if (!_messageFocusNode.hasFocus || _isHoldToSpeakMode) {
|
||||
_keyboardShowFallbackTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
_scheduleKeyboardShowFallback();
|
||||
}
|
||||
|
||||
void _scheduleKeyboardShowFallback() {
|
||||
if (!_supportsProgrammaticKeyboardShow() || _isKeyboardVisible()) {
|
||||
return;
|
||||
}
|
||||
_keyboardShowFallbackTimer?.cancel();
|
||||
_keyboardShowFallbackTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
if (!mounted || !_messageFocusNode.hasFocus || _isHoldToSpeakMode) {
|
||||
return;
|
||||
}
|
||||
if (_isKeyboardVisible()) {
|
||||
return;
|
||||
}
|
||||
SystemChannels.textInput.invokeMethod<void>('TextInput.show');
|
||||
});
|
||||
}
|
||||
|
||||
bool _supportsProgrammaticKeyboardShow() {
|
||||
if (kIsWeb) {
|
||||
return false;
|
||||
}
|
||||
return defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
}
|
||||
|
||||
bool _isKeyboardVisible() {
|
||||
final mediaQuery = MediaQuery.maybeOf(context);
|
||||
if (mediaQuery == null) {
|
||||
return false;
|
||||
}
|
||||
return mediaQuery.viewInsets.bottom > 0;
|
||||
}
|
||||
|
||||
void _onSubmit() {
|
||||
final draft = widget.messageController.text.trim();
|
||||
if (draft.isEmpty) {
|
||||
return;
|
||||
}
|
||||
widget.onSubmitText(draft);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
|
||||
const _recordingCancelTopColor = AppColors.warningBackground;
|
||||
const _recordingCancelBottomColor = AppColors.red400;
|
||||
const _recordingCancelLabelColor = AppColors.red600;
|
||||
const _recordingActiveTopColor = AppColors.blue50;
|
||||
const _recordingActiveBottomColor = AppColors.blue400;
|
||||
const _recordingActiveLabelColor = AppColors.white;
|
||||
|
||||
class HomeRecordingOverlay extends StatelessWidget {
|
||||
const HomeRecordingOverlay({
|
||||
super.key,
|
||||
required this.isCancel,
|
||||
required this.listeningAnimation,
|
||||
});
|
||||
|
||||
final bool isCancel;
|
||||
final Animation<double> listeningAnimation;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final topColor = isCancel
|
||||
? _recordingCancelTopColor
|
||||
: _recordingActiveTopColor;
|
||||
final bottomColor = isCancel
|
||||
? _recordingCancelBottomColor
|
||||
: _recordingActiveBottomColor;
|
||||
final labelColor = isCancel
|
||||
? _recordingCancelLabelColor
|
||||
: _recordingActiveLabelColor;
|
||||
final label = isCancel
|
||||
? context.l10n.homeRecordingReleaseCancel
|
||||
: context.l10n.homeRecordingHintReleaseSend;
|
||||
|
||||
return IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: const BoxConstraints(minHeight: AppSpacing.xxl * 7),
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.xxl,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(AppRadius.xxl),
|
||||
topRight: Radius.circular(AppRadius.xxl),
|
||||
),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [topColor, bottomColor],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: AppSpacing.xl,
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_WaveDots(
|
||||
listeningAnimation: listeningAnimation,
|
||||
barColor: isCancel ? AppColors.red500 : AppColors.blue500,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WaveDots extends StatelessWidget {
|
||||
const _WaveDots({required this.listeningAnimation, required this.barColor});
|
||||
|
||||
final Animation<double> listeningAnimation;
|
||||
final Color barColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: listeningAnimation,
|
||||
builder: (context, _) {
|
||||
final t = listeningAnimation.value;
|
||||
final barCount = (AppSpacing.xxl * 2).toInt();
|
||||
return SizedBox(
|
||||
height: AppSpacing.lg,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: List.generate(barCount, (index) {
|
||||
final phase = (index / barCount + t) % 1;
|
||||
final active = (1 - ((phase - 0.5).abs() * 2)).clamp(0.0, 1.0);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Container(
|
||||
width: AppSpacing.xs / 2,
|
||||
height: AppSpacing.sm + AppSpacing.xs * active,
|
||||
decoration: BoxDecoration(
|
||||
color: barColor.withValues(alpha: 0.35 + active * 0.65),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_pressable.dart';
|
||||
|
||||
class HomeUnreadBadge extends StatelessWidget {
|
||||
const HomeUnreadBadge({super.key, required this.count, required this.onTap});
|
||||
|
||||
final int count;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppPressable(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.blue600,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.slate900.withValues(alpha: 0.18),
|
||||
blurRadius: AppRadius.md,
|
||||
offset: const Offset(0, AppSpacing.xs),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.homeUnreadMessages(count),
|
||||
style: const TextStyle(
|
||||
color: AppColors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user