From 2a307b2f486fb03291d36ff8c5bfdd786b57b894 Mon Sep 17 00:00:00 2001 From: Wang Zhiyu Date: Sat, 24 Oct 2026 01:39:07 +0800 Subject: [PATCH] feat: icon-based status strip with search and log actions --- lib/models/app_status.dart | 53 ++++++++++++ lib/pages/study_page.dart | 165 ++++++++++++++++++++++++++++++++++--- test/widget_test.dart | 13 +++ 3 files changed, 218 insertions(+), 13 deletions(-) create mode 100644 lib/models/app_status.dart diff --git a/lib/models/app_status.dart b/lib/models/app_status.dart new file mode 100644 index 0000000..9467fa9 --- /dev/null +++ b/lib/models/app_status.dart @@ -0,0 +1,53 @@ +/// 云同步状态。 +enum SyncState { + /// 尚未建立同步(未登录或未配置)。 + unknown, + + /// 正在同步。 + syncing, + + /// 最近一次同步成功。 + success, + + /// 最近一次同步失败。 + failed, +} + +/// 顶部状态条所需的数据。 +/// +/// 目前由 [AppStatus.sample] 提供示例值;接入内核与同步服务后改为真实数据。 +class AppStatus { + const AppStatus({ + required this.streakDays, + required this.todayItems, + required this.sync, + }); + + /// 连续学习天数(计划接力)。 + final int streakDays; + + /// 今日涉及的条目总数,等于「已完成 + 待完成」,与今日计划卡里的 + /// 「待复习 + 待新识」不是同一个口径。 + final int todayItems; + + /// 云同步状态。 + final SyncState sync; + + String get streakLabel => '计划接力: $streakDays 天'; + + String get todayLabel => '今日记忆: $todayItems 条目'; + + String get syncLabel => switch (sync) { + SyncState.unknown => '云同步: 未连接', + SyncState.syncing => '云同步: 同步中', + SyncState.success => '云同步: 成功', + SyncState.failed => '云同步: 失败', + }; + + /// 示例数据。今日 59 条 = 已完成 6 条 + 待完成 53 条。 + static const AppStatus sample = AppStatus( + streakDays: 4, + todayItems: 59, + sync: SyncState.success, + ); +} diff --git a/lib/pages/study_page.dart b/lib/pages/study_page.dart index 189fcff..bc08946 100644 --- a/lib/pages/study_page.dart +++ b/lib/pages/study_page.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import '../models/app_status.dart'; import '../models/study_plan.dart'; /// 学习首页。 /// /// 信息层级刻意保持简单,和百词斩首页同构: -/// 当前材料 → 今日任务(要做什么 + 要多久)→ 唯一主行动 → 快捷入口。 +/// 顶部状态标识 → 当前材料 → 今日任务(要做什么 + 要多久)→ 唯一主行动 +/// → 快捷入口。 /// 但去掉它的商业化部分:没有广告位、没有课程倒计时、没有电商入口。 class StudyPage extends StatelessWidget { const StudyPage({super.key}); @@ -13,18 +15,155 @@ class StudyPage extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('学习')), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - children: const [ - _DeckCard(plan: StudyPlan.sample), - SizedBox(height: 14), - _TodayPlanCard(plan: StudyPlan.sample), - SizedBox(height: 18), - _QuickActions(), + body: SafeArea( + bottom: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 2), + child: _StatusStrip(status: AppStatus.sample), + ), + Expanded( + child: ListView( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 32), + children: const [ + _DeckCard(plan: StudyPlan.sample), + SizedBox(height: 14), + _TodayPlanCard(plan: StudyPlan.sample), + SizedBox(height: 18), + _QuickActions(), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// 顶部状态标识:计划接力 / 今日记忆 / 云同步,右侧是搜索与日志入口。 +/// +/// 状态用图形和颜色表达,文字只留给「今日记忆」这个需要被读懂的标签; +/// 完整语义("云同步: 成功" 等)放进 tooltip 与无障碍描述里。 +/// 窄屏时胶囊横向滚动,右侧按钮始终可见。 +class _StatusStrip extends StatelessWidget { + const _StatusStrip({required this.status}); + + final AppStatus status; + + static const Color _streak = Color(0xFFFFB300); + static const Color _success = Color(0xFF4CAF50); + + @override + Widget build(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + final (IconData syncIcon, Color syncColor) = switch (status.sync) { + SyncState.success => (Icons.cloud_done_rounded, _success), + SyncState.syncing => (Icons.cloud_sync_rounded, colors.tertiary), + SyncState.failed => (Icons.cloud_off_rounded, colors.error), + SyncState.unknown => (Icons.cloud_queue_rounded, colors.outline), + }; + + return Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _Pill( + leading: Icon( + Icons.bolt_rounded, + size: 16, + color: _streak, + ), + value: '${status.streakDays}', + tooltip: status.streakLabel, + ), + const SizedBox(width: 8), + _Pill( + label: '今日记忆', + value: '${status.todayItems}', + tooltip: status.todayLabel, + ), + const SizedBox(width: 8), + _Pill( + leading: Icon(syncIcon, size: 16, color: syncColor), + tooltip: status.syncLabel, + ), + ], + ), + ), + ), + IconButton( + icon: const Icon(Icons.search_rounded), + tooltip: '搜索条目', + visualDensity: VisualDensity.compact, + onPressed: () => _pending(context, '搜索'), + ), + IconButton( + icon: const Icon(Icons.receipt_long_outlined), + tooltip: '学习日志', + visualDensity: VisualDensity.compact, + onPressed: () => _pending(context, '学习日志'), + ), + ], + ); + } +} + +class _Pill extends StatelessWidget { + const _Pill({this.leading, this.label, this.value, this.tooltip}); + + final Widget? leading; + final String? label; + final String? value; + final String? tooltip; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final TextTheme text = theme.textTheme; + final Widget? leading = this.leading; + final String? label = this.label; + final String? value = this.value; + + final Widget pill = Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: ShapeDecoration( + color: colors.surfaceContainerHigh, + shape: const StadiumBorder(), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (leading != null) ...[ + leading, + const SizedBox(width: 6), + ], + if (label != null) + Text( + label, + style: text.labelMedium?.copyWith(color: colors.onSurfaceVariant), + ), + if (label != null && value != null) const SizedBox(width: 6), + if (value != null) + Text( + value, + style: text.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colors.onSurface, + ), + ), ], ), ); + + final String? tooltip = this.tooltip; + return tooltip == null ? pill : Tooltip(message: tooltip, child: pill); } } @@ -230,9 +369,9 @@ class _QuickActions extends StatelessWidget { static const List<(IconData, String)> _items = <(IconData, String)>[ (Icons.star_border_rounded, '收藏夹'), - (Icons.volume_up_outlined, '语音缓存'), - (Icons.history_rounded, '最近学习'), - (Icons.replay_rounded, '难点重练'), + (Icons.quiz_outlined, '自我测试'), + (Icons.attach_file_rounded, '附带资料'), + (Icons.tune_rounded, '计划设置'), ]; @override diff --git a/test/widget_test.dart b/test/widget_test.dart index 255bdea..45198da 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -50,6 +50,19 @@ void main() { expect(find.text('待新识'), findsOneWidget); expect(find.text('开始学习'), findsOneWidget); + // 顶部状态标识:接力用闪电、同步用云图标,不再用文字表达状态。 + expect(find.byIcon(Icons.bolt_rounded), findsOneWidget); + expect(find.text('今日记忆'), findsOneWidget); + expect(find.text('59'), findsOneWidget); + expect(find.byIcon(Icons.cloud_done_rounded), findsOneWidget); + expect(find.byTooltip('搜索条目'), findsOneWidget); + expect(find.byTooltip('学习日志'), findsOneWidget); + + // 学习域的四个快捷入口。 + for (final String label in ['收藏夹', '自我测试', '附带资料', '计划设置']) { + expect(find.text(label), findsOneWidget); + } + // 未接入内核的入口点击后要有反馈,不能静默无响应。 await tester.tap(find.text('开始学习')); await tester.pump();