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