diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4bff20e..48662d4 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,8 @@ + android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="false"> { + TargetPlatform.android: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.iOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.macOS: CupertinoPageTransitionsBuilder(), + TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(), + TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(), + }, + ), ); } } diff --git a/lib/models/account_profile.dart b/lib/models/account_profile.dart new file mode 100644 index 0000000..257972e --- /dev/null +++ b/lib/models/account_profile.dart @@ -0,0 +1,30 @@ +/// 账户页展示的用户资料。 +class AccountProfile { + const AccountProfile({ + required this.displayName, + required this.userId, + required this.stage, + required this.totalHours, + required this.streakDays, + }); + + final String displayName; + final String userId; + + /// 学习阶段,如「大一」。 + final String stage; + + /// 累计学习时长(小时)。 + final int totalHours; + + /// 连续学习天数。 + final int streakDays; + + static const AccountProfile sample = AccountProfile( + displayName: 'zhiyu', + userId: 'KM-1024', + stage: '大一', + totalHours: 42, + streakDays: 4, + ); +} diff --git a/lib/models/catalog.dart b/lib/models/catalog.dart new file mode 100644 index 0000000..46b1328 --- /dev/null +++ b/lib/models/catalog.dart @@ -0,0 +1,146 @@ +/// 管理页「单元集」列表项。 +class DeckInfo { + const DeckInfo({ + required this.id, + required this.title, + required this.algorithm, + required this.totalItems, + required this.activatedItems, + required this.lastStudied, + required this.description, + }); + + final String id; + final String title; + final String algorithm; + final int totalItems; + final int activatedItems; + + /// 最近一次学习的人话描述,如「3 天前」。 + final String lastStudied; + final String description; + + double get progress => totalItems == 0 ? 0 : activatedItems / totalItems; + + String get progressPercent => '${(progress * 100).toStringAsFixed(1)}%'; + + static const List samples = [ + DeckInfo( + id: 'cngk-t', + title: '高考必背古诗文', + algorithm: 'NSP-0', + totalItems: 950, + activatedItems: 295, + lastStudied: '3 天前', + description: '高考古诗文 60 篇', + ), + DeckInfo( + id: 'cet6-core', + title: 'CET-6 核心词汇', + algorithm: 'SM-2', + totalItems: 6204, + activatedItems: 2819, + lastStudied: '昨天', + description: '六级高频词,按词频排序', + ), + DeckInfo( + id: 'linalg-formula', + title: '线性代数公式', + algorithm: 'FSRS', + totalItems: 120, + activatedItems: 0, + lastStudied: '未开始', + description: '期末复习用,含推导步骤', + ), + ]; +} + +/// 管理页「计划」列表项。 +class PlanInfo { + const PlanInfo({ + required this.title, + required this.newPerDay, + required this.reviewPerDay, + required this.window, + required this.enabled, + required this.description, + }); + + final String title; + final int newPerDay; + final int reviewPerDay; + + /// 允许学习的时段,如「08:00–23:00」。 + final String window; + final bool enabled; + final String description; + + static const List samples = [ + PlanInfo( + title: '每日计划', + newPerDay: 35, + reviewPerDay: 200, + window: '08:00–23:00', + enabled: true, + description: '默认计划,每天引入 35 条新识记', + ), + PlanInfo( + title: '周末冲刺', + newPerDay: 0, + reviewPerDay: 400, + window: '09:00–22:00', + enabled: false, + description: '只复习不新增,周末启用', + ), + ]; +} + +/// 管理页「算法」列表项。 +class AlgorithmInfo { + const AlgorithmInfo({ + required this.name, + required this.version, + required this.description, + required this.parameters, + required this.isDefault, + }); + + final String name; + final String version; + final String description; + + /// 关键参数摘要,如「阈值 3 · 间隔上限 365 天」。 + final String parameters; + final bool isDefault; + + static const List samples = [ + AlgorithmInfo( + name: 'NSP-0', + version: '1.0', + description: '快速筛选用非间隔重复调度器', + parameters: '阈值 3 · 每日复习', + isDefault: true, + ), + AlgorithmInfo( + name: 'SM-2', + version: '1.0', + description: '第二代 SuperMemo,Anki 的默认调度器', + parameters: 'EF 1.3–2.5 · 间隔倍数 1.0', + isDefault: false, + ), + AlgorithmInfo( + name: 'FSRS', + version: '5.0', + description: '先进开放间隔重复调度器', + parameters: '可保留率 0.9 · 权重 19', + isDefault: false, + ), + AlgorithmInfo( + name: 'SM-15M', + version: '1.0', + description: '复杂间隔重复调度器(逆向工程实现)', + parameters: '最优间隔 · 遗忘曲线拟合', + isDefault: false, + ), + ]; +} diff --git a/lib/pages/about_page.dart b/lib/pages/about_page.dart new file mode 100644 index 0000000..c04c057 --- /dev/null +++ b/lib/pages/about_page.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../app_info.dart'; + +/// 关于页。 +class AboutPage extends StatelessWidget { + const AboutPage({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('关于')), + body: ListView( + padding: const EdgeInsets.fromLTRB(24, 32, 24, 32), + children: [ + Center( + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + color: colors.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Icon( + Icons.menu_book_rounded, + size: 36, + color: colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + Center(child: Text(appName, style: theme.textTheme.headlineSmall)), + const SizedBox(height: 4), + Center( + child: Text( + '版本 $appVersion', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 28), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + 'KiriMemo 是 HeurAMS 的移动端前端:用单元集组织材料,' + '用多种谜题测验同一条知识,调度算法可以按单元集替换。', + style: theme.textTheme.bodyMedium?.copyWith(height: 1.6), + ), + ), + const SizedBox(height: 24), + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.description_outlined, + color: colors.onSurfaceVariant, + ), + title: const Text('开源许可'), + subtitle: const Text('MIT License'), + trailing: Icon( + Icons.chevron_right_rounded, + color: colors.onSurfaceVariant, + ), + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('许可文本尚未接入'), + behavior: SnackBarBehavior.floating, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/account_page.dart b/lib/pages/account_page.dart index 641ae8f..7d15f80 100644 --- a/lib/pages/account_page.dart +++ b/lib/pages/account_page.dart @@ -1,20 +1,166 @@ import 'package:flutter/material.dart'; -import '../widgets/empty_state.dart'; +import '../models/account_profile.dart'; +import '../widgets/grouped_list_section.dart'; +import '../widgets/stat_block.dart'; +import 'about_page.dart'; +import 'docs_page.dart'; +import 'feedback_page.dart'; +import 'settings_page.dart'; -/// 账户:登录、同步与偏好设置。 +/// 账户页:资料、学习数据、功能入口。 class AccountPage extends StatelessWidget { const AccountPage({super.key}); + static const AccountProfile _profile = AccountProfile.sample; + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('账户')), - body: const EmptyState( - icon: Icons.person_outline, - title: '账户与同步', - message: '登录后可以在多台设备之间同步学习记录。', + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 24), + children: const [ + _ProfileCard(profile: _profile), + SizedBox(height: 12), + _StatsCard(profile: _profile), + SizedBox(height: 22), + _ActionList(), + ], ), ); } } + +class _ProfileCard extends StatelessWidget { + const _ProfileCard({required this.profile}); + + final AccountProfile profile; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + CircleAvatar( + radius: 30, + backgroundColor: colors.primaryContainer, + child: Text( + profile.displayName.characters.first, + style: theme.textTheme.titleLarge?.copyWith( + color: colors.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + profile.displayName, + style: theme.textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'ID ${profile.userId} · ${profile.stage}', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right_rounded, + color: colors.onSurfaceVariant, + ), + ], + ), + ); + } +} + +class _StatsCard extends StatelessWidget { + const _StatsCard({required this.profile}); + + final AccountProfile profile; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + children: [ + Expanded( + child: StatBlock( + label: '累计学习', + value: '${profile.totalHours}', + unit: '小时', + ), + ), + Expanded( + child: StatBlock( + label: '坚持', + value: '${profile.streakDays}', + unit: '天', + ), + ), + ], + ), + ); + } +} + +/// 竖直排列的长按钮列表,样式参考原生 Android 设置页。 +class _ActionList extends StatelessWidget { + const _ActionList(); + + static const List<(IconData, String)> _items = <(IconData, String)>[ + (Icons.settings_outlined, '设置'), + (Icons.menu_book_outlined, '文档与帮助'), + (Icons.feedback_outlined, '反馈问题'), + (Icons.info_outline_rounded, '关于 KiriMemo'), + ]; + + @override + Widget build(BuildContext context) { + return GroupedListSection( + tiles: [ + for (final (IconData icon, String label) in _items) + GroupedListTile( + icon: icon, + title: label, + onTap: () => _open(context, label), + ), + ], + ); + } + + void _open(BuildContext context, String label) { + final Widget page = switch (label) { + '设置' => const SettingsPage(), + '文档与帮助' => const DocsPage(), + '反馈问题' => const FeedbackPage(), + _ => const AboutPage(), + }; + Navigator.of(context).push( + MaterialPageRoute(builder: (BuildContext _) => page), + ); + } +} diff --git a/lib/pages/docs_page.dart b/lib/pages/docs_page.dart new file mode 100644 index 0000000..3248fbe --- /dev/null +++ b/lib/pages/docs_page.dart @@ -0,0 +1,312 @@ +import 'package:flutter/material.dart'; + +/// 文档与帮助页:内容区留空,底部是可上滑的 LLM 助手对话框。 +class DocsPage extends StatelessWidget { + const DocsPage({super.key}); + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('文档与帮助')), + body: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + children: [ + // 内容区:整体留白,只保留底色和边框,深浅色都跟随主题。 + Padding( + padding: const EdgeInsets.all(16), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: colors.outlineVariant), + ), + alignment: Alignment.center, + child: Text( + '文档内容将在这里显示', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + _AssistantSheet(availableHeight: constraints.maxHeight), + ], + ); + }, + ), + ); + } +} + +/// 底部助手:收起时只露出一条,上滑或点击后展开成对话框。 +class _AssistantSheet extends StatefulWidget { + const _AssistantSheet({required this.availableHeight}); + + /// 父级可用高度,用于把拖拽像素换算成比例。 + final double availableHeight; + + @override + State<_AssistantSheet> createState() => _AssistantSheetState(); +} + +class _AssistantSheetState extends State<_AssistantSheet> { + static const double _collapsed = 0.13; + static const double _expandedSize = 0.92; + + final DraggableScrollableController _controller = + DraggableScrollableController(); + final TextEditingController _input = TextEditingController(); + + static const List<(bool, String)> _messages = <(bool, String)>[ + ( + false, + '你好,我是 KiriMemo 的助手。文档还没接入,但关于用法和概念的问题可以先问我。', + ), + ]; + + @override + void dispose() { + _controller.dispose(); + _input.dispose(); + super.dispose(); + } + + void _animateTo(double target) { + if (!_controller.isAttached) { + return; + } + _controller.animateTo( + target, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + } + + void _toggle() { + if (!_controller.isAttached) { + return; + } + final bool open = _controller.size > 0.4; + _animateTo(open ? _collapsed : _expandedSize); + } + + void _onDragUpdate(DragUpdateDetails details) { + if (!_controller.isAttached || widget.availableHeight <= 0) { + return; + } + final double next = + (_controller.size - details.delta.dy / widget.availableHeight) + .clamp(_collapsed, _expandedSize); + _controller.jumpTo(next); + } + + void _onDragEnd(DragEndDetails details) { + if (!_controller.isAttached) { + return; + } + final bool open = + _controller.size > (_collapsed + _expandedSize) / 2; + _animateTo(open ? _expandedSize : _collapsed); + } + + void _send() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('助手尚未接入模型'), + behavior: SnackBarBehavior.floating, + ), + ); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return DraggableScrollableSheet( + controller: _controller, + initialChildSize: _collapsed, + minChildSize: _collapsed, + maxChildSize: _expandedSize, + builder: (BuildContext context, ScrollController scrollController) { + return Container( + decoration: BoxDecoration( + color: colors.surfaceContainerHigh, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + border: Border(top: BorderSide(color: colors.outlineVariant)), + ), + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final bool expanded = constraints.maxHeight > 200; + return Column( + children: [ + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _toggle, + onVerticalDragUpdate: _onDragUpdate, + onVerticalDragEnd: _onDragEnd, + child: _header(theme, colors, expanded: expanded), + ), + // 列表必须始终挂载:DraggableScrollableController 的 + // isAttached 取决于内部 ScrollController 是否有 client, + // 收起时若拿掉列表,就再也展不开了。 + Expanded( + child: ListView( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + children: [ + if (expanded) + for (final (bool fromUser, String text) in _messages) + _Bubble(fromUser: fromUser, text: text), + ], + ), + ), + if (expanded) _inputRow(theme, colors), + ], + ); + }, + ), + ); + }, + ); + } + + Widget _header( + ThemeData theme, + ColorScheme colors, { + required bool expanded, + }) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 10), + child: Column( + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.auto_awesome_rounded, size: 18, color: colors.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('问问 KiriMemo', style: theme.textTheme.titleSmall), + // 收起时只有一条横栏,放不下副标题就不放。 + if (expanded) + Text( + '文档暂未接入,可以先问概念与用法', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon( + expanded + ? Icons.keyboard_arrow_down_rounded + : Icons.keyboard_arrow_up_rounded, + color: colors.onSurfaceVariant, + ), + ], + ), + ], + ), + ); + } + + Widget _inputRow(ThemeData theme, ColorScheme colors) { + return Padding( + padding: EdgeInsets.fromLTRB( + 16, + 0, + 16, + 12 + MediaQuery.viewPaddingOf(context).bottom, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _input, + textInputAction: TextInputAction.send, + onSubmitted: (_) => _send(), + decoration: InputDecoration( + hintText: '问点什么…', + isDense: true, + filled: true, + fillColor: colors.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + ), + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _send, + icon: const Icon(Icons.arrow_upward_rounded), + tooltip: '发送', + ), + ], + ), + ); + } +} + +class _Bubble extends StatelessWidget { + const _Bubble({required this.fromUser, required this.text}); + + final bool fromUser; + final String text; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Align( + alignment: fromUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: const BoxConstraints(maxWidth: 320), + decoration: BoxDecoration( + color: fromUser + ? colors.primaryContainer + : colors.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(fromUser ? 16 : 4), + bottomRight: Radius.circular(fromUser ? 4 : 16), + ), + ), + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + color: fromUser ? colors.onPrimaryContainer : colors.onSurface, + ), + ), + ), + ); + } +} diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart new file mode 100644 index 0000000..01b6727 --- /dev/null +++ b/lib/pages/feedback_page.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +/// 反馈问题:一个最简的提交表单。 +class FeedbackPage extends StatefulWidget { + const FeedbackPage({super.key}); + + @override + State createState() => _FeedbackPageState(); +} + +class _FeedbackPageState extends State { + final TextEditingController _text = TextEditingController(); + + @override + void dispose() { + _text.dispose(); + super.dispose(); + } + + void _submit() { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('反馈已记录(示例,尚未接入后端)'), + behavior: SnackBarBehavior.floating, + ), + ); + _text.clear(); + } + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('反馈问题')), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + Text('描述一下遇到的问题或建议', style: theme.textTheme.titleSmall), + const SizedBox(height: 12), + TextField( + controller: _text, + minLines: 5, + maxLines: 10, + decoration: InputDecoration( + hintText: '例如:在学习页点击「开始学习」没有反应', + filled: true, + fillColor: colors.surfaceContainerLow, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colors.outlineVariant), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: colors.outlineVariant), + ), + ), + ), + const SizedBox(height: 8), + Text( + '提交时会附带应用版本与设备信息,方便定位问题。', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + FilledButton( + onPressed: _submit, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: const Text('提交反馈'), + ), + ], + ), + ); + } +} diff --git a/lib/pages/manage_page.dart b/lib/pages/manage_page.dart index ca89623..96044b8 100644 --- a/lib/pages/manage_page.dart +++ b/lib/pages/manage_page.dart @@ -1,19 +1,445 @@ import 'package:flutter/material.dart'; -import '../widgets/empty_state.dart'; +import '../models/catalog.dart'; -/// 管理:牌组与卡片的增删改查。 +/// 管理页:单元集 / 计划 / 算法三个维度。 class ManagePage extends StatelessWidget { const ManagePage({super.key}); @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('管理')), - body: const EmptyState( - icon: Icons.library_books_outlined, - title: '管理卡片与牌组', - message: '在这里添加、编辑和整理你的学习材料。', + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('管理'), + bottom: const TabBar( + tabs: [ + Tab(text: '单元集'), + Tab(text: '计划'), + Tab(text: '算法'), + ], + ), + ), + body: const TabBarView( + children: [ + _DeckTab(), + _PlanTab(), + _AlgorithmTab(), + ], + ), + ), + ); + } +} + +class _DeckTab extends StatelessWidget { + const _DeckTab(); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: DeckInfo.samples.length + 1, + separatorBuilder: (BuildContext context, int index) => + const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return _CountLabel(text: '共 ${DeckInfo.samples.length} 个单元集'); + } + return _DeckCard(deck: DeckInfo.samples[index - 1]); + }, + ); + } +} + +class _DeckCard extends StatelessWidget { + const _DeckCard({required this.deck}); + + final DeckInfo deck; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final TextTheme text = theme.textTheme; + + return _ManageCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Badge(icon: Icons.menu_book_rounded, color: colors.primary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + deck.title, + style: text.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + deck.description, + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + _Chip(label: deck.algorithm), + ], + ), + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: LinearProgressIndicator( + value: deck.progress, + minHeight: 6, + backgroundColor: colors.surfaceContainerHighest, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: _MetaText( + '${deck.activatedItems} / ${deck.totalItems} · ${deck.progressPercent}', + ), + ), + _MetaText('最近 ${deck.lastStudied}'), + ], + ), + const SizedBox(height: 6), + _MetaText('标识 ${deck.id}'), + ], + ), + ); + } +} + +class _PlanTab extends StatelessWidget { + const _PlanTab(); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: PlanInfo.samples.length + 1, + separatorBuilder: (BuildContext context, int index) => + const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return _CountLabel(text: '共 ${PlanInfo.samples.length} 个计划'); + } + return _PlanCard(plan: PlanInfo.samples[index - 1]); + }, + ); + } +} + +class _PlanCard extends StatelessWidget { + const _PlanCard({required this.plan}); + + final PlanInfo plan; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final TextTheme text = theme.textTheme; + + return _ManageCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Badge(icon: Icons.event_note_rounded, color: colors.tertiary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(plan.title, style: text.titleMedium), + const SizedBox(height: 2), + Text( + plan.description, + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + _Chip( + label: plan.enabled ? '启用' : '停用', + emphasized: plan.enabled, + ), + ], + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _Metric( + label: '新识记', + value: '${plan.newPerDay}', + suffix: '/ 天', + ), + ), + Expanded( + child: _Metric( + label: '复习上限', + value: '${plan.reviewPerDay}', + suffix: '/ 天', + ), + ), + ], + ), + const SizedBox(height: 10), + _MetaText('时段 ${plan.window}'), + ], + ), + ); + } +} + +class _AlgorithmTab extends StatelessWidget { + const _AlgorithmTab(); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + itemCount: AlgorithmInfo.samples.length + 1, + separatorBuilder: (BuildContext context, int index) => + const SizedBox(height: 12), + itemBuilder: (BuildContext context, int index) { + if (index == 0) { + return _CountLabel( + text: '共 ${AlgorithmInfo.samples.length} 个调度算法', + ); + } + return _AlgorithmCard(algorithm: AlgorithmInfo.samples[index - 1]); + }, + ); + } +} + +class _AlgorithmCard extends StatelessWidget { + const _AlgorithmCard({required this.algorithm}); + + final AlgorithmInfo algorithm; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final TextTheme text = theme.textTheme; + + return _ManageCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Badge(icon: Icons.functions_rounded, color: colors.secondary), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(algorithm.name, style: text.titleMedium), + const SizedBox(width: 6), + _MetaText('v${algorithm.version}'), + ], + ), + const SizedBox(height: 2), + Text( + algorithm.description, + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + if (algorithm.isDefault) ...[ + const SizedBox(width: 8), + const _Chip(label: '默认', emphasized: true), + ], + ], + ), + const SizedBox(height: 12), + _MetaText(algorithm.parameters), + ], + ), + ); + } +} + +/// 卡片统一外观。 +class _ManageCard extends StatelessWidget { + const _ManageCard({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + ), + child: child, + ); + } +} + +class _Badge extends StatelessWidget { + const _Badge({required this.icon, required this.color}); + + final IconData icon; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 20, color: color), + ); + } +} + +class _Chip extends StatelessWidget { + const _Chip({required this.label, this.emphasized = false}); + + final String label; + final bool emphasized; + + @override + Widget build(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: ShapeDecoration( + color: emphasized + ? colors.primaryContainer + : colors.surfaceContainerHighest, + shape: const StadiumBorder(), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: emphasized + ? colors.onPrimaryContainer + : colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _Metric extends StatelessWidget { + const _Metric({required this.label, required this.value, this.suffix}); + + final String label; + final String value; + final String? suffix; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final String? suffix = this.suffix; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 2), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + value, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (suffix != null) ...[ + const SizedBox(width: 4), + Text( + suffix, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ); + } +} + +class _MetaText extends StatelessWidget { + const _MetaText(this.value); + + final String value; + + @override + Widget build(BuildContext context) { + return Text( + value, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } +} + +class _CountLabel extends StatelessWidget { + const _CountLabel({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, ), ); } diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart new file mode 100644 index 0000000..c60895f --- /dev/null +++ b/lib/pages/settings_page.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; + +import '../app_info.dart'; + +/// 设置页,按 Android 系统设置的分组列表组织。 +class SettingsPage extends StatefulWidget { + const SettingsPage({super.key}); + + @override + State createState() => _SettingsPageState(); +} + +class _SettingsPageState extends State { + bool _requeueOnWrong = true; + bool _readAloud = true; + bool _autoSync = true; + bool _wifiOnly = true; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('设置')), + body: ListView( + padding: const EdgeInsets.only(bottom: 32), + children: [ + const _SectionHeader('学习'), + _ValueTile( + icon: Icons.school_outlined, + title: '每日新识记上限', + value: '35 条', + onTap: () => _pending('每日新识记上限'), + ), + _ValueTile( + icon: Icons.replay_rounded, + title: '每日复习上限', + value: '200 条', + onTap: () => _pending('每日复习上限'), + ), + _ValueTile( + icon: Icons.schedule_rounded, + title: '学习时段', + value: '08:00–23:00', + onTap: () => _pending('学习时段'), + ), + _SwitchTile( + icon: Icons.restart_alt_rounded, + title: '答错后重新排队', + subtitle: '同一轮里再出现一次', + value: _requeueOnWrong, + onChanged: (bool value) => setState(() => _requeueOnWrong = value), + ), + + const _SectionHeader('测验'), + _ValueTile( + icon: Icons.quiz_outlined, + title: '默认谜题类型', + value: '完形填空', + onTap: () => _pending('默认谜题类型'), + ), + _SwitchTile( + icon: Icons.volume_up_outlined, + title: '自动朗读', + subtitle: '进入谜题时播放语音', + value: _readAloud, + onChanged: (bool value) => setState(() => _readAloud = value), + ), + + const _SectionHeader('外观'), + _ValueTile( + icon: Icons.dark_mode_outlined, + title: '主题', + value: '跟随系统', + onTap: () => _pending('主题'), + ), + _ValueTile( + icon: Icons.text_fields_rounded, + title: '字号', + value: '标准', + onTap: () => _pending('字号'), + ), + + const _SectionHeader('同步'), + _SwitchTile( + icon: Icons.cloud_sync_outlined, + title: '自动同步', + subtitle: '学完一轮后自动上传', + value: _autoSync, + onChanged: (bool value) => setState(() => _autoSync = value), + ), + _SwitchTile( + icon: Icons.wifi_rounded, + title: '仅在 Wi-Fi 下同步', + value: _wifiOnly, + onChanged: (bool value) => setState(() => _wifiOnly = value), + ), + _ValueTile( + icon: Icons.sync_rounded, + title: '立即同步', + onTap: () => _pending('立即同步'), + ), + + const _SectionHeader('关于'), + _ValueTile( + icon: Icons.info_outline_rounded, + title: '版本', + value: appVersion, + ), + _ValueTile( + icon: Icons.description_outlined, + title: '开源许可', + onTap: () => _pending('开源许可'), + ), + ], + ), + ); + } + + void _pending(String feature) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('$feature 尚未接入内核'), + behavior: SnackBarBehavior.floating, + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), + child: Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} + +class _ValueTile extends StatelessWidget { + const _ValueTile({ + required this.icon, + required this.title, + this.value, + this.onTap, + }); + + final IconData icon; + final String title; + final String? value; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + final String? value = this.value; + + return ListTile( + leading: Icon(icon, color: colors.onSurfaceVariant), + title: Text(title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (value != null) + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (onTap != null) ...[ + const SizedBox(width: 4), + Icon( + Icons.chevron_right_rounded, + size: 20, + color: colors.onSurfaceVariant, + ), + ], + ], + ), + onTap: onTap, + ); + } +} + +class _SwitchTile extends StatelessWidget { + const _SwitchTile({ + required this.icon, + required this.title, + required this.value, + required this.onChanged, + this.subtitle, + }); + + final IconData icon; + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + return SwitchListTile( + secondary: Icon(icon, color: colors.onSurfaceVariant), + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + value: value, + onChanged: onChanged, + ); + } +} diff --git a/lib/pages/study_page.dart b/lib/pages/study_page.dart index bc08946..204397d 100644 --- a/lib/pages/study_page.dart +++ b/lib/pages/study_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../models/app_status.dart'; import '../models/study_plan.dart'; +import '../widgets/stat_block.dart'; /// 学习首页。 /// @@ -278,8 +279,20 @@ class _TodayPlanCard extends StatelessWidget { const SizedBox(height: 18), Row( children: [ - Expanded(child: _BigStat(label: '待复习', value: plan.dueItems)), - Expanded(child: _BigStat(label: '待新识', value: plan.newItems)), + Expanded( + child: StatBlock( + label: '待复习', + value: '${plan.dueItems}', + unit: '条', + ), + ), + Expanded( + child: StatBlock( + label: '待新识', + value: '${plan.newItems}', + unit: '条', + ), + ), ], ), const SizedBox(height: 16), @@ -317,52 +330,6 @@ class _TodayPlanCard extends StatelessWidget { } } -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(); diff --git a/lib/widgets/grouped_list_section.dart b/lib/widgets/grouped_list_section.dart new file mode 100644 index 0000000..f0c6100 --- /dev/null +++ b/lib/widgets/grouped_list_section.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; + +/// 一组圆角卡片式列表项,行间自动加分隔线。 +/// +/// 容器用 [Material] 而不是 `Container`:`InkWell` 的水波纹画在最近的 +/// `Material` 祖先上,普通容器会让墨水落到外层 Scaffold,圆角裁剪失效, +/// 按住时就变成直角矩形。 +class GroupedListSection extends StatelessWidget { + const GroupedListSection({ + required this.tiles, + this.dividerIndent = 58, + super.key, + }); + + final List tiles; + + /// 分隔线左侧缩进,默认与图标后的文字对齐。 + final double dividerIndent; + + @override + Widget build(BuildContext context) { + final ColorScheme colors = Theme.of(context).colorScheme; + + return Material( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (final (int index, Widget tile) in tiles.indexed) ...[ + tile, + if (index != tiles.length - 1) + Divider( + height: 1, + thickness: 1, + indent: dividerIndent, + color: colors.outlineVariant.withValues(alpha: 0.6), + ), + ], + ], + ), + ); + } +} + +/// 分组列表里的一行:图标 + 标题(+ 可选副标题)+ 右侧箭头。 +class GroupedListTile extends StatelessWidget { + const GroupedListTile({ + required this.icon, + required this.title, + this.subtitle, + this.onTap, + this.trailing, + super.key, + }); + + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + /// 右侧自定义内容;不传且有 [onTap] 时显示箭头。 + final Widget? trailing; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final String? subtitle = this.subtitle; + final Widget? trailing = this.trailing; + + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 18), + child: Row( + children: [ + Icon(icon, size: 22, color: colors.primary), + const SizedBox(width: 18), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.bodyLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (trailing != null) + trailing + else if (onTap != null) + Icon( + Icons.chevron_right_rounded, + size: 22, + color: colors.onSurfaceVariant, + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/stat_block.dart b/lib/widgets/stat_block.dart new file mode 100644 index 0000000..6c01810 --- /dev/null +++ b/lib/widgets/stat_block.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +/// 「标签 + 大数字 +(可选)单位」的组合,学习页与账户页共用。 +class StatBlock extends StatelessWidget { + const StatBlock({ + required this.label, + required this.value, + this.unit, + super.key, + }); + + final String label; + final String value; + final String? unit; + + @override + Widget build(BuildContext context) { + final ThemeData theme = Theme.of(context); + final ColorScheme colors = theme.colorScheme; + final String? unit = this.unit; + + 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: [ + Flexible( + child: Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.displaySmall?.copyWith( + fontWeight: FontWeight.w700, + height: 1.1, + ), + ), + ), + if (unit != null) ...[ + const SizedBox(width: 6), + Text( + unit, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ); + } +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 45198da..d2eb962 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mobile/main.dart'; +import 'package:mobile/widgets/grouped_list_section.dart'; /// 在导航栏内按标签定位,避免和页面标题的相同文字混淆。 Finder navLabel(String label) { @@ -11,9 +12,22 @@ Finder navLabel(String label) { ); } +/// 手机尺寸(400 × 800 dp),避免默认 800×600 掩盖溢出问题。 +void usePhoneSurface(WidgetTester tester) { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); +} + +Future pumpApp(WidgetTester tester) async { + usePhoneSurface(tester); + await tester.pumpWidget(const KiriMemoApp()); + await tester.pumpAndSettle(); +} + void main() { testWidgets('底部导航提供学习 / 管理 / 统计 / 账户四个入口', (WidgetTester tester) async { - await tester.pumpWidget(const KiriMemoApp()); + await pumpApp(tester); expect(find.byType(NavigationBar), findsOneWidget); expect(find.byType(NavigationDestination), findsNWidgets(4)); @@ -23,8 +37,19 @@ void main() { } }); + testWidgets('Android 端使用水平滑入转场,而非预测式返回', (WidgetTester tester) async { + await pumpApp(tester); + + final MaterialApp app = tester.widget(find.byType(MaterialApp)); + final PageTransitionsTheme transitions = app.theme!.pageTransitionsTheme; + expect( + transitions.builders[TargetPlatform.android], + isA(), + ); + }); + testWidgets('默认停留在学习页,点击其它入口会切换', (WidgetTester tester) async { - await tester.pumpWidget(const KiriMemoApp()); + await pumpApp(tester); NavigationBar bar = tester.widget(find.byType(NavigationBar)); expect(bar.selectedIndex, 0); @@ -43,7 +68,7 @@ void main() { }); testWidgets('学习首页展示今日任务与唯一主行动', (WidgetTester tester) async { - await tester.pumpWidget(const KiriMemoApp()); + await pumpApp(tester); expect(find.text('今日计划'), findsOneWidget); expect(find.text('待复习'), findsOneWidget); @@ -62,12 +87,97 @@ void main() { for (final String label in ['收藏夹', '自我测试', '附带资料', '计划设置']) { expect(find.text(label), findsOneWidget); } + }); + + testWidgets('未接入内核的入口点击后给出反馈', (WidgetTester tester) async { + await pumpApp(tester); - // 未接入内核的入口点击后要有反馈,不能静默无响应。 await tester.tap(find.text('开始学习')); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); expect(find.text('学习会话 尚未接入内核'), findsOneWidget); }); + + testWidgets('管理页有三个维度,各自列出卡片', (WidgetTester tester) async { + await pumpApp(tester); + await tester.tap(navLabel('管理')); + await tester.pumpAndSettle(); + + for (final String label in ['单元集', '计划', '算法']) { + expect(find.text(label), findsOneWidget); + } + expect(find.text('高考必背古诗文'), findsOneWidget); + expect(find.text('标识 cngk-t'), findsOneWidget); + + await tester.tap(find.text('计划')); + await tester.pumpAndSettle(); + expect(find.text('每日计划'), findsOneWidget); + expect(find.text('周末冲刺'), findsOneWidget); + expect(find.text('新识记'), findsNWidgets(2)); + + await tester.tap(find.text('算法')); + await tester.pumpAndSettle(); + expect(find.text('NSP-0'), findsOneWidget); + expect(find.text('默认'), findsOneWidget); + }); + + testWidgets('账户页展示资料、学习数据与四个功能入口', (WidgetTester tester) async { + await pumpApp(tester); + await tester.tap(navLabel('账户')); + await tester.pumpAndSettle(); + + expect(find.text('zhiyu'), findsOneWidget); + expect(find.text('ID KM-1024 · 大一'), findsOneWidget); + expect(find.text('累计学习'), findsOneWidget); + expect(find.text('坚持'), findsOneWidget); + + // 四个入口是同一张分组卡片里的四行。 + expect(find.byType(GroupedListSection), findsOneWidget); + expect(find.byType(GroupedListTile), findsNWidgets(4)); + + for (final String label in [ + '设置', + '文档与帮助', + '反馈问题', + '关于 KiriMemo', + ]) { + expect(find.text(label), findsOneWidget); + } + }); + + testWidgets('账户页可以进入设置页,设置项按分组排列', (WidgetTester tester) async { + await pumpApp(tester); + await tester.tap(navLabel('账户')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('设置')); + await tester.pumpAndSettle(); + + expect(find.text('每日新识记上限'), findsOneWidget); + expect(find.text('答错后重新排队'), findsOneWidget); + + // 分组在下方,需要滚动到可见。 + await tester.scrollUntilVisible(find.text('版本'), 200); + expect(find.text('版本'), findsOneWidget); + expect(find.text('1.0.0'), findsOneWidget); + }); + + testWidgets('文档与帮助页底部有可上滑的助手', (WidgetTester tester) async { + await pumpApp(tester); + await tester.tap(navLabel('账户')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('文档与帮助')); + await tester.pumpAndSettle(); + + expect(find.text('文档内容将在这里显示'), findsOneWidget); + expect(find.text('问问 KiriMemo'), findsOneWidget); + + // 收起时是横栏,点开后出现输入框。 + expect(find.byType(TextField), findsNothing); + await tester.tap(find.text('问问 KiriMemo')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); + }); }