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