1
This commit is contained in:
@@ -96,10 +96,10 @@ class _MinePageState extends ConsumerState<MinePage> {
|
||||
ListTile(
|
||||
leading: const Icon(Icons.face_retouching_natural),
|
||||
title: const Text('我的形象'),
|
||||
subtitle: const Text('3D 化身 · 形象照片 · 身形参数'),
|
||||
subtitle: const Text('拍摄照片 · 身形参数 · 生成 3D 化身'),
|
||||
trailing:
|
||||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||||
onTap: () => context.push('/profile'),
|
||||
onTap: () => context.push('/avatar-build'),
|
||||
),
|
||||
const Divider(height: 1, indent: 56),
|
||||
ListTile(
|
||||
|
||||
@@ -95,7 +95,7 @@ class _OutfitPageState extends ConsumerState<OutfitPage> {
|
||||
if (ok) {
|
||||
Fluttertoast.showToast(msg: '生成完成');
|
||||
ref.read(generateProvider.notifier).reset();
|
||||
context.go('/plan-viewer');
|
||||
context.push('/plan-viewer');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ class _PlanCard extends ConsumerWidget {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => context.go('/plan-viewer'),
|
||||
onTap: () => context.push('/plan-viewer'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../features/cps/cps_provider.dart';
|
||||
import '../../shared/widgets/avatar_viewer.dart';
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import '../profile/avatar_provider.dart';
|
||||
import 'outfit_provider.dart';
|
||||
|
||||
/// 方案流:手指左右滑动切换方案,底部操作(效果图/选定/收藏)
|
||||
/// 方案流:上部 3D 化身展示 + 下部方案左右滑动切换,清单含商业化入口(到店试穿/买同款)
|
||||
class PlanViewerPage extends ConsumerWidget {
|
||||
const PlanViewerPage({super.key});
|
||||
|
||||
@@ -17,67 +17,73 @@ class PlanViewerPage extends ConsumerWidget {
|
||||
final plans = ref.watch(outfitPlanProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('穿搭方案')),
|
||||
body: plans.when(
|
||||
loading: () => const LoadingView(text: '加载方案...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Center(child: Text('暂无方案'));
|
||||
}
|
||||
return PageView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) =>
|
||||
_PlanDetailView(planId: list[i].id, key: ValueKey(list[i].id)),
|
||||
);
|
||||
},
|
||||
body: Column(
|
||||
children: [
|
||||
_buildAvatarBar(context, ref),
|
||||
Expanded(
|
||||
child: plans.when(
|
||||
loading: () => const LoadingView(text: '加载方案...'),
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return const Center(child: Text('暂无方案'));
|
||||
}
|
||||
return PageView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) => _PlanDetailView(
|
||||
planId: list[i].id, key: ValueKey(list[i].id)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 上部:当前用户的 3D 化身展示(帧轮播,未构建时占位)
|
||||
Widget _buildAvatarBar(BuildContext context, WidgetRef ref) {
|
||||
final avatar = ref.watch(avatarProvider);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: avatar.when(
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (e, _) => const SizedBox.shrink(),
|
||||
data: (a) => AvatarViewer(
|
||||
framesUrl: a.framesUrl,
|
||||
title: '我的 3D 化身',
|
||||
subtitle: a.built ? '' : '尚未构建,可在「我的」页完成照片与身形后生成',
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlanDetailView extends ConsumerStatefulWidget {
|
||||
class _PlanDetailView extends ConsumerWidget {
|
||||
final int planId;
|
||||
|
||||
const _PlanDetailView({required this.planId, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<_PlanDetailView> createState() => _PlanDetailViewState();
|
||||
}
|
||||
|
||||
class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
|
||||
bool _selecting = false;
|
||||
|
||||
Future<void> _selectMain(int planId) async {
|
||||
setState(() => _selecting = true);
|
||||
try {
|
||||
await ref.read(outfitPlanProvider.notifier).selectMain(planId);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '已设为主方案,正在生成效果图');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '操作失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _selecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _review(int planId, String action) async {
|
||||
try {
|
||||
await ref.read(outfitPlanProvider.notifier).review(planId, action);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: action == 'fav' ? '已收藏' : '已取消收藏');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '操作失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
}
|
||||
/// 商业化入口:推荐为空时也跳转(source/品类为空 → 列表页展示空态)
|
||||
void _openCpsList(
|
||||
BuildContext context, String title, String scene, List<CpsProductInfo> rec) {
|
||||
final first = rec.isEmpty ? null : rec.first;
|
||||
context.push('/cps-product-list', extra: CpsListArgs(
|
||||
title: title,
|
||||
source: first?.source ?? '',
|
||||
categoryCode: first?.categoryCode ?? '',
|
||||
city: first?.city ?? '',
|
||||
scene: scene,
|
||||
));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detail = ref.watch(planDetailProvider(widget.planId));
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(planDetailProvider(planId));
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -86,15 +92,6 @@ class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
|
||||
error: (e, _) => Center(child: Text('加载失败:$e')),
|
||||
data: (d) {
|
||||
final plan = d.plan;
|
||||
// 商业化入口:推荐为空/未配置时 value 为 null 或空 → 按钮不渲染
|
||||
final haircutRec = ref
|
||||
.watch(cpsRecommendProvider(
|
||||
(planId: plan.id, scene: 'haircut')))
|
||||
.value;
|
||||
final occasionRec = ref
|
||||
.watch(cpsRecommendProvider(
|
||||
(planId: plan.id, scene: 'occasion')))
|
||||
.value;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
@@ -130,67 +127,40 @@ class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
Text('评分 ${plan.score}',
|
||||
style: const TextStyle(color: Colors.grey)),
|
||||
const SizedBox(height: 12),
|
||||
if (d.hairstyleName.isNotEmpty || plan.hairColor.isNotEmpty)
|
||||
Card(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.content_cut, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'发型:${d.hairstyleName.isNotEmpty ? d.hairstyleName : '默认'}'
|
||||
'${plan.hairColor.isNotEmpty ? ' · 发色:${plan.hairColor}' : ''}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (haircutRec != null && haircutRec.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _openCpsList(
|
||||
'做同款发型', 'haircut', haircutRec),
|
||||
icon: const Icon(Icons.storefront_outlined,
|
||||
size: 16),
|
||||
label: const Text('做同款发型'),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 穿搭清单:标题行 + 场景延伸优惠入口
|
||||
Row(
|
||||
children: [
|
||||
Text('穿搭清单',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
const Spacer(),
|
||||
if (plan.occasion.isNotEmpty)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _openCpsList(context,
|
||||
'${plan.occasion}延伸优惠', 'occasion', const []),
|
||||
icon: const Icon(Icons.local_offer_outlined,
|
||||
size: 16),
|
||||
label: const Text('延伸优惠'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (plan.occasion.isNotEmpty &&
|
||||
occasionRec != null &&
|
||||
occasionRec.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _openCpsList(
|
||||
'${plan.occasion}延伸优惠', 'occasion', occasionRec),
|
||||
icon: const Icon(Icons.local_offer_outlined,
|
||||
size: 16),
|
||||
label: const Text('延伸优惠'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('穿衣清单',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 发型作为清单首项(含"做同款发型"入口)
|
||||
if (d.hairstyleName.isNotEmpty || plan.hairColor.isNotEmpty)
|
||||
_HairstyleEntryCard(
|
||||
planId: plan.id,
|
||||
hairstyleName: d.hairstyleName.isNotEmpty
|
||||
? d.hairstyleName
|
||||
: '默认',
|
||||
hairColor: plan.hairColor,
|
||||
onOpen: (title, scene, rec) =>
|
||||
_openCpsList(context, title, scene, rec),
|
||||
),
|
||||
if (d.items.isEmpty)
|
||||
const Text('暂无穿搭单品',
|
||||
style: TextStyle(color: Colors.grey)),
|
||||
@@ -199,112 +169,69 @@ class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
|
||||
planId: plan.id,
|
||||
item: item,
|
||||
onOpen: (title, scene, rec) =>
|
||||
_openCpsList(title, scene, rec),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('效果图',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scheme.primary)),
|
||||
const SizedBox(height: 8),
|
||||
if (d.images.every((img) => !img.done))
|
||||
const Text('效果图生成中,选定主方案后生成(每日限 3 次)',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 13))
|
||||
else
|
||||
SizedBox(
|
||||
height: 96,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
for (final img in d.images.where((i) => i.done))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/plan-effect',
|
||||
extra: d.images
|
||||
.where((i) => i.done)
|
||||
.toList()),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
AppConfig.resolveUrl(img.url),
|
||||
width: 80,
|
||||
height: 96,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => Container(
|
||||
width: 80,
|
||||
height: 96,
|
||||
color: Colors.grey.shade200,
|
||||
child: const Icon(Icons.image,
|
||||
color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_openCpsList(context, title, scene, rec),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: plan.isMain
|
||||
? null
|
||||
: _selecting
|
||||
? null
|
||||
: () => _selectMain(plan.id),
|
||||
icon: const Icon(Icons.star_outline),
|
||||
label: Text(plan.isMain ? '主方案' : '设为主方案'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.push('/plan-effect',
|
||||
extra: d.images),
|
||||
icon: const Icon(Icons.image_outlined),
|
||||
label: const Text('效果图'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _review(plan.id, 'fav'),
|
||||
icon: const Icon(Icons.favorite_outline),
|
||||
label: const Text('收藏'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 商业化入口:推荐为空时不跳转
|
||||
void _openCpsList(String title, String scene, List<CpsProductInfo> rec) {
|
||||
if (rec.isEmpty) return;
|
||||
final first = rec.first;
|
||||
context.push('/cps-product-list', extra: CpsListArgs(
|
||||
title: title,
|
||||
source: first.source,
|
||||
categoryCode: first.categoryCode,
|
||||
city: first.city,
|
||||
scene: scene,
|
||||
));
|
||||
/// 穿搭清单项:发型(含"做同款发型"入口,推荐为空也跳转)
|
||||
class _HairstyleEntryCard extends ConsumerWidget {
|
||||
final int planId;
|
||||
final String hairstyleName;
|
||||
final String hairColor;
|
||||
final void Function(String title, String scene, List<CpsProductInfo> rec)
|
||||
onOpen;
|
||||
|
||||
const _HairstyleEntryCard({
|
||||
required this.planId,
|
||||
required this.hairstyleName,
|
||||
required this.hairColor,
|
||||
required this.onOpen,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final rec = ref
|
||||
.watch(cpsRecommendProvider((planId: planId, scene: 'haircut')))
|
||||
.value;
|
||||
return Card(
|
||||
color: scheme.primaryContainer.withValues(alpha: 0.4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ListTile(
|
||||
dense: true,
|
||||
leading: const Icon(Icons.content_cut, size: 20),
|
||||
title: Text(
|
||||
'发型:$hairstyleName${hairColor.isNotEmpty ? ' · 发色:$hairColor' : ''}',
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onOpen('做同款发型', 'haircut', rec ?? []),
|
||||
icon: const Icon(Icons.storefront_outlined, size: 16),
|
||||
label: const Text('做同款发型'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 穿衣清单项:衣橱 → 到店试穿;新品 → 买同款(推荐为空不渲染按钮)
|
||||
/// 穿搭清单项:衣橱已有单品无入口;AI 推荐新品 → 去购买(推荐为空也跳转)
|
||||
class _ItemEntryCard extends ConsumerWidget {
|
||||
final int planId;
|
||||
final PlanItemInfo item;
|
||||
@@ -350,18 +277,14 @@ class _ItemEntryCard extends ConsumerWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
if (rec != null && rec.isNotEmpty)
|
||||
// 衣橱已有单品无需入口;仅 AI 推荐新品展示购买入口
|
||||
if (!item.fromWardrobe)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton.icon(
|
||||
onPressed: () => onOpen(
|
||||
item.fromWardrobe ? '到店试穿' : '买同款', scene, rec),
|
||||
icon: Icon(
|
||||
item.fromWardrobe
|
||||
? Icons.storefront_outlined
|
||||
: Icons.shopping_cart_outlined,
|
||||
size: 16),
|
||||
label: Text(item.fromWardrobe ? '到店试穿' : '买同款'),
|
||||
onPressed: () => onOpen('去购买', scene, rec ?? []),
|
||||
icon: const Icon(Icons.shopping_cart_outlined, size: 16),
|
||||
label: const Text('去购买'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.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;
|
||||
Fluttertoast.showToast(msg: '${PhotoType.labels[type]}上传成功');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '上传失败:${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) {
|
||||
Fluttertoast.showToast(msg: '3D 化身已生成');
|
||||
context.go('/avatar-viewer');
|
||||
} else if (a?.buildStatus == 'failed') {
|
||||
setState(() => _buildError = a!.error);
|
||||
} else {
|
||||
Fluttertoast.showToast(msg: '构建超时,请稍后重试');
|
||||
}
|
||||
} 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,20 @@ class BodyState {
|
||||
final int height;
|
||||
final int weight;
|
||||
final int skinTone;
|
||||
final int bust;
|
||||
final int waist;
|
||||
final int hip;
|
||||
final int shoulder;
|
||||
|
||||
const BodyState({this.height = 170, this.weight = 60, this.skinTone = 3});
|
||||
const BodyState({
|
||||
this.height = 170,
|
||||
this.weight = 60,
|
||||
this.skinTone = 3,
|
||||
this.bust = 88,
|
||||
this.waist = 70,
|
||||
this.hip = 92,
|
||||
this.shoulder = 42,
|
||||
});
|
||||
}
|
||||
|
||||
class BodyNotifier extends AsyncNotifier<BodyState> {
|
||||
@@ -19,18 +31,33 @@ class BodyNotifier extends AsyncNotifier<BodyState> {
|
||||
height: (data['height'] as num?)?.toInt() ?? 170,
|
||||
weight: (data['weight'] as num?)?.toInt() ?? 60,
|
||||
skinTone: (data['skin_tone'] as num?)?.toInt() ?? 3,
|
||||
bust: (data['bust'] as num?)?.toInt() ?? 88,
|
||||
waist: (data['waist'] as num?)?.toInt() ?? 70,
|
||||
hip: (data['hip'] as num?)?.toInt() ?? 92,
|
||||
shoulder: (data['shoulder'] as num?)?.toInt() ?? 42,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> save(int height, int weight, int skinTone) async {
|
||||
Future<void> save(int height, int weight, int skinTone, int bust,
|
||||
int waist, int hip, int shoulder) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/body-measurement/save', {
|
||||
'height': height,
|
||||
'weight': weight,
|
||||
'skin_tone': skinTone,
|
||||
'bust': bust,
|
||||
'waist': waist,
|
||||
'hip': hip,
|
||||
'shoulder': shoulder,
|
||||
});
|
||||
state = AsyncData(
|
||||
BodyState(height: height, weight: weight, skinTone: skinTone));
|
||||
state = AsyncData(BodyState(
|
||||
height: height,
|
||||
weight: weight,
|
||||
skinTone: skinTone,
|
||||
bust: bust,
|
||||
waist: waist,
|
||||
hip: hip,
|
||||
shoulder: shoulder));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'body_provider.dart';
|
||||
|
||||
/// 肤色选项(1-5,与后端 SkinTone 对应)
|
||||
const skinToneColors = <int, Color>{
|
||||
1: Color(0xFFF6E3D4),
|
||||
2: Color(0xFFEAC9A8),
|
||||
3: Color(0xFFD9A87C),
|
||||
4: Color(0xFFB97E55),
|
||||
5: Color(0xFF8D5B38),
|
||||
};
|
||||
|
||||
class BodyTunePage extends ConsumerStatefulWidget {
|
||||
const BodyTunePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<BodyTunePage> createState() => _BodyTunePageState();
|
||||
}
|
||||
|
||||
class _BodyTunePageState extends ConsumerState<BodyTunePage> {
|
||||
int _height = 170;
|
||||
int _weight = 60;
|
||||
int _skinTone = 3;
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final cur = ref.read(bodyProvider).value;
|
||||
if (cur != null) {
|
||||
_height = cur.height;
|
||||
_weight = cur.weight;
|
||||
_skinTone = cur.skinTone;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref
|
||||
.read(bodyProvider.notifier)
|
||||
.save(_height, _weight, _skinTone);
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(msg: '身形已保存');
|
||||
context.go('/home');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '保存失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = ref.watch(bodyProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('身形设置')),
|
||||
body: 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()),
|
||||
),
|
||||
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(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: _saving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('保存并生成我的 3D 化身'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../shared/widgets/loading_view.dart';
|
||||
import 'photo_upload_provider.dart';
|
||||
|
||||
/// 拍照引导:3 视角全身照(正面/侧面/背面)逐一上传,用于 Tripo 构建 3D 化身
|
||||
class PhotoGuidePage extends ConsumerStatefulWidget {
|
||||
const PhotoGuidePage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<PhotoGuidePage> createState() => _PhotoGuidePageState();
|
||||
}
|
||||
|
||||
class _PhotoGuidePageState extends ConsumerState<PhotoGuidePage> {
|
||||
final _picker = ImagePicker();
|
||||
int? _uploadingType;
|
||||
|
||||
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;
|
||||
Fluttertoast.showToast(msg: '${PhotoType.labels[type]}上传成功');
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
Fluttertoast.showToast(
|
||||
msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}');
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingType = null);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final photos = ref.watch(photoProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('拍摄形象照片')),
|
||||
body: 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
|
||||
? () => context.go('/body-tune')
|
||||
: null,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: Text(allDone ? '照片已完成,设置身形' : '还需拍摄剩余照片'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'avatar_provider.dart';
|
||||
import 'body_provider.dart';
|
||||
import 'photo_upload_provider.dart';
|
||||
|
||||
/// 我的形象:化身 + 照片 + 身形(退出登录在「我的」页)
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final avatar = ref.watch(avatarProvider);
|
||||
final photos = ref.watch(photoProvider);
|
||||
final body = ref.watch(bodyProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的形象')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_buildAvatarCard(context, ref, avatar),
|
||||
const SizedBox(height: 12),
|
||||
_buildPhotoCard(context, ref, photos),
|
||||
const SizedBox(height: 12),
|
||||
_buildBodyCard(context, ref, body),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarCard(BuildContext context, WidgetRef ref,
|
||||
AsyncValue<AvatarState> avatar) {
|
||||
return _SectionCard(
|
||||
title: '3D 化身',
|
||||
icon: Icons.face_retouching_natural,
|
||||
onActionText: null,
|
||||
child: avatar.when(
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))),
|
||||
),
|
||||
error: (e, _) => _ActionRow(
|
||||
text: '构建失败:${e.toString().replaceFirst('Exception: ', '')}',
|
||||
actionText: '重试',
|
||||
onAction: () => ref.read(avatarProvider.notifier).buildAvatar(),
|
||||
),
|
||||
data: (a) => _ActionRow(
|
||||
text: a.built
|
||||
? '已构建(模板 ${a.faceTemplateId}/${a.bodyTemplateId})'
|
||||
: '尚未构建化身,需要先完成照片与身形',
|
||||
actionText: a.built ? '查看' : '立即构建',
|
||||
onAction: a.built
|
||||
? () => context.go('/avatar-viewer')
|
||||
: () async {
|
||||
await ref.read(avatarProvider.notifier).buildAvatar();
|
||||
if (context.mounted) {
|
||||
Fluttertoast.showToast(msg: '化身构建完成');
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhotoCard(
|
||||
BuildContext context, WidgetRef ref, AsyncValue<List<UserPhotoInfo>> photos) {
|
||||
return _SectionCard(
|
||||
title: '形象照片',
|
||||
icon: Icons.photo_camera_outlined,
|
||||
onActionText: '去拍摄',
|
||||
onAction: () => context.go('/photo-guide'),
|
||||
child: photos.when(
|
||||
loading: () => const _InfoText('加载中...'),
|
||||
error: (e, _) => _InfoText('加载失败:$e'),
|
||||
data: (list) => Column(
|
||||
children: [
|
||||
for (final type in PhotoType.all)
|
||||
_InfoText(
|
||||
'${PhotoType.labels[type]}:'
|
||||
'${list.any((p) => p.type == type) ? '已上传' : '未上传'}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBodyCard(
|
||||
BuildContext context, WidgetRef ref, AsyncValue<BodyState> body) {
|
||||
return _SectionCard(
|
||||
title: '身形参数',
|
||||
icon: Icons.accessibility_new,
|
||||
onActionText: '去设置',
|
||||
onAction: () => context.go('/body-tune'),
|
||||
child: body.when(
|
||||
loading: () => const _InfoText('加载中...'),
|
||||
error: (e, _) => _InfoText('加载失败:$e'),
|
||||
data: (b) => _InfoText(
|
||||
'身高 ${b.height}cm · 体重 ${b.weight}kg · 肤色 ${b.skinTone}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Widget child;
|
||||
final String? onActionText;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
const _SectionCard({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.child,
|
||||
this.onActionText,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon,
|
||||
size: 20, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
if (onActionText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: onAction,
|
||||
child: Text(onActionText!),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
final String text;
|
||||
final String actionText;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
const _ActionRow({
|
||||
required this.text,
|
||||
required this.actionText,
|
||||
required this.onAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 13))),
|
||||
if (onAction != null)
|
||||
TextButton(onPressed: onAction, child: Text(actionText)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoText extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _InfoText(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(text, style: const TextStyle(fontSize: 13)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+3
-13
@@ -12,10 +12,8 @@ import 'features/outfit/outfit_page.dart';
|
||||
import 'features/outfit/outfit_provider.dart';
|
||||
import 'features/outfit/plan_effect_page.dart';
|
||||
import 'features/outfit/plan_viewer_page.dart';
|
||||
import 'features/profile/avatar_build_flow_page.dart';
|
||||
import 'features/profile/avatar_viewer_page.dart';
|
||||
import 'features/profile/body_tune_page.dart';
|
||||
import 'features/profile/photo_guide_page.dart';
|
||||
import 'features/profile/profile_page.dart';
|
||||
import 'features/wardrobe/wardrobe_page.dart';
|
||||
import 'features/wardrobe/wardrobe_upload_page.dart';
|
||||
|
||||
@@ -71,16 +69,8 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
builder: (ctx, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (ctx, state) => const ProfilePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/photo-guide',
|
||||
builder: (ctx, state) => const PhotoGuidePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/body-tune',
|
||||
builder: (ctx, state) => const BodyTunePage(),
|
||||
path: '/avatar-build',
|
||||
builder: (ctx, state) => const AvatarBuildFlowPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/wardrobe',
|
||||
|
||||
Reference in New Issue
Block a user