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,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;
}
}
@@ -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,
),
),
),
);
}
}