- 管理页:单元集 / 计划 / 算法三个维度,卡片带元数据 - 账户页:资料、学习数据、竖直分组的四个功能入口 - 新增设置、文档与帮助(底部可上滑助手)、反馈、关于四个页面 - 抽出 StatBlock 与 GroupedListSection 共用组件 - 关闭 Android 预测式返回,转场改为 M3 水平滑入
313 lines
9.8 KiB
Dart
313 lines
9.8 KiB
Dart
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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|