diff --git a/lib/models/study_plan.dart b/lib/models/study_plan.dart new file mode 100644 index 0000000..288b73d --- /dev/null +++ b/lib/models/study_plan.dart @@ -0,0 +1,78 @@ +/// 「学习」首页需要的一份排程摘要。 +/// +/// 目前由 [StudyPlan.sample] 提供示例数据;接上新内核后,这里应改为由排程 +/// 返回的真实数据(到期条目数、新识记额度、历史平均速度等),UI 不需要改。 +class StudyPlan { + const StudyPlan({ + required this.deckName, + required this.algorithm, + required this.totalItems, + required this.activatedItems, + required this.dueItems, + required this.newItems, + required this.secondsPerItem, + }); + + /// 当前单元集名称。 + final String deckName; + + /// 该单元集使用的调度算法标识,如 `NSP-0`、`SM-2`。 + final String algorithm; + + /// 单元集条目总数。 + final int totalItems; + + /// 已激活(学过至少一次)的条目数。 + final int activatedItems; + + /// 今天到期、需要复习的条目数。 + final int dueItems; + + /// 今天还可以引入的新识记条目数。 + final int newItems; + + /// 单个条目完成一轮多谜题测验的平均耗时(秒)。 + /// + /// 用于把「今天要学多少」翻译成用户能感知的时间承诺。实测速度应当来自 + /// 内核,这里先用经验值。 + final int secondsPerItem; + + /// 今天总共要处理的条目数。 + int get itemCount => dueItems + newItems; + + /// 今天是否无事可做。 + bool get isEmpty => itemCount == 0; + + /// 单元集整体完成度,0..1。 + double get progress => totalItems == 0 ? 0 : activatedItems / totalItems; + + /// 完成度百分比,保留一位小数。 + String get progressPercent => '${(progress * 100).toStringAsFixed(1)}%'; + + /// 预计用时(分钟,向上取整)。条目数为 0 时返回 0。 + int get estimatedMinutes => _minutes(itemCount, secondsPerItem); + + /// 预计用时的展示文案。 + String get estimatedLabel => isEmpty + ? '今天没有到期的内容' + : '预计用时 $estimatedMinutes 分钟'; + + /// 示例数据:高考必背古诗文,NSP-0 调度。 + static const StudyPlan sample = StudyPlan( + deckName: '高考必背古诗文', + algorithm: 'NSP-0', + totalItems: 950, + activatedItems: 295, + dueItems: 18, + newItems: 35, + secondsPerItem: 13, + ); + + static int _minutes(int itemCount, int secondsPerItem) { + if (itemCount <= 0 || secondsPerItem <= 0) { + return 0; + } + final int totalSeconds = itemCount * secondsPerItem; + return (totalSeconds + 59) ~/ 60; + } +} diff --git a/lib/pages/study_page.dart b/lib/pages/study_page.dart index bab0c86..189fcff 100644 --- a/lib/pages/study_page.dart +++ b/lib/pages/study_page.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; -import '../widgets/empty_state.dart'; +import '../models/study_plan.dart'; -/// 学习:今天到期的卡片与复习会话入口。 +/// 学习首页。 +/// +/// 信息层级刻意保持简单,和百词斩首页同构: +/// 当前材料 → 今日任务(要做什么 + 要多久)→ 唯一主行动 → 快捷入口。 +/// 但去掉它的商业化部分:没有广告位、没有课程倒计时、没有电商入口。 class StudyPage extends StatelessWidget { const StudyPage({super.key}); @@ -10,11 +14,302 @@ class StudyPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('学习')), - body: const EmptyState( - icon: Icons.school_outlined, - title: '开始今天的复习', - message: '待学习的卡片和到期需要复习的卡片会出现在这里。', + 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(), + ], ), ); } } + +/// 当前单元集:名称、算法、完成度。 +class _DeckCard extends StatelessWidget { + const _DeckCard({required this.plan}); + + final StudyPlan plan; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final TextTheme text = theme.textTheme; + + return _SurfaceCard( + onTap: () => _pending(context, '单元集切换'), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.menu_book_rounded, + color: colors.onPrimaryContainer, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + plan.deckName, + style: text.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '调度算法 ${plan.algorithm}', + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.unfold_more_rounded, + size: 20, + color: colors.onSurfaceVariant, + ), + ], + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: plan.progress, + minHeight: 8, + backgroundColor: colors.surfaceContainerHighest, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + '${plan.activatedItems} / ${plan.totalItems}', + style: text.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + const Spacer(), + Text( + plan.progressPercent, + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ); + } +} + +/// 今日任务:两个数字 + 时长承诺 + 唯一主行动。 +class _TodayPlanCard extends StatelessWidget { + const _TodayPlanCard({required this.plan}); + + final StudyPlan plan; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final TextTheme text = theme.textTheme; + + return _SurfaceCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('今日计划', style: text.titleMedium), + const SizedBox(height: 18), + Row( + children: [ + Expanded(child: _BigStat(label: '待复习', value: plan.dueItems)), + Expanded(child: _BigStat(label: '待新识', value: plan.newItems)), + ], + ), + const SizedBox(height: 16), + Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.schedule_rounded, + size: 16, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + plan.estimatedLabel, + style: text.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(height: 16), + FilledButton( + onPressed: plan.isEmpty ? null : () => _pending(context, '学习会话'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + textStyle: text.titleMedium, + ), + child: Text(plan.isEmpty ? '今天已完成' : '开始学习'), + ), + ], + ), + ); + } +} + +class _BigStat extends StatelessWidget { + const _BigStat({required this.label, required this.value}); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$value', + style: theme.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + const SizedBox(width: 6), + Text( + '条', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ], + ); + } +} + +/// 快捷入口。都是「学习域」内的次级入口,维护类操作不放这里。 +class _QuickActions extends StatelessWidget { + const _QuickActions(); + + static const List<(IconData, String)> _items = <(IconData, String)>[ + (Icons.star_border_rounded, '收藏夹'), + (Icons.volume_up_outlined, '语音缓存'), + (Icons.history_rounded, '最近学习'), + (Icons.replay_rounded, '难点重练'), + ]; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Row( + children: [ + for (final (int index, (IconData icon, String label)) in _items.indexed) + Expanded( + child: Padding( + padding: EdgeInsets.only(left: index == 0 ? 0 : 8), + child: Material( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () => _pending(context, label), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14), + child: Column( + children: [ + Icon(icon, size: 22, color: colors.primary), + const SizedBox(height: 8), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// 统一的卡片外观,避免每处重复写圆角和底色。 +class _SurfaceCard extends StatelessWidget { + const _SurfaceCard({required this.child, this.onTap}); + + final Widget child; + + /// 可点击时给出水波纹反馈;为 null 时是纯展示卡片。 + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final BorderRadius radius = BorderRadius.circular(20); + return Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: radius, + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + borderRadius: radius, + child: Padding(padding: const EdgeInsets.all(18), child: child), + ), + ); + } +} + +/// 尚未接入内核的入口,点击给出明确反馈而不是静默无响应。 +void _pending(BuildContext context, String feature) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$feature 尚未接入内核'), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(16, 0, 16, 88), + ), + ); +} diff --git a/test/study_plan_test.dart b/test/study_plan_test.dart new file mode 100644 index 0000000..10ab7b1 --- /dev/null +++ b/test/study_plan_test.dart @@ -0,0 +1,57 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile/models/study_plan.dart'; + +void main() { + test('预计用时按条目数与平均耗时向上取整', () { + // 18 + 35 = 53 条 × 13 秒 = 689 秒 → 12 分钟 + expect(StudyPlan.sample.itemCount, 53); + expect(StudyPlan.sample.estimatedMinutes, 12); + expect(StudyPlan.sample.estimatedLabel, '预计用时 12 分钟'); + }); + + test('没有任务时不给时长承诺,也不算空转', () { + const StudyPlan plan = StudyPlan( + deckName: '空单元集', + algorithm: 'SM-2', + totalItems: 10, + activatedItems: 0, + dueItems: 0, + newItems: 0, + secondsPerItem: 13, + ); + + expect(plan.isEmpty, isTrue); + expect(plan.estimatedMinutes, 0); + expect(plan.estimatedLabel, '今天没有到期的内容'); + }); + + test('完成度保留一位小数', () { + const StudyPlan plan = StudyPlan( + deckName: '高考必背古诗文', + algorithm: 'NSP-0', + totalItems: 950, + activatedItems: 295, + dueItems: 0, + newItems: 0, + secondsPerItem: 0, + ); + + expect(plan.progress, closeTo(0.3105, 0.0001)); + expect(plan.progressPercent, '31.1%'); + }); + + test('条目总数为 0 时不除零', () { + const StudyPlan plan = StudyPlan( + deckName: '空集', + algorithm: 'SM-2', + totalItems: 0, + activatedItems: 0, + dueItems: 0, + newItems: 0, + secondsPerItem: 13, + ); + + expect(plan.progress, 0); + expect(plan.progressPercent, '0.0%'); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 8f5bb04..255bdea 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -41,4 +41,20 @@ void main() { bar = tester.widget(find.byType(NavigationBar)); expect(bar.selectedIndex, 3); }); + + testWidgets('学习首页展示今日任务与唯一主行动', (WidgetTester tester) async { + await tester.pumpWidget(const KiriMemoApp()); + + expect(find.text('今日计划'), findsOneWidget); + expect(find.text('待复习'), findsOneWidget); + expect(find.text('待新识'), findsOneWidget); + expect(find.text('开始学习'), findsOneWidget); + + // 未接入内核的入口点击后要有反馈,不能静默无响应。 + await tester.tap(find.text('开始学习')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('学习会话 尚未接入内核'), findsOneWidget); + }); }