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

313 lines
9.8 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
),
);
}
}