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