437 lines
14 KiB
Dart
437 lines
14 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|