Skip to content
5 changes: 4 additions & 1 deletion client/lib/ui/widgets/fx_section_header.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
138 changes: 54 additions & 84 deletions client/lib/widgets/dashboard/dashboard_body.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -294,8 +292,9 @@ class _DashboardBodyState extends State<DashboardBody> {
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)),
Expand All @@ -311,14 +310,24 @@ class _DashboardBodyState extends State<DashboardBody> {
).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,
Expand Down Expand Up @@ -511,7 +520,9 @@ class _DashboardBodyState extends State<DashboardBody> {
children: [
FxSectionHeader(
title: '习惯',
trailing: '$checked/${widget.habits.length} 已打卡',
trailing: widget.habits.isEmpty
? '暂无习惯'
: '$checked/${widget.habits.length} 已打卡',
trailingWidget:
widget.onViewAllHabits == null
? null
Expand All @@ -523,7 +534,7 @@ class _DashboardBodyState extends State<DashboardBody> {
),
),
if (visible.isEmpty)
_empty('还没有习惯记录')
_empty('还没有习惯记录', compact: mobile)
else
...visible.map((habit) => _habitRow(habit, mobile)),
],
Expand Down Expand Up @@ -625,6 +636,7 @@ class _DashboardBodyState extends State<DashboardBody> {

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(
Expand Down Expand Up @@ -659,70 +671,59 @@ class _DashboardBodyState extends State<DashboardBody> {
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),
);
}

Expand All @@ -742,8 +743,8 @@ class _DashboardBodyState extends State<DashboardBody> {
}
}

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(
Expand Down Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions client/test/screens/home_screen_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 61 additions & 10 deletions client/test/ui/fx_section_header_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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: () {},
),
),
),
),
),
Expand Down
Loading