Files
jizhi/frontend/lib/features/onboarding/pages/onboarding_page.dart
T
2026-07-24 23:11:20 +08:00

437 lines
14 KiB
Dart
Raw 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';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
/// 首次引导:选模式 → 选 AI 伙伴(P9 + P10
/// 形象和性格从后台 API 动态拉取,管理员新增/修改即时生效
class OnboardingPage extends StatefulWidget {
const OnboardingPage({super.key});
@override
State<OnboardingPage> createState() => _OnboardingPageState();
}
class _OnboardingPageState extends State<OnboardingPage> {
int _step = 0;
String _mode = 'normal';
String _avatar = 'cat';
String _persona = 'sassy_cat';
bool _saving = false;
List<AvatarItem> _avatars = [];
List<PersonaItem> _personas = [];
bool _loaded = false;
@override
void initState() {
super.initState();
_loadConfig();
}
Future<void> _loadConfig() async {
try {
final avatars = await PublicConfigApi.avatars();
final personas = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = avatars;
_personas = personas;
if (avatars.isNotEmpty) _avatar = avatars.first.key;
if (personas.isNotEmpty) _persona = personas.first.key;
_loaded = true;
});
} catch (_) {
// API 失败用硬编码兜底
if (mounted) setState(() => _loaded = true);
}
}
// 兜底数据(API 不可用时)
static const _fallbackAvatars = [
('cat', '小账喵', AppIcons.cat),
('dog', '阿福汪', AppIcons.dog),
('robot', '账小智', AppIcons.robot),
];
static const _fallbackPersonas = [
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽'),
('gentle', '温柔小暖', '永远鼓励,温柔提醒'),
('strict', '严格管家', '理性专业,数据说话'),
('meme', '沙雕损友', '玩梗高手,快乐记账'),
];
Future<void> _finish() async {
setState(() => _saving = true);
try {
await AuthApi.completeOnboarding(
appMode: _mode,
avatarKey: _avatar,
personaKey: _persona,
);
if (!mounted) return;
context.go(_mode == 'ai' ? '/ai-mode' : '/home');
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
if (!_loaded)
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
);
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 26),
child: _step == 0 ? _ModeStep() : _CompanionStep(),
),
),
);
}
Widget _ModeStep() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 8),
Row(
children: [
Container(
width: 28,
height: 5,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(3),
),
),
SizedBox(width: 6),
Container(
width: 18,
height: 5,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
],
),
SizedBox(height: 12),
Text(
'你想怎么用它?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
SizedBox(height: 6),
Text(
'两种完全不同的体验,之后随时可以切换',
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
SizedBox(height: 18),
_ModeCard(
selected: _mode == 'normal',
iconAsset: AppIcons.wallet,
iconColor: AppTheme.primary,
title: '普通记账模式',
desc: '经典账本界面,手动记账、报表统计一应俱全,AI 助手随叫随到',
onTap: () => setState(() => _mode = 'normal'),
),
SizedBox(height: 11),
_ModeCard(
selected: _mode == 'ai',
iconAsset: AppIcons.sparkle,
iconColor: AppTheme.ai,
title: '全 AI 模式',
desc: '没有复杂界面,打开就是和 AI 聊天,说句话就完成记账、查账、看报告',
onTap: () => setState(() => _mode = 'ai'),
),
Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => setState(() => _step = 1),
child: Text('继续'),
),
),
SizedBox(height: 16),
],
);
}
Widget _CompanionStep() {
// 优先 API 数据,回退硬编码
final avatars = _avatars.isNotEmpty
? _avatars
.map(
(a) => (
a.key,
a.defaultName,
a.key == 'dog'
? AppIcons.dog
: a.key == 'robot'
? AppIcons.robot
: AppIcons.cat,
),
)
.toList()
: _fallbackAvatars;
final personas = _personas.isNotEmpty
? _personas.map((p) => (p.key, p.name, p.description)).toList()
: _fallbackPersonas;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: 8),
Row(
children: [
Container(
width: 18,
height: 5,
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(3),
),
),
SizedBox(width: 6),
Container(
width: 28,
height: 5,
decoration: BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(3),
),
),
],
),
SizedBox(height: 12),
Text(
'认识你的 AI 伙伴',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
SizedBox(height: 6),
Text(
'选形象、挑性格 —— 它就是你的专属记账搭子',
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
SizedBox(height: 14),
SizedBox(
height: 96,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: avatars.map((a) {
final on = _avatar == a.$1;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 11),
child: GestureDetector(
onTap: () => setState(() => _avatar = a.$1),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.elasticOut,
width: 56,
height: 56,
child: Transform.scale(
scale: on ? 1.18 : 1.0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: on ? AppTheme.ai : context.jz.aiBackground,
boxShadow: on
? [
BoxShadow(
color: AppTheme.ai.withValues(
alpha: 0.3,
),
blurRadius: 14,
),
]
: null,
),
child: Center(
child: AppIcons.icon(
a.$3,
size: on ? 28 : 24,
color: on ? Colors.white : context.jz.text3,
),
),
),
),
),
SizedBox(height: 6),
Text(
a.$2,
style: TextStyle(
fontSize: 10,
color: on ? AppTheme.ai : context.jz.text3,
fontWeight: on ? FontWeight.w600 : null,
),
),
],
),
),
);
}).toList(),
),
),
SizedBox(height: 16),
Expanded(
child: GridView.count(
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.5,
children: personas.map((p) {
final on = _persona == p.$1;
return GestureDetector(
onTap: () => setState(() => _persona = p.$1),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? context.jz.aiBackground : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: on ? AppTheme.ai : context.jz.line,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AppIcons.icon(
AppIcons.chat,
size: 18,
color: on ? AppTheme.ai : context.jz.text3,
),
SizedBox(height: 8),
Text(
p.$2,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
p.$3,
style: TextStyle(fontSize: 10, color: context.jz.text2),
),
],
),
),
);
}).toList(),
),
),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _finish,
child: _saving
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('开始记账之旅'),
),
),
SizedBox(height: 16),
],
);
}
}
class _ModeCard extends StatelessWidget {
final bool selected;
final String iconAsset;
final Color iconColor;
final String title, desc;
final VoidCallback onTap;
const _ModeCard({
required this.selected,
required this.iconAsset,
required this.iconColor,
required this.title,
required this.desc,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: selected ? 2 : 1.5,
),
),
child: Row(
children: [
AppIcons.icon(iconAsset, size: 22, color: iconColor),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700),
),
SizedBox(height: 4),
Text(
desc,
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
),
],
),
),
SizedBox(width: 8),
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: 1.5,
),
color: selected ? AppTheme.primary : null,
),
child: selected
? AppIcons.icon(AppIcons.check, size: 13, color: Colors.white)
: null,
),
],
),
),
);
}
}