Files
slogan/app/lib/features/profile/avatar_build_flow_page.dart
T
2026-08-04 17:04:18 +08:00

546 lines
17 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../shared/app_toast.dart';
import '../../shared/widgets/loading_view.dart';
import 'avatar_provider.dart';
import 'body_provider.dart';
import 'photo_upload_provider.dart';
/// 肤色选项(1-5,与后端 SkinTone 对应)
const skinToneColors = <int, Color>{
1: Color(0xFFF6E3D4),
2: Color(0xFFEAC9A8),
3: Color(0xFFD9A87C),
4: Color(0xFFB97E55),
5: Color(0xFF8D5B38),
};
/// 我的形象三步流程:① 拍摄三视角全身照 → ② 填写身形参数 → ③ 生成 3D 化身
class AvatarBuildFlowPage extends ConsumerStatefulWidget {
const AvatarBuildFlowPage({super.key});
@override
ConsumerState<AvatarBuildFlowPage> createState() =>
_AvatarBuildFlowPageState();
}
class _AvatarBuildFlowPageState extends ConsumerState<AvatarBuildFlowPage> {
final _picker = ImagePicker();
int _step = 1;
int? _uploadingType;
bool _busy = false;
String? _buildError;
int _height = 170;
int _weight = 60;
int _skinTone = 3;
int _bust = 88;
int _waist = 70;
int _hip = 92;
int _shoulder = 42;
@override
void initState() {
super.initState();
final cur = ref.read(bodyProvider).value;
if (cur != null) {
_height = cur.height;
_weight = cur.weight;
_skinTone = cur.skinTone;
_bust = cur.bust;
_waist = cur.waist;
_hip = cur.hip;
_shoulder = cur.shoulder;
}
}
Future<void> _pickAndUpload(int type) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.photo_camera_outlined),
title: const Text('拍照'),
onTap: () => Navigator.pop(ctx, ImageSource.camera),
),
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(ctx, ImageSource.gallery),
),
],
),
),
);
if (source == null || !mounted) return;
final file = await _picker.pickImage(
source: source, maxWidth: 2048, imageQuality: 85);
if (file == null) return;
setState(() => _uploadingType = type);
try {
await ref.read(photoProvider.notifier).upload(type, file.path);
if (!mounted) return;
showToast('${PhotoType.labels[type]}上传成功');
} catch (e) {
if (!mounted) return;
showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}');
} finally {
if (mounted) setState(() => _uploadingType = null);
}
}
Future<void> _saveAndBuild() async {
setState(() {
_busy = true;
_buildError = null;
});
try {
await ref
.read(bodyProvider.notifier)
.save(_height, _weight, _skinTone, _bust, _waist, _hip, _shoulder);
await ref.read(avatarProvider.notifier).buildAvatar();
if (!mounted) return;
final a = ref.read(avatarProvider).value;
if (a?.built == true) {
showToast('3D 化身已生成');
context.go('/avatar-viewer');
} else if (a?.buildStatus == 'failed') {
setState(() => _buildError = a!.error);
} else {
showToast('构建超时,请稍后重试');
}
} catch (e) {
if (mounted) {
setState(
() => _buildError = e.toString().replaceFirst('Exception: ', ''));
}
} finally {
if (mounted) setState(() => _busy = false);
}
}
@override
Widget build(BuildContext context) {
final built = ref.watch(avatarProvider).value?.built ?? false;
return Scaffold(
appBar: AppBar(
title: const Text('我的形象'),
actions: [
if (built)
IconButton(
tooltip: '查看已生成的 3D 化身',
icon: const Icon(Icons.view_in_ar),
onPressed: () => context.go('/avatar-viewer'),
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: _StepIndicator(
current: _step,
onTap: (i) => setState(() => _step = i)),
),
Expanded(
child: switch (_step) {
1 => _buildStep1(),
2 => _buildStep2(),
_ => _buildStep3(),
},
),
],
),
);
}
// ---- 步 1:拍摄三视角全身照 ----
Widget _buildStep1() {
final photos = ref.watch(photoProvider);
return photos.when(
loading: () => const LoadingView(text: '加载照片状态...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (list) {
final allDone =
PhotoType.all.every((t) => list.any((p) => p.type == t));
return ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('拍摄 3 张全身照(正面/侧面/背面),用于构建你的 3D 形象',
style: TextStyle(color: Colors.grey, fontSize: 13)),
const SizedBox(height: 12),
for (final type in PhotoType.all) ...[
_PhotoCard(
type: type,
uploaded: list.any((p) => p.type == type),
uploading: _uploadingType == type,
onTap:
_uploadingType == null ? () => _pickAndUpload(type) : null,
),
const SizedBox(height: 12),
],
const SizedBox(height: 8),
FilledButton.icon(
onPressed: allDone && _uploadingType == null
? () => setState(() => _step = 2)
: null,
icon: const Icon(Icons.arrow_forward),
label: Text(allDone ? '照片已完成,下一步' : '还需拍摄剩余照片'),
),
],
);
},
);
}
// ---- 步 2:身形参数 ----
Widget _buildStep2() {
final body = ref.watch(bodyProvider);
return body.when(
loading: () => const LoadingView(text: '加载身形参数...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (_) => ListView(
padding: const EdgeInsets.all(16),
children: [
_SliderRow(
label: '身高',
value: '$_height cm',
min: 145,
max: 200,
current: _height.toDouble(),
onChanged: (v) => setState(() => _height = v.round()),
),
_SliderRow(
label: '体重',
value: '$_weight kg',
min: 40,
max: 120,
current: _weight.toDouble(),
onChanged: (v) => setState(() => _weight = v.round()),
),
_SliderRow(
label: '胸围',
value: '$_bust cm',
min: 60,
max: 130,
current: _bust.toDouble(),
onChanged: (v) => setState(() => _bust = v.round()),
),
_SliderRow(
label: '腰围',
value: '$_waist cm',
min: 50,
max: 110,
current: _waist.toDouble(),
onChanged: (v) => setState(() => _waist = v.round()),
),
_SliderRow(
label: '臀围',
value: '$_hip cm',
min: 60,
max: 130,
current: _hip.toDouble(),
onChanged: (v) => setState(() => _hip = v.round()),
),
_SliderRow(
label: '肩宽',
value: '$_shoulder cm',
min: 30,
max: 60,
current: _shoulder.toDouble(),
onChanged: (v) => setState(() => _shoulder = v.round()),
),
const SizedBox(height: 16),
const Text('肤色', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Row(
children: [
for (var i = 1; i <= 5; i++)
Expanded(
child: GestureDetector(
onTap: () => setState(() => _skinTone = i),
child: Container(
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: skinToneColors[i],
borderRadius: BorderRadius.circular(10),
border: Border.all(
width: _skinTone == i ? 3 : 1,
color: _skinTone == i
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
),
),
child: _skinTone == i
? const Icon(Icons.check, color: Colors.white)
: null,
),
),
),
],
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () => setState(() => _step = 3),
icon: const Icon(Icons.arrow_forward),
label: const Text('下一步'),
),
],
),
);
}
// ---- 步 3:确认并生成 ----
Widget _buildStep3() {
final photos = ref.watch(photoProvider);
final photoDone = photos.value?.isNotEmpty ?? false;
final doneCount = photoDone
? PhotoType.all
.where((t) =>
photos.value!.any((p) => p.type == t))
.length
: 0;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('形象资料',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: Theme.of(context).colorScheme.primary)),
const SizedBox(height: 8),
_InfoLine(
label: '照片',
value: '$doneCount/${PhotoType.all.length} 已上传'),
_InfoLine(
label: '身高',
value: '$_height cm'),
_InfoLine(
label: '体重',
value: '$_weight kg'),
_InfoLine(
label: '胸围/腰围/臀围',
value: '$_bust / $_waist / $_hip cm'),
_InfoLine(label: '肩宽', value: '$_shoulder cm'),
_InfoLine(label: '肤色', value: '$_skinTone 档'),
],
),
),
),
if (_buildError != null) ...[
const SizedBox(height: 12),
Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Text('生成失败:$_buildError',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onErrorContainer)),
),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _busy ? null : _saveAndBuild,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14)),
icon: _busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(Icons.auto_awesome),
label: Text(_busy ? '正在生成 3D 化身...' : '生成我的 3D 化身'),
),
const SizedBox(height: 8),
const Text('生成约需 1-3 分钟,请勿关闭页面;完成后可在查看页观看 3D 形象',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12)),
],
);
}
}
/// 顶部步骤指示器:① 拍摄照片 ② 身形参数 ③ 生成
class _StepIndicator extends StatelessWidget {
final int current;
final ValueChanged<int> onTap;
const _StepIndicator({required this.current, required this.onTap});
@override
Widget build(BuildContext context) {
const titles = ['拍摄照片', '身形参数', '生成'];
return Row(
children: [
for (var i = 0; i < 3; i++) ...[
if (i > 0)
const Expanded(
child: Divider(indent: 8, endIndent: 8),
),
InkWell(
onTap: () => onTap(i + 1),
borderRadius: BorderRadius.circular(20),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
children: [
CircleAvatar(
radius: 14,
backgroundColor: i + 1 <= current
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
child: Text('${i + 1}',
style: TextStyle(
fontSize: 13,
color: i + 1 <= current
? Colors.white
: Colors.grey.shade600)),
),
const SizedBox(height: 4),
Text(titles[i],
style: TextStyle(
fontSize: 12,
fontWeight:
i + 1 == current ? FontWeight.bold : null,
color: i + 1 == current
? Theme.of(context).colorScheme.primary
: Colors.grey)),
],
),
),
),
],
],
);
}
}
class _PhotoCard extends StatelessWidget {
final int type;
final bool uploaded;
final bool uploading;
final VoidCallback? onTap;
const _PhotoCard({
required this.type,
required this.uploaded,
required this.uploading,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Card(
clipBehavior: Clip.antiAlias,
child: ListTile(
onTap: onTap,
leading: CircleAvatar(
backgroundColor:
uploaded ? Colors.green.shade100 : scheme.primaryContainer,
child: uploading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2))
: Icon(uploaded ? Icons.check : Icons.add_a_photo_outlined),
),
title: Text(PhotoType.labels[type]!),
subtitle: Text(PhotoType.descs[type]!),
trailing: uploaded
? const Chip(
label: Text('已上传'),
backgroundColor: Colors.green,
labelStyle: TextStyle(color: Colors.white, fontSize: 12),
)
: null,
),
);
}
}
class _SliderRow extends StatelessWidget {
final String label;
final String value;
final double min;
final double max;
final double current;
final ValueChanged<double> onChanged;
const _SliderRow({
required this.label,
required this.value,
required this.min,
required this.max,
required this.current,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
const Spacer(),
Text(value,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold)),
],
),
Slider(
value: current.clamp(min, max),
min: min,
max: max,
divisions: (max - min).round(),
label: value,
onChanged: onChanged,
),
],
);
}
}
class _InfoLine extends StatelessWidget {
final String label;
final String value;
const _InfoLine({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Text(label, style: const TextStyle(fontSize: 13)),
const Spacer(),
Text(value,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
],
),
);
}
}