feat: manage, account and help pages

- 管理页:单元集 / 计划 / 算法三个维度,卡片带元数据
- 账户页:资料、学习数据、竖直分组的四个功能入口
- 新增设置、文档与帮助(底部可上滑助手)、反馈、关于四个页面
- 抽出 StatBlock 与 GroupedListSection 共用组件
- 关闭 Android 预测式返回,转场改为 M3 水平滑入
This commit is contained in:
pluv committed 2026-10-24 15:21:30 +08:00
1 parent 2a307b2f48
commit 22715f7247
15 files changed
+1789 -72

No files matched your search

+2 -1
View File
@@ -2,7 +2,8 @@
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="false">
<activity
android:name=".MainActivity"
android:exported="true"
+8
View File
@@ -0,0 +1,8 @@
/// 应用级元信息。
///
/// [appName] 需与各平台原生配置保持一致(Android 的 `app_name` 字符串资源、
/// iOS 的 `CFBundleDisplayName`、linux/windows 的 `APPLICATION_TITLE`)。
const String appName = 'KiriMemo';
/// 展示用的版本号,与 pubspec.yaml 的 version 保持同步。
const String appVersion = '1.0.0';
+16 -5
View File
@@ -1,12 +1,9 @@
import 'package:flutter/cupertino.dart' show CupertinoPageTransitionsBuilder;
import 'package:flutter/material.dart';
import 'app_info.dart';
import 'app_shell.dart';
/// Dart 层的应用名。各平台原生的应用名(窗口标题、桌面图标)由各自的配置
/// 文件提供:android 的 `app_name` 字符串资源、ios 的 `CFBundleDisplayName`、
/// linux/windows 的 `APPLICATION_TITLE`,文本与这里保持一致。
const String appName = 'KiriMemo';
void main() {
runApp(const KiriMemoApp());
}
@@ -31,6 +28,20 @@ class KiriMemoApp extends StatelessWidget {
seedColor: const Color(0xFF4C6EF5),
brightness: brightness,
),
// Flutter 3.47 起 Android 默认用 PredictiveBackPageTransitionsBuilder,
// 配合 targetSdk 36(Android 15+ 默认开启预测式返回)会在单路由外壳里
// 出现"预览走完直接退应用"的割裂感。这里显式换成无预测的 M3 水平
// 滑入转场(FadeForwards),同时 AndroidManifest 里关掉了
// enableOnBackInvokedCallback。
pageTransitionsTheme: const PageTransitionsTheme(
builders: <TargetPlatform, PageTransitionsBuilder>{
TargetPlatform.android: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
TargetPlatform.linux: FadeForwardsPageTransitionsBuilder(),
TargetPlatform.windows: FadeForwardsPageTransitionsBuilder(),
},
),
);
}
}
+30
View File
@@ -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,
);
}
+146
View File
@@ -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<DeckInfo> samples = <DeckInfo>[
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<PlanInfo> samples = <PlanInfo>[
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<AlgorithmInfo> samples = <AlgorithmInfo>[
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,
),
];
}
+82
View File
@@ -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: <Widget>[
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,
),
),
),
],
),
);
}
}
+152 -6
View File
@@ -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 <Widget>[
_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: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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<void>(builder: (BuildContext _) => page),
);
}
}
+312
View File
@@ -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: <Widget>[
// 内容区:整体留白,只保留底色和边框,深浅色都跟随主题。
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: <Widget>[
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: <Widget>[
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: <Widget>[
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: colors.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
Row(
children: <Widget>[
Icon(Icons.auto_awesome_rounded, size: 18, color: colors.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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,
),
),
),
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
/// 反馈问题:一个最简的提交表单。
class FeedbackPage extends StatefulWidget {
const FeedbackPage({super.key});
@override
State<FeedbackPage> createState() => _FeedbackPageState();
}
class _FeedbackPageState extends State<FeedbackPage> {
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: <Widget>[
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('提交反馈'),
),
],
),
);
}
}
+434 -8
View File
@@ -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: <Widget>[
Tab(text: '单元集'),
Tab(text: '计划'),
Tab(text: '算法'),
],
),
),
body: const TabBarView(
children: <Widget>[
_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: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_Badge(icon: Icons.menu_book_rounded, color: colors.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_Badge(icon: Icons.event_note_rounded, color: colors.tertiary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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: <Widget>[
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: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_Badge(icon: Icons.functions_rounded, color: colors.secondary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
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) ...<Widget>[
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: <Widget>[
Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Text(
value,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
if (suffix != null) ...<Widget>[
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,
),
);
}
+221
View File
@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import '../app_info.dart';
/// 设置页,按 Android 系统设置的分组列表组织。
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
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: <Widget>[
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: <Widget>[
if (value != null)
Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
if (onTap != null) ...<Widget>[
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<bool> 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,
);
}
}
+15 -48
View File
@@ -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: <Widget>[
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: <Widget>[
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
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();
+117
View File
@@ -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<Widget> 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: <Widget>[
for (final (int index, Widget tile) in tiles.indexed) ...<Widget>[
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: <Widget>[
Icon(icon, size: 22, color: colors.primary),
const SizedBox(width: 18),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
title,
style: theme.textTheme.bodyLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...<Widget>[
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,
),
],
),
),
);
}
}
+61
View File
@@ -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: <Widget>[
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: <Widget>[
Flexible(
child: Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w700,
height: 1.1,
),
),
),
if (unit != null) ...<Widget>[
const SizedBox(width: 6),
Text(
unit,
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
],
],
),
],
);
}
}
+114 -4
View File
@@ -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<void> 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<MaterialApp>(find.byType(MaterialApp));
final PageTransitionsTheme transitions = app.theme!.pageTransitionsTheme;
expect(
transitions.builders[TargetPlatform.android],
isA<FadeForwardsPageTransitionsBuilder>(),
);
});
testWidgets('默认停留在学习页,点击其它入口会切换', (WidgetTester tester) async {
await tester.pumpWidget(const KiriMemoApp());
await pumpApp(tester);
NavigationBar bar = tester.widget<NavigationBar>(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 <String>['收藏夹', '自我测试', '附带资料', '计划设置']) {
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 <String>['单元集', '计划', '算法']) {
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 <String>[
'设置',
'文档与帮助',
'反馈问题',
'关于 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);
});
}