diff --git a/apps/lib/core/theme/design_tokens.dart b/apps/lib/core/theme/design_tokens.dart index 2241315..925a29a 100644 --- a/apps/lib/core/theme/design_tokens.dart +++ b/apps/lib/core/theme/design_tokens.dart @@ -105,6 +105,20 @@ class AppColors { static const authLinkText = Color(0xFF356CC8); static const authLinkMuted = Color(0xFF70839E); + static const homeBackgroundTop = Color(0xFFF5F9FF); + static const homeBackgroundBottom = Color(0xFFF7FAFE); + static const homeBackgroundGlow = Color(0xFFDCEBFF); + static const homeBackgroundGlowSoft = Color(0xFFF1F6FF); + static const homeToolbarSurface = Color(0xF2FFFFFF); + static const homeToolbarBorder = Color(0xFFD9E6F7); + static const homeConversationSurface = Color(0xBFFFFFFF); + static const homeConversationBorder = Color(0xFFDDE8F6); + static const homeComposerShell = Color(0xFDFCFEFF); + static const homeComposerInner = Color(0xFFF7FAFE); + static const homeComposerBorder = Color(0xFFD7E3F3); + static const homeComposerAccent = Color(0xFFEAF3FF); + static const homeAttachmentSurface = Color(0xFFF3F7FD); + static const feedbackInfoSurface = Color(0xFFF3F8FF); static const feedbackInfoBorder = Color(0xFFD6E5FB); static const feedbackInfoIcon = Color(0xFF2D6CDF); diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 446962f..8598d8d 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -19,12 +19,13 @@ import '../../../../shared/widgets/message_composer.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; import 'home_sheet.dart'; +import '../widgets/home_attachment_strip.dart'; +import '../widgets/home_background_field.dart'; +import '../widgets/home_floating_header.dart'; /// 布局常量 -const _headerHeight = 60.0; const _defaultPadding = 20.0; const _itemSpacing = 16.0; -const _inputPadding = 16.0; const _iconSize = 24.0; const _messagePaddingH = 13.0; const _messagePaddingV = 9.0; @@ -38,6 +39,11 @@ const _transcribingStrokeWidth = 2.0; const _attachmentPreviewSize = 88.0; const _attachmentPreviewRadius = 10.0; const _attachmentPreviewGap = 8.0; +const _bottomStackReservedHeight = 140.0; + +const homeConversationStageKey = ValueKey('home_conversation_stage'); +const homeBottomInputStackKey = ValueKey('home_bottom_input_stack'); +const homeEmptyStateAmbientKey = ValueKey('home_empty_state_ambient'); /// 颜色常量 const _chatBgColor = AppColors.slate50; @@ -49,6 +55,7 @@ class HomeScreen extends StatefulWidget { final Future Function(String transcript)? onAutoSendTranscript; final ChatBloc? chatBloc; final bool autoLoadHistory; + final List initialSelectedImages; const HomeScreen({ super.key, @@ -57,6 +64,7 @@ class HomeScreen extends StatefulWidget { this.onAutoSendTranscript, this.chatBloc, this.autoLoadHistory = true, + this.initialSelectedImages = const [], }); @override @@ -96,6 +104,7 @@ class _HomeScreenState extends State vsync: this, duration: const Duration(milliseconds: _rippleDurationMs), ); + _selectedImages.addAll(widget.initialSelectedImages); if (widget.autoLoadHistory) { _chatBloc.loadHistory(); } @@ -158,14 +167,15 @@ class _HomeScreenState extends State body: SafeArea( child: Stack( children: [ + const Positioned.fill(child: HomeBackgroundField()), Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildHeader(context), Expanded(child: _buildChatArea(context, state)), - _buildImagePreview(), - _buildInputContainer(context, state), ], ), + _buildBottomInputStack(context, state), if (_isRecording) _buildRecordingGestureOverlay(), ], ), @@ -177,80 +187,11 @@ class _HomeScreenState extends State } Widget _buildHeader(BuildContext context) { - return SizedBox( - height: _headerHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: _defaultPadding), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon( - LucideIcons.settings, - size: _iconSize, - color: AppColors.slate900, - ), - onPressed: () => context.push('/settings'), - ), - Row( - children: [ - IconButton( - icon: const Icon( - LucideIcons.calendar, - size: _iconSize, - color: AppColors.slate900, - ), - onPressed: () => context.push('/calendar/dayweek?from=home'), - ), - const SizedBox(width: _itemSpacing), - IconButton( - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon( - LucideIcons.messageSquare, - size: _iconSize, - color: AppColors.slate900, - ), - if (_unreadCount > 0) - Positioned( - right: -4, - top: -4, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.red500, - borderRadius: BorderRadius.circular(8), - ), - constraints: const BoxConstraints( - minWidth: 16, - minHeight: 16, - ), - child: Text( - _unreadCount > 99 - ? '99+' - : _unreadCount.toString(), - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: AppColors.white, - ), - textAlign: TextAlign.center, - ), - ), - ), - ], - ), - onPressed: () => context.push('/messages/invites'), - ), - ], - ), - ], - ), - ), + return HomeFloatingHeader( + unreadCount: _unreadCount, + onTapSettings: () => context.push('/settings'), + onTapCalendar: () => context.push('/calendar/dayweek?from=home'), + onTapMessages: () => context.push('/messages/invites'), ); } @@ -262,67 +203,74 @@ class _HomeScreenState extends State return const Center(child: CircularProgressIndicator()); } - if (state.items.isEmpty) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Expanded( - child: Center( - child: Text( - '开始对话吧', - style: TextStyle(fontSize: 16, color: AppColors.slate400), + return Padding( + padding: const EdgeInsets.fromLTRB( + _defaultPadding, + 0, + _defaultPadding, + _bottomStackReservedHeight, + ), + child: KeyedSubtree( + key: homeConversationStageKey, + child: Stack( + children: [ + if (state.items.isEmpty) + const Positioned.fill(child: _HomeEmptyStateAmbient()) + else + Positioned.fill( + child: RefreshIndicator( + onRefresh: () => _onRefresh(context), + child: ListView.builder( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: AppSpacing.sm), + itemCount: + state.items.length + (state.hasEarlierHistory ? 1 : 0), + itemBuilder: (context, index) { + if (index == 0 && state.hasEarlierHistory) { + return _buildLoadMoreButton( + context, + state.isLoadingHistory, + ); + } + + 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) + _buildDateDivider(item.timestamp), + Padding( + padding: const EdgeInsets.only( + bottom: _itemSpacing, + ), + child: _buildChatItem(item), + ), + ], + ); + }, + ), + ), ), - ), - ), - if (showWaitingIndicator) - _buildWaitingIndicator(currentStage: state.currentStage), - ], - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: RefreshIndicator( - onRefresh: () => _onRefresh(context), - child: ListView.builder( - controller: _scrollController, - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(_defaultPadding), - itemCount: state.items.length + (state.hasEarlierHistory ? 1 : 0), - itemBuilder: (context, index) { - if (index == 0 && state.hasEarlierHistory) { - return _buildLoadMoreButton(context, state.isLoadingHistory); - } - - 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) _buildDateDivider(item.timestamp), - Padding( - padding: const EdgeInsets.only(bottom: _itemSpacing), - child: _buildChatItem(item), - ), - ], - ); - }, - ), - ), + if (showWaitingIndicator) + Align( + alignment: Alignment.bottomLeft, + child: _buildWaitingIndicator(currentStage: state.currentStage), + ), + ], ), - if (showWaitingIndicator) - _buildWaitingIndicator(currentStage: state.currentStage), - ], + ), ); } @@ -351,7 +299,7 @@ class _HomeScreenState extends State color: AppColors.blue600, ), ), - SizedBox(width: 8), + SizedBox(width: AppSpacing.sm), Text( label, style: TextStyle(fontSize: 14, color: AppColors.slate500), @@ -591,7 +539,7 @@ class _HomeScreenState extends State mainAxisSize: MainAxisSize.min, children: [ Icon(statusIcon, size: 16, color: statusColor), - const SizedBox(width: 8), + const SizedBox(width: AppSpacing.sm), Text( item.toolName, style: const TextStyle( @@ -600,11 +548,11 @@ class _HomeScreenState extends State color: AppColors.slate700, ), ), - const SizedBox(width: 8), + const SizedBox(width: AppSpacing.sm), Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)), if (item.toolName == 'front.navigate_to_route' && item.status == ToolCallStatus.pending) ...[ - const SizedBox(width: 8), + const SizedBox(width: AppSpacing.sm), GestureDetector( onTap: () => _chatBloc.approveToolCall(item.callId), child: Container( @@ -629,62 +577,28 @@ class _HomeScreenState extends State return UiSchemaRenderer.render(item.uiCard); } - Widget _buildImagePreview() { - if (_selectedImages.isEmpty) { - return const SizedBox.shrink(); - } - - return Padding( - padding: const EdgeInsets.only( - left: _inputPadding, - right: _inputPadding, - bottom: AppSpacing.sm, - ), - child: Wrap( - spacing: AppSpacing.sm, - runSpacing: AppSpacing.sm, - children: _selectedImages.asMap().entries.map((entry) { - final index = entry.key; - final image = entry.value; - return _buildImageThumbnail(image, index); - }).toList(), - ), - ); - } - - Widget _buildImageThumbnail(XFile image, int index) { - return Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppRadius.md), - child: Image.file( - File(image.path), - width: 80, - height: 80, - fit: BoxFit.cover, + Widget _buildBottomInputStack(BuildContext context, ChatState state) { + return Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: KeyedSubtree( + key: homeBottomInputStackKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HomeAttachmentStrip( + images: _selectedImages, + onRemove: _removeImage, + ), + if (_selectedImages.isNotEmpty) + const SizedBox(height: AppSpacing.sm), + _buildInputContainer(context, state), + ], ), ), - Positioned( - top: 4, - right: 4, - child: GestureDetector( - onTap: () => _removeImage(index), - child: Container( - width: 24, - height: 24, - decoration: const BoxDecoration( - color: AppColors.red500, - shape: BoxShape.circle, - ), - child: const Icon( - LucideIcons.x, - size: 14, - color: AppColors.white, - ), - ), - ), - ), - ], + ), ); } @@ -698,8 +612,7 @@ class _HomeScreenState extends State final isWaitingAgent = state.isWaitingFirstToken || state.isStreaming || state.isCancelling; return Container( - padding: const EdgeInsets.all(AppSpacing.lg), - color: _chatBgColor, + padding: EdgeInsets.zero, child: MessageComposer( mode: _isHoldToSpeakMode ? MessageComposerMode.holdToSpeak @@ -1103,3 +1016,33 @@ class _HomeScreenState extends State ); } } + +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), + ], + ), + ), + ), + ), + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_attachment_strip.dart b/apps/lib/features/home/ui/widgets/home_attachment_strip.dart new file mode 100644 index 0000000..e8fa6d0 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_attachment_strip.dart @@ -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 images; + final ValueChanged 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, + ), + ), + ), + ), + ], + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_background_field.dart b/apps/lib/features/home/ui/widgets/home_background_field.dart new file mode 100644 index 0000000..7e76447 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_background_field.dart @@ -0,0 +1,85 @@ +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 Align( + alignment: Alignment.bottomCenter, + child: IgnorePointer( + child: Container( + key: homeBottomGlowKey, + width: double.infinity, + height: AppSpacing.xxl * 6, + margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + decoration: BoxDecoration( + color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(AppSpacing.xxl * 2), + boxShadow: [ + BoxShadow( + color: AppColors.homeBackgroundGlow.withValues(alpha: 0.12), + blurRadius: AppSpacing.xxl * 2, + spreadRadius: AppSpacing.md, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/lib/features/home/ui/widgets/home_floating_header.dart b/apps/lib/features/home/ui/widgets/home_floating_header.dart new file mode 100644 index 0000000..5f2ac94 --- /dev/null +++ b/apps/lib/features/home/ui/widgets/home_floating_header.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons/lucide_icons.dart'; + +import '../../../../core/theme/design_tokens.dart'; + +const homeFloatingHeaderKey = ValueKey('home_floating_header'); + +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 Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.lg, + AppSpacing.sm, + AppSpacing.lg, + AppSpacing.md, + ), + child: Container( + key: homeFloatingHeaderKey, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.sm, + vertical: AppSpacing.xs, + ), + decoration: BoxDecoration( + color: AppColors.homeToolbarSurface, + borderRadius: BorderRadius.circular(AppRadius.full), + border: Border.all(color: AppColors.homeToolbarBorder), + boxShadow: const [ + BoxShadow( + color: AppColors.white, + blurRadius: AppRadius.md, + offset: Offset(0, -(AppSpacing.xs / 2)), + ), + BoxShadow( + color: AppColors.slate200, + blurRadius: AppRadius.lg, + offset: Offset(0, AppSpacing.sm - (AppSpacing.xs / 2)), + ), + ], + ), + child: 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, + ), + ], + ), + ], + ), + ), + ); + } +} + +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, + 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, + 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, + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/lib/shared/widgets/message_composer.dart b/apps/lib/shared/widgets/message_composer.dart index 9fe79bb..08eaae2 100644 --- a/apps/lib/shared/widgets/message_composer.dart +++ b/apps/lib/shared/widgets/message_composer.dart @@ -9,6 +9,8 @@ enum MessageComposerMode { text, holdToSpeak } enum MessageComposerProcess { idle, recording, transcribing } const messageComposerContainerKey = ValueKey('message_composer_container'); +const messageComposerShellKey = ValueKey('message_composer_shell'); +const messageComposerInnerKey = ValueKey('message_composer_inner'); const messageComposerPlusButtonKey = ValueKey('message_composer_plus_button'); const messageComposerRightButtonKey = ValueKey('message_composer_right_button'); const messageComposerHoldAreaKey = ValueKey('message_composer_hold_area'); @@ -35,7 +37,7 @@ class MessageComposer extends StatelessWidget { required this.textInputChild, required this.recordingAnimation, this.holdToSpeakText = '按住说话', - this.recordingText = '松手发送', + this.recordingText = '松开发送', this.transcribingText = '语音识别中...', this.recordingHintText = '松开发送,上滑取消', this.showRecordingInlineFeedback = true, @@ -69,71 +71,77 @@ class MessageComposer extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( + return KeyedSubtree( key: messageComposerContainerKey, - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.xs, - ), - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.slate200), - boxShadow: const [ - BoxShadow( - color: AppColors.slate200, - blurRadius: AppRadius.lg, - offset: Offset(AppSpacing.none, AppSpacing.xs), - ), - BoxShadow( - color: AppColors.white, - blurRadius: AppRadius.md, - offset: Offset(AppSpacing.none, -AppSpacing.xs), - ), - ], - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - IgnorePointer( - ignoring: _isRecording && _isHoldMode, - child: Opacity( - opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1, - child: IconButton( - key: messageComposerPlusButtonKey, - visualDensity: VisualDensity.compact, - onPressed: onTapPlus, - icon: Icon( - LucideIcons.plus, - size: iconSize, - color: AppColors.slate500, + child: Container( + key: messageComposerShellKey, + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, + ), + decoration: BoxDecoration( + color: AppColors.homeComposerShell, + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all(color: AppColors.homeComposerBorder), + boxShadow: const [ + BoxShadow( + color: AppColors.slate200, + blurRadius: AppRadius.lg, + offset: Offset(AppSpacing.none, AppSpacing.sm), + ), + BoxShadow( + color: AppColors.white, + blurRadius: AppRadius.md, + offset: Offset(AppSpacing.none, -AppSpacing.xs), + ), + ], + ), + child: KeyedSubtree( + key: messageComposerInnerKey, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IgnorePointer( + ignoring: _isRecording && _isHoldMode, + child: Opacity( + opacity: _isRecording && _isHoldMode ? AppSpacing.none : 1, + child: IconButton( + key: messageComposerPlusButtonKey, + visualDensity: VisualDensity.compact, + onPressed: onTapPlus, + icon: Icon( + LucideIcons.plus, + size: iconSize, + color: AppColors.slate500, + ), + ), ), ), - ), + const SizedBox(width: AppSpacing.sm), + Expanded(child: _buildCenterArea()), + const SizedBox(width: AppSpacing.sm), + IconButton( + key: messageComposerRightButtonKey, + visualDensity: VisualDensity.compact, + onPressed: onTapRightAction, + icon: _isTranscribing + ? const SizedBox( + width: AppSpacing.lg, + height: AppSpacing.lg, + child: CircularProgressIndicator( + strokeWidth: AppSpacing.xs / 2, + color: AppColors.blue600, + ), + ) + : Icon( + _resolveRightIcon(), + size: iconSize, + color: _resolveRightIconColor(), + ), + ), + ], ), - const SizedBox(width: AppSpacing.sm), - Expanded(child: _buildCenterArea()), - const SizedBox(width: AppSpacing.sm), - IconButton( - key: messageComposerRightButtonKey, - visualDensity: VisualDensity.compact, - onPressed: onTapRightAction, - icon: _isTranscribing - ? const SizedBox( - width: AppSpacing.lg, - height: AppSpacing.lg, - child: CircularProgressIndicator( - strokeWidth: AppSpacing.xs / 2, - color: AppColors.blue600, - ), - ) - : Icon( - _resolveRightIcon(), - size: iconSize, - color: _resolveRightIconColor(), - ), - ), - ], + ), ), ); } @@ -201,6 +209,11 @@ class MessageComposer extends StatelessWidget { children: [ recordingAnimation, const SizedBox(height: AppSpacing.xs), + Text( + recordingText, + style: const TextStyle(color: AppColors.slate700), + ), + const SizedBox(height: AppSpacing.xs), Text( recordingHintText, key: messageComposerRecordingHintKey, diff --git a/apps/test/features/home/ui/widgets/home_background_field_test.dart b/apps/test/features/home/ui/widgets/home_background_field_test.dart new file mode 100644 index 0000000..51552b3 --- /dev/null +++ b/apps/test/features/home/ui/widgets/home_background_field_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/home/ui/widgets/home_background_field.dart'; + +void main() { + testWidgets('home background field renders layered glow surfaces', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: HomeBackgroundField())), + ); + + expect(find.byKey(homeBackgroundFieldKey), findsOneWidget); + expect(find.byKey(homeTopGlowKey), findsOneWidget); + expect(find.byKey(homeBottomGlowKey), findsOneWidget); + }); +} diff --git a/apps/test/features/home/ui/widgets/home_composer_test.dart b/apps/test/features/home/ui/widgets/home_composer_test.dart index abf363e..733ebde 100644 --- a/apps/test/features/home/ui/widgets/home_composer_test.dart +++ b/apps/test/features/home/ui/widgets/home_composer_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import 'package:social_app/core/theme/design_tokens.dart'; import 'package:social_app/shared/widgets/message_composer.dart'; Widget _buildTestApp({ @@ -50,8 +51,11 @@ void main() { ); expect(find.byKey(messageComposerContainerKey), findsOneWidget); + expect(find.byKey(messageComposerShellKey), findsOneWidget); + expect(find.byKey(messageComposerInnerKey), findsOneWidget); final containerFinder = find.byKey(messageComposerContainerKey); + final shellFinder = find.byKey(messageComposerShellKey); final plusFinder = find.byKey(messageComposerPlusButtonKey); final rightFinder = find.byKey(messageComposerRightButtonKey); @@ -63,6 +67,29 @@ void main() { find.descendant(of: containerFinder, matching: rightFinder), findsOneWidget, ); + + final container = tester.widget(shellFinder); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, AppColors.homeComposerShell); + expect( + (decoration.border! as Border).top.color, + AppColors.homeComposerBorder, + ); + }); + + testWidgets('recording state keeps unified floating shell', (tester) async { + await tester.pumpWidget( + _buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.recording, + hasMessage: false, + isWaitingAgent: false, + ), + ); + + expect(find.byKey(messageComposerShellKey), findsOneWidget); + expect(find.byKey(messageComposerInnerKey), findsOneWidget); + expect(find.text('松开发送'), findsOneWidget); }); testWidgets('right action icon follows state priority', (tester) async { @@ -85,7 +112,9 @@ void main() { of: find.byKey(messageComposerRightButtonKey), matching: find.byType(Icon), ); + expect(iconFinder, findsOneWidget); final iconWidget = tester.widget(iconFinder.first); + expect(iconWidget.icon, isNotNull); return iconWidget.icon!; } diff --git a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart new file mode 100644 index 0000000..5fda2a7 --- /dev/null +++ b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart @@ -0,0 +1,112 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:social_app/core/api/i_api_client.dart'; +import 'package:social_app/core/di/injection.dart'; +import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart'; +import 'package:social_app/features/home/ui/screens/home_screen.dart'; +import 'package:social_app/features/home/ui/widgets/home_attachment_strip.dart'; +import 'package:social_app/features/home/ui/widgets/home_floating_header.dart'; +import 'package:social_app/features/messages/data/inbox_api.dart'; + +class _TestApiClient implements IApiClient { + @override + Future> delete(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } + + @override + Future> get(String path, {Options? options}) async { + return Response( + requestOptions: RequestOptions(path: path), + data: [] as T, + ); + } + + @override + Future> getSseLines( + String path, { + Map? headers, + }) async { + return const Stream.empty(); + } + + @override + Future> patch(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } + + @override + Future> post(String path, {data, Options? options}) async { + return Response(requestOptions: RequestOptions(path: path)); + } +} + +void main() { + late ChatBloc chatBloc; + + setUp(() { + final apiClient = _TestApiClient(); + if (sl.isRegistered()) { + sl.unregister(); + } + sl.registerSingleton(InboxApi(apiClient)); + chatBloc = ChatBloc(apiClient: apiClient); + }); + + tearDown(() async { + await chatBloc.close(); + if (sl.isRegistered()) { + await sl.unregister(); + } + }); + + Future pumpHomeScreen( + WidgetTester tester, { + List initialSelectedImages = const [], + }) async { + await tester.pumpWidget( + MaterialApp( + home: HomeScreen( + chatBloc: chatBloc, + autoLoadHistory: false, + initialSelectedImages: initialSelectedImages, + ), + ), + ); + await tester.pump(); + } + + testWidgets( + 'home screen shows floating header, conversation stage, and bottom input stack', + (tester) async { + await pumpHomeScreen(tester); + + expect(find.byKey(homeFloatingHeaderKey), findsOneWidget); + expect(find.byKey(homeConversationStageKey), findsOneWidget); + expect(find.byKey(homeBottomInputStackKey), findsOneWidget); + }, + ); + + testWidgets('empty state keeps clean stage without helper copy', ( + tester, + ) async { + await pumpHomeScreen(tester); + + expect(find.byKey(homeConversationStageKey), findsOneWidget); + expect(find.byKey(homeEmptyStateAmbientKey), findsOneWidget); + expect(find.text('开始对话吧'), findsNothing); + }); + + testWidgets('selected images render in attachment strip above composer', ( + tester, + ) async { + await pumpHomeScreen( + tester, + initialSelectedImages: [XFile('/tmp/mock-image-a.png')], + ); + + expect(find.byKey(homeAttachmentStripKey), findsOneWidget); + }); +} diff --git a/docs/plans/2026-03-13-home-screen-visual-refresh.md b/docs/plans/2026-03-13-home-screen-visual-refresh.md new file mode 100644 index 0000000..f5c83ab --- /dev/null +++ b/docs/plans/2026-03-13-home-screen-visual-refresh.md @@ -0,0 +1,484 @@ +# Home Screen Visual Refresh Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Rebuild the post-login home screen into a calm, premium assistant homepage with a layered background field, a clearer conversation stage, and a floating input island that carries text, voice, and attachment states without relying on helper copy. + +**Architecture:** Keep the existing chat data flow and AG-UI behavior unchanged. Refactor the screen into a surface-based composition: background field at the bottom, content layer for header and conversation stage in the middle, and a floating input layer on top. Move visual semantics into shared design tokens first, then rebuild `MessageComposer` and `HomeScreen` around those tokens. + +**Tech Stack:** Flutter, Material, flutter_bloc, existing design token system, existing widget tests in `apps/test/features/home/ui/widgets/`. + +--- + +### Task 1: Add home surface tokens + +**Files:** +- Modify: `apps/lib/core/theme/design_tokens.dart` +- Reference: `apps/rules/visual_design_language.md` + +**Step 1: Write the failing test** + +Add a widget test assertion that depends on a new home token being used by `MessageComposer`, for example: + +```dart +testWidgets('composer uses refreshed home surface token', (tester) async { + await tester.pumpWidget(_buildTestApp( + mode: MessageComposerMode.text, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + )); + + final container = tester.widget( + find.byKey(messageComposerContainerKey), + ); + final decoration = container.decoration! as BoxDecoration; + expect(decoration.color, AppColors.homeComposerShell); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test apps/test/features/home/ui/widgets/home_composer_test.dart` + +Expected: FAIL because `homeComposerShell` does not exist yet. + +**Step 3: Write minimal implementation** + +Add the first batch of home-specific tokens in `apps/lib/core/theme/design_tokens.dart`, keeping names semantic instead of layout-specific: + +```dart +static const homeBackgroundTop = Color(0xFFF5F9FF); +static const homeBackgroundBottom = Color(0xFFF7FAFE); +static const homeBackgroundGlow = Color(0xFFDCEBFF); +static const homeBackgroundGlowSoft = Color(0xFFF1F6FF); +static const homeToolbarSurface = Color(0xF2FFFFFF); +static const homeToolbarBorder = Color(0xD9E6F7); +static const homeConversationSurface = Color(0xBFFFFFFF); +static const homeConversationBorder = Color(0xDDE8F6); +static const homeComposerShell = Color(0xFDFCFEFF); +static const homeComposerInner = Color(0xFFF7FAFE); +static const homeComposerBorder = Color(0xD7E3F3); +static const homeComposerAccent = Color(0xFFEAF3FF); +static const homeAttachmentSurface = Color(0xFFF3F7FD); +``` + +**Step 4: Run test to verify it passes** + +Run: `flutter test apps/test/features/home/ui/widgets/home_composer_test.dart` + +Expected: PASS, with the new token available to downstream widgets. + +**Step 5: Commit** + +```bash +git add apps/lib/core/theme/design_tokens.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "feat: add home screen surface tokens" +``` + +### Task 2: Extract background field and floating header primitives + +**Files:** +- Create: `apps/lib/features/home/ui/widgets/home_background_field.dart` +- Create: `apps/lib/features/home/ui/widgets/home_floating_header.dart` +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` + +**Step 1: Write the failing test** + +Create a focused widget test for the new background field composition: + +```dart +testWidgets('home background field renders layered glow surfaces', (tester) async { + await tester.pumpWidget(const MaterialApp( + home: Scaffold(body: HomeBackgroundField()), + )); + + expect(find.byKey(homeBackgroundFieldKey), findsOneWidget); + expect(find.byKey(homeTopGlowKey), findsOneWidget); + expect(find.byKey(homeBottomGlowKey), findsOneWidget); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test apps/test/features/home/ui/widgets/home_background_field_test.dart` + +Expected: FAIL because the widget file and keys do not exist. + +**Step 3: Write minimal implementation** + +Build a reusable background widget using only tokens and soft gradients: + +```dart +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: Stack( + children: const [ + _TopGlow(), + _BottomGlow(), + ], + ), + ); + } +} +``` + +Add a matching lightweight floating header widget so `HomeScreen` stops inlining all visual treatment in one file. + +**Step 4: Run test to verify it passes** + +Run: `flutter test apps/test/features/home/ui/widgets/home_background_field_test.dart` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/home/ui/widgets/home_background_field.dart apps/lib/features/home/ui/widgets/home_floating_header.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_background_field_test.dart +git commit -m "feat: add layered home screen background primitives" +``` + +### Task 3: Rebuild `MessageComposer` as a floating input island + +**Files:** +- Modify: `apps/lib/shared/widgets/message_composer.dart` +- Modify: `apps/test/features/home/ui/widgets/home_composer_test.dart` + +**Step 1: Write the failing test** + +Extend the existing composer tests to enforce the new structure: + +```dart +testWidgets('composer exposes shell and inner surface', (tester) async { + await tester.pumpWidget(_buildTestApp( + mode: MessageComposerMode.text, + process: MessageComposerProcess.idle, + hasMessage: false, + isWaitingAgent: false, + )); + + expect(find.byKey(messageComposerShellKey), findsOneWidget); + expect(find.byKey(messageComposerInnerKey), findsOneWidget); +}); +``` + +Add another test to ensure the hold-to-speak state stays within the same shell: + +```dart +testWidgets('recording state keeps unified floating shell', (tester) async { + await tester.pumpWidget(_buildTestApp( + mode: MessageComposerMode.holdToSpeak, + process: MessageComposerProcess.recording, + hasMessage: false, + isWaitingAgent: false, + )); + + expect(find.byKey(messageComposerShellKey), findsOneWidget); + expect(find.text('松开发送'), findsOneWidget); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test apps/test/features/home/ui/widgets/home_composer_test.dart` + +Expected: FAIL because the new structure keys do not exist. + +**Step 3: Write minimal implementation** + +Refactor `MessageComposer` from a single decorated `Container` into a layered shell: + +```dart +return Container( + key: messageComposerShellKey, + padding: const EdgeInsets.all(AppSpacing.sm), + decoration: BoxDecoration( + color: AppColors.homeComposerShell, + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all(color: AppColors.homeComposerBorder), + boxShadow: const [ + BoxShadow(...), + ], + ), + child: Container( + key: messageComposerInnerKey, + decoration: BoxDecoration( + color: AppColors.homeComposerInner, + borderRadius: BorderRadius.circular(AppRadius.xl), + ), + child: Row(...), + ), +); +``` + +Keep all current callbacks and AG-UI-adjacent behavior intact. Do not change event names or chat flow. + +**Step 4: Run test to verify it passes** + +Run: `flutter test apps/test/features/home/ui/widgets/home_composer_test.dart` + +Expected: PASS, including the existing callback and state-priority assertions. + +**Step 5: Commit** + +```bash +git add apps/lib/shared/widgets/message_composer.dart apps/test/features/home/ui/widgets/home_composer_test.dart +git commit -m "feat: rebuild composer as floating input island" +``` + +### Task 4: Integrate attachment strip into the input island stack + +**Files:** +- Create: `apps/lib/features/home/ui/widgets/home_attachment_strip.dart` +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` + +**Step 1: Write the failing test** + +Add a widget test that verifies selected images render in a unified strip above the composer shell: + +```dart +testWidgets('selected images render in attachment strip above composer', (tester) async { + // Pump HomeScreen with seeded selected images via a test-only constructor hook. + expect(find.byKey(homeAttachmentStripKey), findsOneWidget); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test apps/test/features/home/ui/widgets/home_screen_input_stack_test.dart` + +Expected: FAIL because the strip widget and key do not exist. + +**Step 3: Write minimal implementation** + +Create a dedicated strip widget that uses the same home surface language: + +```dart +class HomeAttachmentStrip extends StatelessWidget { + const HomeAttachmentStrip({ + super.key, + required this.images, + required this.onRemove, + }); + + final List images; + final ValueChanged 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), + ), + child: Wrap(...), + ); + } +} +``` + +Mount it in the same bottom stack as the composer, not in the main scroll column. + +**Step 4: Run test to verify it passes** + +Run: `flutter test apps/test/features/home/ui/widgets/home_screen_input_stack_test.dart` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/home/ui/widgets/home_attachment_strip.dart apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_screen_input_stack_test.dart +git commit -m "feat: unify home attachments with composer stack" +``` + +### Task 5: Recompose `HomeScreen` around stage + floating bottom stack + +**Files:** +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` +- Reference: `apps/lib/features/home/ui/screens/home_sheet.dart` + +**Step 1: Write the failing test** + +Add a focused home screen layout test: + +```dart +testWidgets('home screen shows floating header, conversation stage, and bottom input stack', (tester) async { + await tester.pumpWidget(buildHomeScreenForTest()); + + expect(find.byKey(homeFloatingHeaderKey), findsOneWidget); + expect(find.byKey(homeConversationStageKey), findsOneWidget); + expect(find.byKey(homeBottomInputStackKey), findsOneWidget); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test apps/test/features/home/ui/widgets/home_screen_layout_test.dart` + +Expected: FAIL because the layout keys are not present. + +**Step 3: Write minimal implementation** + +Refactor the page into a single `Stack` with explicit layers: + +```dart +return Scaffold( + body: SafeArea( + child: Stack( + children: [ + const Positioned.fill(child: HomeBackgroundField()), + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const HomeFloatingHeader(), + Expanded(child: _buildConversationStage(context, state)), + ], + ), + _buildBottomInputStack(context, state), + if (_isRecording) _buildRecordingGestureOverlay(), + ], + ), + ), +); +``` + +Inside `_buildConversationStage`, keep existing history/message rendering logic but place it inside a calmer stage container with stable bottom padding so the floating composer never overlaps message content. + +**Step 4: Run test to verify it passes** + +Run: `flutter test apps/test/features/home/ui/widgets/home_screen_layout_test.dart` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_screen_layout_test.dart +git commit -m "feat: recompose home screen into layered assistant stage" +``` + +### Task 6: Tune empty-state and waiting-state presentation without adding helper copy + +**Files:** +- Modify: `apps/lib/features/home/ui/screens/home_screen.dart` + +**Step 1: Write the failing test** + +Add a test that verifies the empty state uses a dedicated stage surface instead of only centered text: + +```dart +testWidgets('empty state renders stage surface without relying on helper copy', (tester) async { + await tester.pumpWidget(buildEmptyHomeScreenForTest()); + expect(find.byKey(homeConversationStageKey), findsOneWidget); + expect(find.byKey(homeEmptyStateOrbKey), findsOneWidget); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `flutter test apps/test/features/home/ui/widgets/home_screen_layout_test.dart` + +Expected: FAIL because the empty-state focal surface does not exist. + +**Step 3: Write minimal implementation** + +Replace the current `Center(Text('开始对话吧'))` style empty state with a focal surface that uses shape, spacing, and a soft orb layer instead of explanatory copy: + +```dart +Widget _buildEmptyConversationStage() { + return Center( + child: Container( + key: homeEmptyStateOrbKey, + width: 220, + height: 220, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient(...), + ), + ), + ); +} +``` + +Waiting state should sit at the lower edge of the stage, visually connected to the composer instead of appearing as a detached loading row. + +**Step 4: Run test to verify it passes** + +Run: `flutter test apps/test/features/home/ui/widgets/home_screen_layout_test.dart` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add apps/lib/features/home/ui/screens/home_screen.dart apps/test/features/home/ui/widgets/home_screen_layout_test.dart +git commit -m "feat: refine home empty and waiting states" +``` + +### Task 7: Verify visual refresh and document manual QA + +**Files:** +- Modify: `docs/plans/2026-03-13-home-screen-visual-refresh.md` + +**Step 1: Run automated verification** + +Run: + +```bash +flutter test apps/test/features/home/ui/widgets/home_composer_test.dart apps/test/features/home/ui/widgets/home_background_field_test.dart apps/test/features/home/ui/widgets/home_screen_layout_test.dart apps/test/features/home/ui/widgets/home_screen_input_stack_test.dart +``` + +Expected: PASS. + +**Step 2: Run static verification** + +Run: + +```bash +dart format apps/lib/core/theme/design_tokens.dart apps/lib/features/home/ui/screens/home_screen.dart apps/lib/features/home/ui/widgets/home_background_field.dart apps/lib/features/home/ui/widgets/home_floating_header.dart apps/lib/features/home/ui/widgets/home_attachment_strip.dart apps/lib/shared/widgets/message_composer.dart apps/test/features/home/ui/widgets/home_composer_test.dart apps/test/features/home/ui/widgets/home_background_field_test.dart apps/test/features/home/ui/widgets/home_screen_layout_test.dart apps/test/features/home/ui/widgets/home_screen_input_stack_test.dart +flutter analyze +``` + +Expected: PASS. + +**Step 3: Run manual QA** + +Verify on a phone-sized simulator or device: + +```text +1. 首页空态 first impression 不依赖提示文案,仍然有清晰主场感 +2. 顶部浮层不抢焦点,底部输入岛是最稳定视觉锚点 +3. 文本/语音/转写/等待态切换时,输入岛壳体保持连续 +4. 附件预览与输入区属于同一层级,不再像临时插块 +5. 消息列表滚动到底部时,不会被悬浮输入岛遮挡 +``` + +**Step 4: Update plan status note** + +Append a short verification note to this plan with pass/fail status and any follow-up token work. + +**Step 5: Commit** + +```bash +git add docs/plans/2026-03-13-home-screen-visual-refresh.md +git commit -m "docs: record home screen visual refresh verification" +```