diff --git a/client/lib/ui/widgets/fx_section_header.dart b/client/lib/ui/widgets/fx_section_header.dart index 57af2003..1a882b27 100644 --- a/client/lib/ui/widgets/fx_section_header.dart +++ b/client/lib/ui/widgets/fx_section_header.dart @@ -59,7 +59,10 @@ class FxSectionHeader extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final scale = MediaQuery.textScalerOf(context).scale(1); - final narrow = constraints.maxWidth < 440 || scale >= 1.6; + // 优先单行呈现:普通 Android 窄屏容得下标题和短操作,不应按全局宽度强制换行。 + // 仅在真正紧窄或超大字体时退化为右对齐的第二行。 + final narrow = constraints.maxWidth < 300 || + (scale >= 1.6 && constraints.maxWidth < 440); final action = trailingWidget; if (action == null) { return ConstrainedBox( diff --git a/client/lib/widgets/dashboard/dashboard_body.dart b/client/lib/widgets/dashboard/dashboard_body.dart index 52582cab..e8621d61 100644 --- a/client/lib/widgets/dashboard/dashboard_body.dart +++ b/client/lib/widgets/dashboard/dashboard_body.dart @@ -1,5 +1,3 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -294,8 +292,9 @@ class _DashboardBodyState extends State { final theme = Theme.of(context); final color = _parseColor(item['color']?.toString()); final active = _activeDays(item); + final activeCount = _lastSevenDays().where((day) => active.contains(_dateKey(day))).length; return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: color.withValues(alpha: .06), border: Border.all(color: color.withValues(alpha: .18)), @@ -311,14 +310,24 @@ class _DashboardBodyState extends State { ).copyWith(fontWeight: FontWeight.w600), ), ), - if (compact) + if (compact) ...[ + const SizedBox(width: 8), + Expanded( + child: FxProgress( + value: activeCount / 7, + height: 6, + color: color, + backgroundColor: color.withValues(alpha: .12), + ), + ), + const SizedBox(width: 8), Text( - '${active.length}/7 天', + '$activeCount/7 天', style: SlowlightTypography.caption( context, ).copyWith(color: theme.colorScheme.onSurfaceVariant), - ) - else + ), + ] else ..._lastSevenDays().map( (day) => Container( width: 7, @@ -511,7 +520,9 @@ class _DashboardBodyState extends State { children: [ FxSectionHeader( title: '习惯', - trailing: '$checked/${widget.habits.length} 已打卡', + trailing: widget.habits.isEmpty + ? '暂无习惯' + : '$checked/${widget.habits.length} 已打卡', trailingWidget: widget.onViewAllHabits == null ? null @@ -523,7 +534,7 @@ class _DashboardBodyState extends State { ), ), if (visible.isEmpty) - _empty('还没有习惯记录') + _empty('还没有习惯记录', compact: mobile) else ...visible.map((habit) => _habitRow(habit, mobile)), ], @@ -625,6 +636,7 @@ class _DashboardBodyState extends State { Widget _focusCard() { final theme = Theme.of(context); + final mobile = !ResponsiveLayout.isDesktopOrWider(context); final count = (_facts['focus_count'] as num?)?.toInt() ?? 0; final minutes = (_facts['focus_minutes'] as num?)?.toInt() ?? 0; return _card( @@ -659,70 +671,59 @@ class _DashboardBodyState extends State { icon: LucideIcons.timer, variant: FxButtonVariant.outline, size: FxButtonSize.sm, - expanded: true, onPressed: widget.onStartFocus, ), ], ], ), + padding: EdgeInsets.all(mobile ? 12 : 16), ); } Widget _questionCard() { final theme = Theme.of(context); + final mobile = !ResponsiveLayout.isDesktopOrWider(context); final visible = _insights .where((item) => !_ignored.contains(item.id)) .take(1) .toList(growable: false); final insight = visible.isEmpty ? null : visible.first; - return FxInkWell( - borderRadius: BorderRadius.circular(SlowlightRadius.lg), - onTap: insight == null ? null : () => _respond(insight), - child: CustomPaint( - painter: _DashedBorderPainter( - color: fxDivider(context), - radius: SlowlightRadius.lg, - ), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: fxSurface(context), - borderRadius: BorderRadius.circular(SlowlightRadius.lg), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FxChip( - label: '今日提问', - backgroundColor: activePalette.accent.withValues(alpha: .12), - foregroundColor: activePalette.accent, - borderRadius: 999, - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + return _card( + FxInkWell( + borderRadius: BorderRadius.circular(SlowlightRadius.lg), + onTap: insight == null ? null : () => _respond(insight), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FxChip( + label: '今日提问', + backgroundColor: activePalette.accent.withValues(alpha: .12), + foregroundColor: activePalette.accent, + borderRadius: SlowlightRadius.pill, + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3), + ), + const SizedBox(height: 8), + Text( + insight?.content ?? '今天暂时没有特别需要关注的变化。', + maxLines: 4, + overflow: TextOverflow.ellipsis, + style: SlowlightTypography.secondary(context).copyWith( + fontWeight: insight == null ? FontWeight.w400 : FontWeight.w600, ), + ), + if (insight != null) ...[ const SizedBox(height: 8), Text( - insight?.content ?? '今天暂时没有特别需要关注的变化。', - maxLines: 4, - overflow: TextOverflow.ellipsis, - style: SlowlightTypography.secondary(context).copyWith( - fontWeight: - insight == null ? FontWeight.w400 : FontWeight.w600, - ), + '点击去回应 →', + style: SlowlightTypography.caption( + context, + ).copyWith(color: theme.colorScheme.onSurfaceVariant), ), - if (insight != null) ...[ - const SizedBox(height: 8), - Text( - '点击去回应 →', - style: SlowlightTypography.caption( - context, - ).copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], ], - ), + ], ), ), + padding: EdgeInsets.all(mobile ? 12 : 16), ); } @@ -742,8 +743,8 @@ class _DashboardBodyState extends State { } } - Widget _empty(String text) => Padding( - padding: const EdgeInsets.symmetric(vertical: 14), + Widget _empty(String text, {bool compact = false}) => Padding( + padding: EdgeInsets.symmetric(vertical: compact ? 8 : 14), child: Text( text, style: SlowlightTypography.secondary( @@ -952,34 +953,3 @@ class _SwipeAction extends StatelessWidget { } } -class _DashedBorderPainter extends CustomPainter { - final Color color; - final double radius; - - const _DashedBorderPainter({required this.color, required this.radius}); - - @override - void paint(Canvas canvas, Size size) { - final paint = - Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeWidth = 1; - final path = - Path()..addRRect( - RRect.fromRectAndRadius(Offset.zero & size, Radius.circular(radius)), - ); - for (final metric in path.computeMetrics()) { - var distance = 0.0; - while (distance < metric.length) { - final end = math.min(distance + 5, metric.length).toDouble(); - canvas.drawPath(metric.extractPath(distance, end), paint); - distance += 9; - } - } - } - - @override - bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) => - oldDelegate.color != color || oldDelegate.radius != radius; -} diff --git a/client/test/screens/home_screen_test.dart b/client/test/screens/home_screen_test.dart index cce7c0eb..b1910094 100644 --- a/client/test/screens/home_screen_test.dart +++ b/client/test/screens/home_screen_test.dart @@ -36,6 +36,14 @@ void main() { }); } + testWidgets('移动端保留可拖动的悬浮快速添加任务入口', (tester) async { + await pumpAt(tester, size: const Size(360, 800)); + expect(fxTooltipFinder('快速记录任务(按住可拖动)'), findsOneWidget); + expect(fxTooltipFinder('记录任务'), findsOneWidget); + expect(tester.takeException(), isNull); + await disposeFxTestHost(tester); + }); + testWidgets('360×800 在 130% 系统字体下不产生布局异常', (tester) async { await pumpAt( tester, diff --git a/client/test/ui/fx_section_header_test.dart b/client/test/ui/fx_section_header_test.dart index 19699e0e..faaeb3ef 100644 --- a/client/test/ui/fx_section_header_test.dart +++ b/client/test/ui/fx_section_header_test.dart @@ -82,6 +82,50 @@ void main() { await disposeFxTestHost(tester); }); + for (final scale in [1.0, 1.3]) { + testWidgets('FxSectionHeader 360dp 常规字体 $scale 时标题操作同一行', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + + await tester.pumpWidget( + buildFxTestHost( + theme: AppTheme.lightTheme(), + home: MediaQuery( + data: MediaQueryData( + size: const Size(360, 800), + textScaler: TextScaler.linear(scale), + ), + child: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + // 首页卡片在 360dp 设备上的真实内容宽度。 + width: 304, + child: FxSectionHeader( + title: '今日任务', + trailing: '0/1 完成', + trailingWidget: FxButton( + label: '查看全部', + variant: FxButtonVariant.ghost, + size: FxButtonSize.sm, + onPressed: () {}, + ), + ), + ), + ), + ), + ), + ), + ); + + final titleRect = tester.getRect(find.text('今日任务')); + final actionRect = tester.getRect(find.text('查看全部')); + expect((titleRect.center.dy - actionRect.center.dy).abs(), lessThan(24)); + expect(tester.takeException(), isNull); + await disposeFxTestHost(tester); + }); + } + testWidgets('FxSectionHeader 大字体时将查看全部放到下一行右侧', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; addTearDown(() => debugDefaultTargetPlatformOverride = null); @@ -95,16 +139,23 @@ void main() { textScaler: TextScaler.linear(2), ), child: Scaffold( - body: Padding( - padding: const EdgeInsets.all(16), - child: FxSectionHeader( - title: '今日任务', - trailing: '3/5 已完成', - trailingWidget: FxButton( - label: '查看全部', - variant: FxButtonVariant.ghost, - size: FxButtonSize.sm, - onPressed: () {}, + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + // MediaQuery.size 不会约束测试视口;模拟实际 360dp 的卡片宽度。 + width: 328, + child: Padding( + padding: const EdgeInsets.all(16), + child: FxSectionHeader( + title: '今日任务', + trailing: '3/5 已完成', + trailingWidget: FxButton( + label: '查看全部', + variant: FxButtonVariant.ghost, + size: FxButtonSize.sm, + onPressed: () {}, + ), + ), ), ), ),