diff --git a/lib/features/mine/mine_page.dart b/lib/features/mine/mine_page.dart index 40cef87..cfd3697 100644 --- a/lib/features/mine/mine_page.dart +++ b/lib/features/mine/mine_page.dart @@ -96,10 +96,10 @@ class _MinePageState extends ConsumerState { 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( diff --git a/lib/features/outfit/outfit_page.dart b/lib/features/outfit/outfit_page.dart index 20f9dee..11544d1 100644 --- a/lib/features/outfit/outfit_page.dart +++ b/lib/features/outfit/outfit_page.dart @@ -95,7 +95,7 @@ class _OutfitPageState extends ConsumerState { 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( diff --git a/lib/features/outfit/plan_viewer_page.dart b/lib/features/outfit/plan_viewer_page.dart index 0884927..e5f473c 100644 --- a/lib/features/outfit/plan_viewer_page.dart +++ b/lib/features/outfit/plan_viewer_page.dart @@ -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 _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 _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 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 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 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('去购买'), ), ), ], diff --git a/lib/features/profile/avatar_build_flow_page.dart b/lib/features/profile/avatar_build_flow_page.dart new file mode 100644 index 0000000..3560866 --- /dev/null +++ b/lib/features/profile/avatar_build_flow_page.dart @@ -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 = { + 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 createState() => + _AvatarBuildFlowPageState(); +} + +class _AvatarBuildFlowPageState extends ConsumerState { + 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 _pickAndUpload(int type) async { + final source = await showModalBottomSheet( + 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 _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 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 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)), + ], + ), + ); + } +} diff --git a/lib/features/profile/body_provider.dart b/lib/features/profile/body_provider.dart index 8bf324f..397b30b 100644 --- a/lib/features/profile/body_provider.dart +++ b/lib/features/profile/body_provider.dart @@ -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 { @@ -19,18 +31,33 @@ class BodyNotifier extends AsyncNotifier { 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 save(int height, int weight, int skinTone) async { + Future 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)); } } diff --git a/lib/features/profile/body_tune_page.dart b/lib/features/profile/body_tune_page.dart deleted file mode 100644 index 8512bda..0000000 --- a/lib/features/profile/body_tune_page.dart +++ /dev/null @@ -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 = { - 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 createState() => _BodyTunePageState(); -} - -class _BodyTunePageState extends ConsumerState { - 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 _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 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, - ), - ], - ); - } -} diff --git a/lib/features/profile/photo_guide_page.dart b/lib/features/profile/photo_guide_page.dart deleted file mode 100644 index 2ee0d3e..0000000 --- a/lib/features/profile/photo_guide_page.dart +++ /dev/null @@ -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 createState() => _PhotoGuidePageState(); -} - -class _PhotoGuidePageState extends ConsumerState { - final _picker = ImagePicker(); - int? _uploadingType; - - Future _pickAndUpload(int type) async { - final source = await showModalBottomSheet( - 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, - ), - ); - } -} diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart deleted file mode 100644 index 7c5e3a7..0000000 --- a/lib/features/profile/profile_page.dart +++ /dev/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 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> 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 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)), - ); - } -} diff --git a/lib/main.dart b/lib/main.dart index 5e31a11..da9440d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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((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',