This commit is contained in:
2026-08-03 12:14:50 +08:00
parent 55aa30214c
commit a11a668869
9 changed files with 726 additions and 770 deletions
+2 -2
View File
@@ -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(
+2 -2
View File
@@ -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(
+142 -219
View File
@@ -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)),
],
),
);
}
}
+31 -4
View File
@@ -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));
}
}
-180
View File
@@ -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,
),
],
);
}
}
-151
View File
@@ -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,
),
);
}
}
-199
View File
@@ -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
View File
@@ -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',