diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 992bdd7..258640a 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -95,6 +95,8 @@ All UI/UX work **MUST** follow the visual design language defined in `apps/rules - **MUST** apply the surface-based design system (background, primary, secondary, interactive surfaces). - **MUST** follow the motion and interaction feel guidelines (soft, responsive, premium). - **MUST** achieve visual hierarchy through spacing, surface grouping, radius, depth, density, contrast, scale, and motion—not color alone. +- **MUST** prioritize compact informational delivery in top bars: when the page purpose can be clearly expressed by a concise header title, avoid repeating equivalent explanatory hints in the body. +- **MUST NOT** duplicate page identity text between header and first content block unless the repeated text introduces new decision-critical information. - **MUST** follow the screen-level decision rules: 1. What is the primary focus? 2. What is the surface hierarchy? diff --git a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart index 6b4d964..45f2e67 100644 --- a/apps/lib/features/chat/presentation/bloc/chat_bloc.dart +++ b/apps/lib/features/chat/presentation/bloc/chat_bloc.dart @@ -280,16 +280,6 @@ class ChatBloc extends Cubit { sender: MessageSender.ai, ), ); - } else if (event.resultSummary.isNotEmpty) { - _upsertById( - items, - TextMessageItem( - id: event.messageId, - content: event.resultSummary, - timestamp: timestamp, - sender: MessageSender.ai, - ), - ); } emit(state.copyWith(items: items)); @@ -311,7 +301,10 @@ class ChatBloc extends Cubit { List _convertHistoryMessages(List messages) { final converted = []; for (final msg in messages) { - final sender = msg.role == 'user' ? MessageSender.user : MessageSender.ai; + final normalizedRole = msg.role.toLowerCase(); + final isUser = normalizedRole == 'user'; + final isTool = normalizedRole == 'tool' || normalizedRole == 'tools'; + final sender = isUser ? MessageSender.user : MessageSender.ai; final attachments = msg.attachments .map( (attachment) => { @@ -321,7 +314,7 @@ class ChatBloc extends Cubit { ) .toList(); - if (msg.content.isNotEmpty || sender == MessageSender.user) { + if (!isTool && (msg.content.isNotEmpty || isUser)) { converted.add( TextMessageItem( id: msg.id, diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index 651c39b..f909557 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -16,6 +16,7 @@ import '../../../messages/data/inbox_api.dart'; import '../../data/voice_recorder.dart'; import '../../../chat/ui/widgets/ui_schema_renderer.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; import '../../../../shared/widgets/message_composer.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -95,6 +96,7 @@ class _HomeScreenState extends State bool _isTranscribing = false; bool _isCancelGestureActive = false; bool _isSendingMessage = false; + bool _isPullRefreshing = false; int _unreadCount = 0; final List _selectedImages = []; @@ -210,7 +212,7 @@ class _HomeScreenState extends State const Positioned.fill(child: _HomeEmptyStateAmbient()) else Positioned.fill( - child: RefreshIndicator( + child: RefreshIndicator.noSpinner( onRefresh: () => _onRefresh(context), child: ListView.builder( controller: _scrollController, @@ -260,6 +262,10 @@ class _HomeScreenState extends State alignment: Alignment.bottomLeft, child: _buildWaitingIndicator(currentStage: state.currentStage), ), + Align( + alignment: Alignment.topCenter, + child: AppPullRefreshFeedback(visible: _isPullRefreshing), + ), ], ), ), @@ -347,7 +353,19 @@ class _HomeScreenState extends State } Future _onRefresh(BuildContext context) async { - await context.read().loadMoreHistory(); + if (_isPullRefreshing) { + return; + } + if (mounted) { + setState(() => _isPullRefreshing = true); + } + try { + await context.read().loadMoreHistory(); + } finally { + if (mounted) { + setState(() => _isPullRefreshing = false); + } + } } void _onLoadMore(BuildContext context) { diff --git a/apps/lib/features/home/ui/widgets/home_floating_header.dart b/apps/lib/features/home/ui/widgets/home_floating_header.dart index 5f2ac94..bfaf1a4 100644 --- a/apps/lib/features/home/ui/widgets/home_floating_header.dart +++ b/apps/lib/features/home/ui/widgets/home_floating_header.dart @@ -4,6 +4,7 @@ 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({ @@ -21,60 +22,62 @@ class HomeFloatingHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( + return Container( + key: homeFloatingHeaderKey, padding: const EdgeInsets.fromLTRB( AppSpacing.lg, AppSpacing.sm, AppSpacing.lg, - AppSpacing.md, + AppSpacing.sm, ), - 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, + 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, ), - const SizedBox(width: AppSpacing.sm), - _MessagesButton( - unreadCount: unreadCount, - onPressed: onTapMessages, - ), - ], + ), ), - ], - ), + ), + ], ), ); } diff --git a/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart b/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart index 48b4884..213b6bd 100644 --- a/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart +++ b/apps/lib/features/messages/ui/screens/message_invite_list_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; import '../../../../shared/widgets/page_header.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -39,6 +40,7 @@ class _MessageInviteListScreenState extends State { List _unreadMessages = []; List _readMessages = []; bool _isLoading = false; + bool _isPullRefreshing = false; int _activeTabIndex = 0; @override @@ -51,12 +53,15 @@ class _MessageInviteListScreenState extends State { _loadMessages(); } - Future _loadMessages() async { - if (_isLoading) { + Future _loadMessages({bool showPageLoader = true}) async { + if (_isLoading || _isPullRefreshing) { return; } if (mounted) { - setState(() => _isLoading = true); + setState(() { + _isLoading = showPageLoader; + _isPullRefreshing = !showPageLoader; + }); } try { final results = await Future.wait([ @@ -102,14 +107,22 @@ class _MessageInviteListScreenState extends State { _unreadMessages = unread; _readMessages = read; _isLoading = false; + _isPullRefreshing = false; }); } catch (e) { if (!mounted) return; - setState(() => _isLoading = false); + setState(() { + _isLoading = false; + _isPullRefreshing = false; + }); Toast.show(context, '消息加载失败,请稍后重试', type: ToastType.error); } } + Future _onRefreshMessages() async { + await _loadMessages(showPageLoader: false); + } + List _mapMessagesWithFriend( List messages, Map requestMap, @@ -451,30 +464,37 @@ class _MessageInviteListScreenState extends State { List messages, { required bool isUnread, }) { - return RefreshIndicator( - onRefresh: _loadMessages, - color: AppColors.blue500, - child: ListView.builder( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 20), - itemCount: messages.isEmpty ? 1 : messages.length, - itemBuilder: (context, index) { - if (messages.isEmpty) { - return SizedBox( - height: MediaQuery.sizeOf(context).height * 0.6, - child: _buildEmptyState(isUnread: isUnread), - ); - } - final item = messages[index]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _MessageCard( - item: item, - onTap: () => _handleMessageTap(item), - ), - ); - }, - ), + return Stack( + children: [ + RefreshIndicator.noSpinner( + onRefresh: _onRefreshMessages, + child: ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 20), + itemCount: messages.isEmpty ? 1 : messages.length, + itemBuilder: (context, index) { + if (messages.isEmpty) { + return SizedBox( + height: MediaQuery.sizeOf(context).height * 0.6, + child: _buildEmptyState(isUnread: isUnread), + ); + } + final item = messages[index]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _MessageCard( + item: item, + onTap: () => _handleMessageTap(item), + ), + ); + }, + ), + ), + Align( + alignment: Alignment.topCenter, + child: AppPullRefreshFeedback(visible: _isPullRefreshing), + ), + ], ); } diff --git a/apps/lib/features/settings/ui/screens/account_screen.dart b/apps/lib/features/settings/ui/screens/account_screen.dart index f715d91..a0d5967 100644 --- a/apps/lib/features/settings/ui/screens/account_screen.dart +++ b/apps/lib/features/settings/ui/screens/account_screen.dart @@ -2,102 +2,46 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_pressable.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../auth/presentation/bloc/auth_bloc.dart'; import '../../../auth/presentation/bloc/auth_event.dart'; import '../../../auth/presentation/bloc/auth_state.dart'; +import '../../../../shared/widgets/app_button.dart'; import '../widgets/account_section_card.dart'; import '../widgets/account_surface_scaffold.dart'; class AccountScreen extends StatelessWidget { const AccountScreen({super.key}); + static const double _menuItemHeight = AppSpacing.xl * 2 + AppSpacing.md; + static const double _menuItemHorizontalPadding = AppSpacing.md; + static const double _menuIconSize = 20; + static const double _menuChevronSize = 18; + @override Widget build(BuildContext context) { return AccountSurfaceScaffold( - title: '我的账户', - subtitle: '管理资料信息与账户安全', + title: '账户', + subtitle: null, + compactHeaderTitle: true, onBack: () => context.pop(), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildProfileHero(context), - const SizedBox(height: AppSpacing.lg), - _buildMenuCard(context), - const SizedBox(height: AppSpacing.lg), - _buildSecurityCard(context), - ], + children: [_buildListSurface(context)], ), ); } - Widget _buildProfileHero(BuildContext context) { - final authState = context.watch().state; - final email = authState is AuthAuthenticated ? authState.user.email : ''; - final identity = email.isEmpty ? '当前登录账户' : email; - final badge = email.isEmpty ? 'A' : email.characters.first.toUpperCase(); - + Widget _buildListSurface(BuildContext context) { return AccountSectionCard( - title: '账户概览', - description: '查看当前账户状态与基础身份信息', - backgroundColor: AppColors.surfaceInfoLight, - borderColor: AppColors.borderQuaternary, - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.borderQuaternary), - ), - child: Center( - child: Text( - badge, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.blue600, - ), - ), - ), - ), - const SizedBox(width: AppSpacing.md), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - identity, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSpacing.xs), - const Text( - '账户状态正常,可安全管理资料与密码', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - ], - ), - ), - ], + backgroundColor: AppColors.white, + borderColor: AppColors.borderSecondary, + contentPadding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, + vertical: AppSpacing.sm, ), - ); - } - - Widget _buildMenuCard(BuildContext context) { - return AccountSectionCard( - title: '账户信息', - description: '编辑公开资料和登录信息', child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -112,52 +56,15 @@ class AccountScreen extends StatelessWidget { title: '修改密码', onTap: () => context.push('/change-password'), ), - ], - ), - ); - } - - Widget _buildSecurityCard(BuildContext context) { - return AccountSectionCard( - title: '安全与会话', - description: '若为公共设备,请及时退出登录', - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.md, - vertical: AppSpacing.sm, - ), - decoration: BoxDecoration( - color: AppColors.surfaceSecondary, - borderRadius: BorderRadius.circular(AppRadius.md), - border: Border.all(color: AppColors.borderSecondary), - ), - child: const Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.shield_outlined, - size: 16, - color: AppColors.slate500, - ), - SizedBox(width: AppSpacing.xs), - Expanded( - child: Text( - '退出后需要重新登录才能继续使用。', - style: TextStyle( - fontSize: 12, - color: AppColors.slate500, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), + _buildDivider(), + _buildMenuItem( + icon: Icons.logout, + title: '退出登录', + titleColor: AppColors.feedbackErrorText, + iconColor: AppColors.feedbackErrorIcon, + trailingColor: AppColors.feedbackErrorIcon, + onTap: () => _showLogoutSheet(context), ), - const SizedBox(height: AppSpacing.md), - _buildLogoutButton(context), ], ), ); @@ -167,13 +74,19 @@ class AccountScreen extends StatelessWidget { required IconData icon, required String title, required VoidCallback onTap, + Color titleColor = AppColors.slate900, + Color iconColor = AppColors.slate500, + Color trailingColor = AppColors.slate400, }) { - return GestureDetector( + return AppPressable( onTap: onTap, - behavior: HitTestBehavior.opaque, + borderRadius: BorderRadius.circular(AppRadius.md), child: Container( - height: AppSpacing.xxl, - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + constraints: const BoxConstraints(minHeight: _menuItemHeight), + padding: const EdgeInsets.symmetric( + horizontal: _menuItemHorizontalPadding, + vertical: AppSpacing.sm, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -181,22 +94,25 @@ class AccountScreen extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - Icon(icon, size: 20, color: AppColors.slate500), + SizedBox( + width: _menuIconSize, + child: Icon(icon, size: _menuIconSize, color: iconColor), + ), const SizedBox(width: AppSpacing.md), Text( title, - style: const TextStyle( + style: TextStyle( fontSize: 16, - fontWeight: FontWeight.w500, - color: AppColors.slate900, + fontWeight: FontWeight.w600, + color: titleColor, ), ), ], ), - const Icon( + Icon( Icons.chevron_right, - size: 18, - color: AppColors.slate400, + size: _menuChevronSize, + color: trailingColor, ), ], ), @@ -207,65 +123,108 @@ class AccountScreen extends StatelessWidget { Widget _buildDivider() { return Container( height: 1, - margin: const EdgeInsets.symmetric(horizontal: AppSpacing.md), + margin: const EdgeInsets.only( + left: _menuItemHorizontalPadding + _menuIconSize + AppSpacing.md, + right: _menuItemHorizontalPadding, + ), color: AppColors.borderTertiary, ); } - Widget _buildLogoutButton(BuildContext context) { - return GestureDetector( - onTap: () => _showLogoutDialog(context), - child: Container( - width: double.infinity, - height: 52, - decoration: BoxDecoration( - color: AppColors.white, - borderRadius: BorderRadius.circular(AppRadius.lg), - border: Border.all(color: AppColors.feedbackErrorBorder), - ), - child: const Center( - child: Text( - '退出登录', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.feedbackErrorText, - ), - ), - ), - ), - ); - } - - void _showLogoutDialog(BuildContext context) { - showDialog( + void _showLogoutSheet(BuildContext context) { + showModalBottomSheet( context: context, - builder: (dialogContext) => AlertDialog( - title: const Text('退出登录'), - content: const Text('确定要退出当前账户吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('取消'), + backgroundColor: Colors.transparent, + isScrollControlled: true, + builder: (sheetContext) => SafeArea( + top: false, + child: Container( + margin: const EdgeInsets.fromLTRB( + AppSpacing.md, + AppSpacing.none, + AppSpacing.md, + AppSpacing.md, ), - TextButton( - onPressed: () async { - Navigator.of(dialogContext).pop(); - final authBloc = context.read(); - authBloc.add(AuthLoggedOut()); - await authBloc.stream.firstWhere( - (state) => state is AuthUnauthenticated, - ); - if (context.mounted) { - context.go('/'); - } - }, - child: const Text( - '退出', - style: TextStyle(color: AppColors.feedbackErrorText), - ), + padding: const EdgeInsets.all(AppSpacing.lg), + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.borderSecondary), ), - ], + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + '退出登录', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.xs), + const Text( + '确定退出当前账户吗?', + style: TextStyle(fontSize: 14, color: AppColors.slate500), + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + SizedBox( + height: 52, + child: GestureDetector( + onTap: () async { + Navigator.of(sheetContext).pop(); + final authBloc = context.read(); + authBloc.add(AuthLoggedOut()); + try { + await authBloc.stream + .firstWhere((state) => state is AuthUnauthenticated) + .timeout(const Duration(seconds: 5)); + } catch (_) { + if (context.mounted) { + Toast.show( + context, + '退出失败,请稍后重试', + type: ToastType.error, + ); + } + return; + } + if (context.mounted) { + context.go('/'); + } + }, + child: Container( + decoration: BoxDecoration( + color: AppColors.feedbackErrorIcon, + borderRadius: BorderRadius.circular(AppRadius.full), + ), + alignment: Alignment.center, + child: const Text( + '确认退出', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.white, + ), + ), + ), + ), + ), + const SizedBox(height: AppSpacing.sm), + SizedBox( + height: 52, + child: AppButton( + text: '取消', + isOutlined: true, + onPressed: () => Navigator.of(sheetContext).pop(), + ), + ), + ], + ), + ), ), ); } diff --git a/apps/lib/features/settings/ui/screens/change_password_screen.dart b/apps/lib/features/settings/ui/screens/change_password_screen.dart index 0dc3787..16590b7 100644 --- a/apps/lib/features/settings/ui/screens/change_password_screen.dart +++ b/apps/lib/features/settings/ui/screens/change_password_screen.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../core/di/injection.dart'; import '../../../../shared/widgets/app_button.dart'; -import '../../../../shared/widgets/banner/app_banner.dart'; import '../../../../shared/widgets/fixed_length_code_input.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; @@ -98,7 +97,7 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { }, child: AccountSurfaceScaffold( title: '修改密码', - subtitle: '通过邮箱验证码安全更新你的登录密码', + subtitle: '通过邮箱验证码修改密码', onBack: () => context.pop(), body: _buildForm(), footer: BlocBuilder( @@ -131,8 +130,7 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { Widget _buildEmailSection(ResetPasswordState state, String userEmail) { return AccountSectionCard( - title: '第 1 步:验证邮箱', - description: '先向登录邮箱发送验证码,再进行密码设置', + title: '发送验证码', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -209,33 +207,17 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { bool passwordHasError, bool confirmHasError, ) { + if (!state.codeSent) { + return const SizedBox.shrink(); + } + return AccountSectionCard( - title: '第 2 步:输入验证码并设置新密码', - description: '验证码有效后,确认新密码即可完成修改', - backgroundColor: state.codeSent - ? AppColors.white - : AppColors.surfaceTertiary, - borderColor: state.codeSent - ? AppColors.borderSecondary - : AppColors.borderTertiary, + title: '设置新密码', + backgroundColor: AppColors.white, + borderColor: AppColors.borderSecondary, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!state.codeSent) - const AppBanner( - title: '请先发送验证码', - message: '完成邮箱验证后,可继续设置新密码。', - type: ToastType.info, - ), - if (state.codeSent) - AppBanner( - title: '验证码已发送', - message: state.resendCountdown > 0 - ? '如未收到,可在 ${state.resendCountdown} 秒后重新发送。' - : '若未收到邮件,可重新发送验证码。', - type: ToastType.info, - ), - const SizedBox(height: AppSpacing.lg), const Text( '验证码', style: TextStyle( @@ -372,31 +354,16 @@ class __ChangePasswordViewState extends State<_ChangePasswordView> { Widget _buildSubmitButton(ResetPasswordState state) { final isLoading = state.status == FormzSubmissionStatus.inProgress; - final isDisabled = isLoading || !state.canSubmit; + final isDisabled = isLoading || !state.codeSent || !state.canSubmit; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!state.codeSent) - const Text( - '完成验证码验证后可提交密码修改', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - ), - if (!state.codeSent) const SizedBox(height: AppSpacing.sm), - SizedBox( - width: double.infinity, - height: 52, - child: AppButton( - text: '确认修改', - onPressed: isDisabled ? null : _handleSubmit, - isLoading: isLoading, - ), - ), - ], + return SizedBox( + width: double.infinity, + height: 52, + child: AppButton( + text: '确认修改', + onPressed: isDisabled ? null : _handleSubmit, + isLoading: isLoading, + ), ); } } diff --git a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart index 328ea37..a8fabf5 100644 --- a/apps/lib/features/settings/ui/screens/edit_profile_screen.dart +++ b/apps/lib/features/settings/ui/screens/edit_profile_screen.dart @@ -121,7 +121,7 @@ class _EditProfileScreenState extends State { Widget build(BuildContext context) { return AccountSurfaceScaffold( title: '编辑资料', - subtitle: '完善公开信息,让好友更容易认识你', + subtitle: '编辑账户资料', onBack: () => context.pop(), body: _isLoading ? const Center( @@ -130,8 +130,6 @@ class _EditProfileScreenState extends State { : Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildProfileSummarySection(), - const SizedBox(height: AppSpacing.lg), _buildBasicInfoSection(), const SizedBox(height: AppSpacing.lg), _buildBioSection(), @@ -149,77 +147,9 @@ class _EditProfileScreenState extends State { ); } - Widget _buildProfileSummarySection() { - final username = _user?.username ?? '未设置用户名'; - final email = _user?.email; - - return AccountSectionCard( - title: '资料概览', - description: '展示你的公开身份信息', - backgroundColor: AppColors.white, - borderColor: AppColors.borderSecondary, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.blue100, AppColors.surfaceInfoLight], - ), - borderRadius: BorderRadius.circular(AppRadius.full), - border: Border.all(color: AppColors.borderQuaternary), - ), - child: const Icon( - Icons.person, - size: 28, - color: AppColors.blue600, - ), - ), - const SizedBox(width: AppSpacing.lg), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - username, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: AppColors.slate900, - ), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSpacing.xs), - Text( - email ?? '邮箱未绑定', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), - ], - ), - ); - } - Widget _buildBasicInfoSection() { return AccountSectionCard( title: '基础信息', - description: '用户名会在个人资料和社交场景中展示', backgroundColor: AppColors.white, borderColor: AppColors.borderSecondary, child: Column( @@ -248,7 +178,6 @@ class _EditProfileScreenState extends State { Widget _buildBioSection() { return AccountSectionCard( title: '个人简介', - description: '一句话介绍自己,帮助他人快速了解你', backgroundColor: AppColors.white, borderColor: AppColors.borderSecondary, child: Column( diff --git a/apps/lib/features/settings/ui/widgets/account_section_card.dart b/apps/lib/features/settings/ui/widgets/account_section_card.dart index 0224799..9ab7e2f 100644 --- a/apps/lib/features/settings/ui/widgets/account_section_card.dart +++ b/apps/lib/features/settings/ui/widgets/account_section_card.dart @@ -10,6 +10,7 @@ class AccountSectionCard extends StatelessWidget { required this.child, this.backgroundColor = AppColors.white, this.borderColor = AppColors.borderSecondary, + this.contentPadding = const EdgeInsets.all(AppSpacing.lg), }); final String? title; @@ -17,12 +18,13 @@ class AccountSectionCard extends StatelessWidget { final Widget child; final Color backgroundColor; final Color borderColor; + final EdgeInsetsGeometry contentPadding; @override Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.all(AppSpacing.lg), + padding: contentPadding, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(AppRadius.xl), diff --git a/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart b/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart index c9d571c..fd6d68a 100644 --- a/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart +++ b/apps/lib/features/settings/ui/widgets/account_surface_scaffold.dart @@ -7,17 +7,19 @@ class AccountSurfaceScaffold extends StatelessWidget { const AccountSurfaceScaffold({ super.key, required this.title, - required this.subtitle, + this.subtitle, required this.body, this.footer, this.onBack, + this.compactHeaderTitle = false, }); final String title; - final String subtitle; + final String? subtitle; final Widget body; final Widget? footer; final VoidCallback? onBack; + final bool compactHeaderTitle; @override Widget build(BuildContext context) { @@ -27,37 +29,80 @@ class AccountSurfaceScaffold extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - widgets.PageHeader(leading: widgets.BackButton(onPressed: onBack)), - Padding( - padding: const EdgeInsets.fromLTRB( - AppSpacing.xl, - AppSpacing.none, - AppSpacing.xl, - AppSpacing.sm, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.slate900, + if (compactHeaderTitle) + SizedBox( + height: 64, + child: Stack( + alignment: Alignment.center, + children: [ + widgets.PageHeader( + leading: widgets.BackButton(onPressed: onBack), + trailing: const SizedBox( + width: AppSpacing.xl * 2, + height: AppSpacing.xl * 2, + ), ), + IgnorePointer( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.xxl * 2, + ), + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + ), + ), + ], + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + widgets.PageHeader( + leading: widgets.BackButton(onPressed: onBack), ), - const SizedBox(height: AppSpacing.xs), - Text( - subtitle, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w500, - color: AppColors.slate500, + Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.xl, + AppSpacing.none, + AppSpacing.xl, + AppSpacing.sm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.slate900, + ), + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + subtitle!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.slate500, + ), + ), + ], + ], ), ), ], ), - ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB( diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index a02cb86..fd604cc 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -5,6 +5,7 @@ import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; import '../../../../shared/widgets/app_button.dart'; import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../../../shared/widgets/app_pull_refresh_feedback.dart'; import '../../../../shared/widgets/app_pressable.dart'; import '../../../../shared/widgets/app_sheet_input_field.dart'; import '../../../../shared/widgets/toast/toast.dart'; @@ -26,6 +27,7 @@ class _TodoQuadrantsScreenState extends State { List _todos = []; bool _isLoading = true; + bool _isPullRefreshing = false; bool _loadingTodosRequest = false; String? _error; @@ -35,15 +37,19 @@ class _TodoQuadrantsScreenState extends State { _loadTodos(); } - Future _loadTodos() async { - if (_loadingTodosRequest) { + Future _loadTodos({bool showPageLoader = true}) async { + if (_loadingTodosRequest || _isPullRefreshing) { return; } _loadingTodosRequest = true; setState(() { - _isLoading = true; - _error = null; + if (showPageLoader) { + _isLoading = true; + _error = null; + } else { + _isPullRefreshing = true; + } }); try { @@ -54,20 +60,32 @@ class _TodoQuadrantsScreenState extends State { setState(() { _todos = todos; _isLoading = false; + _isPullRefreshing = false; + _error = null; }); } catch (e) { if (!mounted) { return; } - setState(() { - _error = e.toString(); - _isLoading = false; - }); + if (showPageLoader) { + setState(() { + _error = e.toString(); + _isLoading = false; + _isPullRefreshing = false; + }); + } else { + setState(() => _isPullRefreshing = false); + Toast.show(context, '刷新失败,请稍后重试', type: ToastType.error); + } } finally { _loadingTodosRequest = false; } } + Future _onPullRefresh() async { + await _loadTodos(showPageLoader: false); + } + List get _importantUrgent => _todos.where((t) => t.priority == 1).toList(); @@ -239,44 +257,57 @@ class _TodoQuadrantsScreenState extends State { ); } - return RefreshIndicator( - onRefresh: _loadTodos, - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 4, bottom: 96), - child: ListView( - children: [ - _buildQuadrant( - title: '重要紧急', - textColor: AppColors.g1Text, - dividerColor: AppColors.g1Divider, - borderColor: AppColors.g1Border, - items: _importantUrgent, - onComplete: _completeTodo, - onTap: _navigateToDetail, + return Stack( + children: [ + RefreshIndicator.noSpinner( + onRefresh: _onPullRefresh, + child: Padding( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 4, + bottom: 96, ), - const SizedBox(height: 12), - _buildQuadrant( - title: '紧急不重要', - textColor: AppColors.g2Text, - dividerColor: AppColors.g2Divider, - borderColor: AppColors.g2Border, - items: _urgentNotImportant, - onComplete: _completeTodo, - onTap: _navigateToDetail, + child: ListView( + children: [ + _buildQuadrant( + title: '重要紧急', + textColor: AppColors.g1Text, + dividerColor: AppColors.g1Divider, + borderColor: AppColors.g1Border, + items: _importantUrgent, + onComplete: _completeTodo, + onTap: _navigateToDetail, + ), + const SizedBox(height: 12), + _buildQuadrant( + title: '紧急不重要', + textColor: AppColors.g2Text, + dividerColor: AppColors.g2Divider, + borderColor: AppColors.g2Border, + items: _urgentNotImportant, + onComplete: _completeTodo, + onTap: _navigateToDetail, + ), + const SizedBox(height: 12), + _buildQuadrant( + title: '重要不紧急', + textColor: AppColors.g3Text, + dividerColor: AppColors.g3Divider, + borderColor: AppColors.g3Border, + items: _importantNotUrgent, + onComplete: _completeTodo, + onTap: _navigateToDetail, + ), + ], ), - const SizedBox(height: 12), - _buildQuadrant( - title: '重要不紧急', - textColor: AppColors.g3Text, - dividerColor: AppColors.g3Divider, - borderColor: AppColors.g3Border, - items: _importantNotUrgent, - onComplete: _completeTodo, - onTap: _navigateToDetail, - ), - ], + ), ), - ), + Align( + alignment: Alignment.topCenter, + child: AppPullRefreshFeedback(visible: _isPullRefreshing), + ), + ], ); } diff --git a/apps/lib/shared/widgets/app_pull_refresh_feedback.dart b/apps/lib/shared/widgets/app_pull_refresh_feedback.dart new file mode 100644 index 0000000..688d9c9 --- /dev/null +++ b/apps/lib/shared/widgets/app_pull_refresh_feedback.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import '../../core/theme/design_tokens.dart'; +import 'app_loading_indicator.dart'; + +class AppPullRefreshFeedback extends StatelessWidget { + const AppPullRefreshFeedback({ + super.key, + required this.visible, + this.label = '正在刷新', + this.margin = const EdgeInsets.only(top: AppSpacing.sm), + }); + + final bool visible; + final String label; + final EdgeInsetsGeometry margin; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: AnimatedOpacity( + opacity: visible ? 1 : 0, + duration: kThemeAnimationDuration, + curve: Curves.easeOut, + child: Container( + margin: margin, + 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.borderSecondary), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const AppLoadingIndicator( + variant: AppLoadingVariant.inline, + color: AppColors.blue500, + trackColor: AppColors.blue100, + ), + const SizedBox(width: AppSpacing.sm), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: AppColors.slate600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/rules/visual_design_language.md b/apps/rules/visual_design_language.md index f6b6339..6699c38 100644 --- a/apps/rules/visual_design_language.md +++ b/apps/rules/visual_design_language.md @@ -559,6 +559,11 @@ When multiple valid layouts exist, prefer the one that: - better supports future micro-interactions - better matches the assistant-product identity +For top navigation and title areas: +- prefer concise, high-signal header titles to communicate page identity +- avoid repeating equivalent helper text in the first body section when it does not add new actionable meaning +- use body copy only for new context, constraints, or decision guidance + --- ## 19) AI Generation Guidance (MUST) 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 index 5fda2a7..b58703c 100644 --- a/apps/test/features/home/ui/widgets/home_screen_layout_test.dart +++ b/apps/test/features/home/ui/widgets/home_screen_layout_test.dart @@ -84,6 +84,8 @@ void main() { await pumpHomeScreen(tester); expect(find.byKey(homeFloatingHeaderKey), findsOneWidget); + expect(find.byKey(homeFloatingHeaderTitleKey), findsOneWidget); + expect(find.text('Linksy'), findsOneWidget); expect(find.byKey(homeConversationStageKey), findsOneWidget); expect(find.byKey(homeBottomInputStackKey), findsOneWidget); }, diff --git a/apps/test/features/settings/ui/screens/change_password_screen_test.dart b/apps/test/features/settings/ui/screens/change_password_screen_test.dart index f7622ff..29593b9 100644 --- a/apps/test/features/settings/ui/screens/change_password_screen_test.dart +++ b/apps/test/features/settings/ui/screens/change_password_screen_test.dart @@ -56,7 +56,7 @@ void main() { find.widgetWithText(ElevatedButton, '确认修改'), ); expect(confirmButton.onPressed, isNull); - expect(find.text('完成验证码验证后可提交密码修改'), findsOneWidget); + expect(find.text('设置新密码'), findsNothing); }); testWidgets('发送验证码倒计时期间不会重复触发请求', (tester) async { @@ -67,13 +67,11 @@ void main() { await pumpScreen(tester); - await tester.tap(find.text('发送验证码')); + await tester.tap(find.widgetWithText(ElevatedButton, '发送验证码')); await tester.pump(); expect(find.text('60 秒后可重发'), findsOneWidget); - - await tester.tap(find.text('60 秒后可重发')); - await tester.pump(); + expect(find.text('设置新密码'), findsOneWidget); verify( () => mockAuthRepository.requestPasswordReset('tester@example.com'), diff --git a/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart b/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart index fb8ad6b..c4c2fae 100644 --- a/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart +++ b/apps/test/features/settings/ui/widgets/account_surface_widgets_test.dart @@ -44,4 +44,24 @@ void main() { expect(find.text('主体内容'), findsOneWidget); expect(find.text('底部操作区'), findsOneWidget); }); + + testWidgets('AccountSurfaceScaffold renders compact header title', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: AccountSurfaceScaffold( + title: '账户', + subtitle: '不会在紧凑模式显示', + compactHeaderTitle: true, + body: const Text('主体内容'), + onBack: () {}, + ), + ), + ); + + expect(find.text('账户'), findsOneWidget); + expect(find.text('不会在紧凑模式显示'), findsNothing); + expect(find.text('主体内容'), findsOneWidget); + }); }