Files
jizhi/frontend/lib/features/onboarding/pages/onboarding_page.dart
T

473 lines
15 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 {
if (mounted) setState(() => _loaded = false);
final results = await Future.wait([_loadAvatars(), _loadPersonas()]);
if (!mounted) return;
final avatars = results[0] as List<AvatarItem>;
final personas = results[1] as List<PersonaItem>;
setState(() {
_avatars = avatars;
_personas = personas;
if (!avatars.any((item) => item.key == _avatar) && avatars.isNotEmpty) {
_avatar = avatars.first.key;
}
if (!personas.any((item) => item.key == _persona) && personas.isNotEmpty) {
_persona = personas.first.key;
}
_loaded = true;
});
}
Future<List<AvatarItem>> _loadAvatars() async {
try {
return await PublicConfigApi.avatars();
} catch (_) {
return const [];
}
}
Future<List<PersonaItem>> _loadPersonas() async {
try {
return await PublicConfigApi.personas();
} catch (_) {
return const [];
}
}
Future<void> _finish() async {
if (!_hasValidCatalogSelection) return;
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);
}
}
bool get _hasValidCatalogSelection =>
_avatars.any((item) => item.key == _avatar) &&
_personas.any((item) => item.key == _persona);
@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() {
final avatars = _avatars
.map(
(a) => (
a.key,
a.defaultName,
a.key == 'dog'
? AppIcons.dog
: a.key == 'robot'
? AppIcons.robot
: AppIcons.cat,
),
)
.toList();
final personas = _personas
.map((p) => (p.key, p.name, p.description))
.toList();
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),
if (avatars.isEmpty || personas.isEmpty)
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AppIcons.icon(AppIcons.cloud, size: 28, color: context.jz.text3),
const SizedBox(height: 12),
Text(
'AI 伙伴配置暂不可用',
style: TextStyle(
color: context.jz.text,
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text('请检查网络后重试', style: TextStyle(color: context.jz.text2, fontSize: 12)),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _loadConfig,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重新加载'),
),
],
),
),
)
else ...[
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 : context.jz.card,
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 || !_hasValidCatalogSelection ? 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,
),
],
),
),
);
}
}