Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -0,0 +1,436 @@
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,
),
],
),
),
);
}
}