feat: manage, account and help pages
- 管理页:单元集 / 计划 / 算法三个维度,卡片带元数据 - 账户页:资料、学习数据、竖直分组的四个功能入口 - 新增设置、文档与帮助(底部可上滑助手)、反馈、关于四个页面 - 抽出 StatBlock 与 GroupedListSection 共用组件 - 关闭 Android 预测式返回,转场改为 M3 水平滑入
This commit is contained in:
1 parent
2a307b2f48
commit
22715f7247
15 files changed
+1789
-72
No files matched your search
@@ -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
@@ -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(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user