feat(app): 穿搭生成 + 方案流 + 效果图 + 3D 化身查看

- outfit_provider:方案列表/生成任务轮询(pending→planning→scoring→rendering→done)/详情/选定主方案/收藏
- outfit_page:日期范围 + 地点生成表单,生成中显示任务进度,方案列表卡片
- plan_viewer_page:PageView 手指滑动切换方案,穿衣清单(衣橱/新品标记)、发型发色、设为主方案/收藏
- plan_effect_page:效果图 3 视角(正面/侧面/背面)查看
- avatar_viewer:three_dart + flutter_gl 3D 渲染实现(自动旋转),GLB 资产未接入时占位展示,kEnable3D 开关
- avatar_viewer_page:化身查看页 + 重新构建
- 新增 /plan-viewer /plan-effect /avatar-viewer 路由
This commit is contained in:
2026-07-31 12:50:02 +08:00
parent ceef7bc64e
commit c4fd49bd7e
10 changed files with 1374 additions and 132 deletions
+249 -2
View File
@@ -1,10 +1,257 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:go_router/go_router.dart';
class OutfitPage extends StatelessWidget {
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'outfit_provider.dart';
/// 穿搭:生成表单 + 方案列表
class OutfitPage extends ConsumerStatefulWidget {
const OutfitPage({super.key});
@override
ConsumerState<OutfitPage> createState() => _OutfitPageState();
}
class _OutfitPageState extends ConsumerState<OutfitPage> {
DateTimeRange? _dateRange;
final _locationCtrl = TextEditingController();
bool _submitting = false;
@override
void dispose() {
_locationCtrl.dispose();
super.dispose();
}
Future<void> _pickDateRange() async {
final now = DateTime.now();
final range = await showDateRangePicker(
context: context,
firstDate: now,
lastDate: now.add(const Duration(days: 30)),
initialDateRange: _dateRange ??
DateTimeRange(start: now, end: now.add(const Duration(days: 2))),
);
if (range != null) setState(() => _dateRange = range);
}
String _fmt(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Future<void> _generate() async {
final range = _dateRange;
final location = _locationCtrl.text.trim();
if (range == null) {
Fluttertoast.showToast(msg: '请选择日期范围');
return;
}
if (location.isEmpty) {
Fluttertoast.showToast(msg: '请输入地点');
return;
}
if (range.duration.inDays < 1) {
Fluttertoast.showToast(msg: '日期范围至少 1 天');
return;
}
setState(() => _submitting = true);
final ok = await ref.read(generateProvider.notifier).generate(
startDate: _fmt(range.start),
endDate: _fmt(range.end),
location: location,
);
if (!mounted) return;
setState(() => _submitting = false);
if (ok) {
Fluttertoast.showToast(msg: '生成完成');
ref.read(generateProvider.notifier).reset();
context.go('/plan-viewer');
}
}
@override
Widget build(BuildContext context) {
return const Center(child: Text('穿搭方案(开发中)'));
final plans = ref.watch(outfitPlanProvider);
final gen = ref.watch(generateProvider);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(title: const Text('穿搭方案')),
body: RefreshIndicator(
onRefresh: () => ref.read(outfitPlanProvider.notifier).refresh(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('生成穿搭方案',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: scheme.primary)),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: _submitting ? null : _pickDateRange,
icon: const Icon(Icons.date_range_outlined),
label: Text(_dateRange == null
? '选择日期范围'
: '${_fmt(_dateRange!.start)} ~ ${_fmt(_dateRange!.end)}'),
),
const SizedBox(height: 12),
TextField(
controller: _locationCtrl,
enabled: !_submitting,
decoration: const InputDecoration(
labelText: '地点(如:上海·外滩)',
hintText: '影响天气与穿搭建议',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
if (gen.phase == GeneratePhase.running)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2)),
const SizedBox(width: 8),
Expanded(
child: Text(gen.statusText,
style: const TextStyle(fontSize: 13)),
),
],
),
),
FilledButton(
onPressed: _submitting ? null : _generate,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('生成'),
),
],
),
),
),
const SizedBox(height: 16),
Text('已有方案',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
plans.when(
loading: () => const Padding(
padding: EdgeInsets.only(top: 48),
child: LoadingView(text: '加载方案...')),
error: (e, _) => Padding(
padding: const EdgeInsets.only(top: 48),
child: ErrorView(
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: () => ref.read(outfitPlanProvider.notifier).refresh(),
),
),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.only(top: 48),
child: EmptyView(
message: '还没有穿搭方案,先设置日期与地点生成吧'),
);
}
return Column(
children: [
for (final p in list) ...[
_PlanCard(plan: p),
const SizedBox(height: 8),
],
],
);
},
),
],
),
),
);
}
}
class _PlanCard extends ConsumerWidget {
final OutfitPlanInfo plan;
const _PlanCard({required this.plan});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scheme = Theme.of(context).colorScheme;
return Card(
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.go('/plan-viewer'),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: scheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.checkroom, color: scheme.primary),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(plan.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 15)),
),
if (plan.isMain) ...[
const SizedBox(width: 6),
const Chip(
label: Text('主方案'),
labelStyle:
TextStyle(color: Colors.white, fontSize: 11),
backgroundColor: Colors.indigo,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
],
),
const SizedBox(height: 4),
Text(
'${plan.dateRange} · ${plan.location} · 评分 ${plan.score}'
'${plan.fromAi ? ' · AI 生成' : ' · 规则生成'}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: Colors.grey, fontSize: 12),
),
],
),
),
const Icon(Icons.chevron_right, color: Colors.grey),
],
),
),
),
);
}
}
+245
View File
@@ -0,0 +1,245 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
class OutfitPlanInfo {
final int id;
final String title;
final String source; // ai / rule
final int score;
final int mainFlag;
final String dateRange;
final String location;
final int hairstyleId;
final String hairColor;
const OutfitPlanInfo({
required this.id,
required this.title,
required this.source,
required this.score,
required this.mainFlag,
required this.dateRange,
required this.location,
required this.hairstyleId,
required this.hairColor,
});
bool get isMain => mainFlag == 1;
bool get fromAi => source == 'ai';
}
class PlanItemInfo {
final int id;
final String slot; // 上衣/下装/鞋/配饰
final String source; // wardrobe / new
final int wardrobeItemId;
final String name;
final String desc;
const PlanItemInfo({
required this.id,
required this.slot,
required this.source,
required this.wardrobeItemId,
required this.name,
required this.desc,
});
bool get fromWardrobe => source == 'wardrobe';
}
class PlanEffectImageInfo {
final int id;
final String angle; // front / side / back
final String url;
final String status; // pending / rendering / done / failed
const PlanEffectImageInfo({
required this.id,
required this.angle,
required this.url,
required this.status,
});
bool get done => status == 'done' && url.isNotEmpty;
}
class PlanDetail {
final OutfitPlanInfo plan;
final List<PlanItemInfo> items;
final List<PlanEffectImageInfo> images;
final String hairstyleName;
const PlanDetail({
required this.plan,
required this.items,
required this.images,
required this.hairstyleName,
});
}
/// 方案列表
class OutfitPlanNotifier extends AsyncNotifier<List<OutfitPlanInfo>> {
@override
Future<List<OutfitPlanInfo>> build() async {
final api = ref.read(apiClientProvider);
final list = await api.get<List<dynamic>>('/outfit/plan/list');
return list
.map((e) => OutfitPlanInfo(
id: (e['id'] as num).toInt(),
title: e['title'] as String? ?? '',
source: e['source'] as String? ?? 'rule',
score: (e['score'] as num?)?.toInt() ?? 0,
mainFlag: (e['main_flag'] as num?)?.toInt() ?? 0,
dateRange: e['date_range'] as String? ?? '',
location: e['location'] as String? ?? '',
hairstyleId: (e['hairstyle_id'] as num?)?.toInt() ?? 0,
hairColor: e['hair_color'] as String? ?? '',
))
.toList();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(build);
}
Future<void> selectMain(int planId) async {
final api = ref.read(apiClientProvider);
await api.post('/outfit/plan/select-main', {'plan_id': planId});
await refresh();
}
Future<void> review(int planId, String action) async {
final api = ref.read(apiClientProvider);
await api.post('/outfit/plan/review', {'plan_id': planId, 'action': action});
}
}
final outfitPlanProvider =
AsyncNotifierProvider<OutfitPlanNotifier, List<OutfitPlanInfo>>(
OutfitPlanNotifier.new);
/// 生成任务状态(轮询后端任务状态机)
enum GeneratePhase { idle, running, done, failed }
class GenerateState {
final GeneratePhase phase;
final String statusText;
final String error;
const GenerateState({
this.phase = GeneratePhase.idle,
this.statusText = '',
this.error = '',
});
}
class GenerateNotifier extends Notifier<GenerateState> {
@override
GenerateState build() => const GenerateState();
Future<bool> generate({
required String startDate,
required String endDate,
required String location,
}) async {
state =
const GenerateState(phase: GeneratePhase.running, statusText: '任务创建中...');
final api = ref.read(apiClientProvider);
final data = await api.post<Map<String, dynamic>>('/outfit/generate', {
'start_date': startDate,
'end_date': endDate,
'location': location,
});
final taskId = (data['task_id'] as num).toInt();
for (var i = 0; i < 90; i++) {
await Future.delayed(const Duration(seconds: 2));
final st = await api.get<Map<String, dynamic>>('/outfit/task/status',
query: {'task_id': taskId});
final status = st['status'] as String? ?? '';
final err = st['error'] as String? ?? '';
state = GenerateState(
phase: GeneratePhase.running, statusText: _statusText(status));
if (status == 'done') {
state = const GenerateState(phase: GeneratePhase.done, statusText: '完成');
break;
}
if (status == 'failed') {
state = GenerateState(
phase: GeneratePhase.failed,
error: err.isEmpty ? '生成失败,请稍后重试' : err);
return false;
}
}
await ref.read(outfitPlanProvider.notifier).refresh();
return state.phase == GeneratePhase.done;
}
String _statusText(String status) {
switch (status) {
case 'pending':
return '排队中...';
case 'planning':
return 'AI 规划穿搭中...';
case 'scoring':
return '方案评分中...';
case 'rendering':
return '生成效果图中...';
case 'failed':
return '生成失败';
default:
return '处理中...';
}
}
void reset() => state = const GenerateState();
}
final generateProvider =
NotifierProvider<GenerateNotifier, GenerateState>(GenerateNotifier.new);
/// 方案详情
final planDetailProvider = FutureProvider.family<PlanDetail, int>((ref, planId) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/outfit/plan/detail',
query: {'plan_id': planId});
final planData = data['plan'] as Map<String, dynamic>? ?? {};
final items = (data['items'] as List<dynamic>? ?? [])
.map((e) => PlanItemInfo(
id: (e['id'] as num).toInt(),
slot: e['slot'] as String? ?? '',
source: e['source'] as String? ?? 'new',
wardrobeItemId: (e['wardrobe_item_id'] as num?)?.toInt() ?? 0,
name: e['name'] as String? ?? '',
desc: e['desc'] as String? ?? '',
))
.toList();
final images = (data['images'] as List<dynamic>? ?? [])
.map((e) => PlanEffectImageInfo(
id: (e['id'] as num).toInt(),
angle: e['angle'] as String? ?? '',
url: e['url'] as String? ?? '',
status: e['status'] as String? ?? 'pending',
))
.toList();
final hairstyle = data['hairstyle'] as Map<String, dynamic>?;
return PlanDetail(
plan: OutfitPlanInfo(
id: (planData['id'] as num).toInt(),
title: planData['title'] as String? ?? '',
source: planData['source'] as String? ?? 'rule',
score: (planData['score'] as num?)?.toInt() ?? 0,
mainFlag: (planData['main_flag'] as num?)?.toInt() ?? 0,
dateRange: planData['date_range'] as String? ?? '',
location: planData['location'] as String? ?? '',
hairstyleId: (planData['hairstyle_id'] as num?)?.toInt() ?? 0,
hairColor: planData['hair_color'] as String? ?? '',
),
items: items,
images: images,
hairstyleName: hairstyle?['name'] as String? ?? '',
);
});
+120
View File
@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import '../../core/config/app_config.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'outfit_provider.dart';
const _angleLabels = {
'front': '正面',
'side': '侧面',
'back': '背面',
};
/// 效果图查看:3 视角(正面/侧面/背面)切换
class PlanEffectPage extends StatefulWidget {
final List<PlanEffectImageInfo> images;
const PlanEffectPage({super.key, required this.images});
@override
State<PlanEffectPage> createState() => _PlanEffectPageState();
}
class _PlanEffectPageState extends State<PlanEffectPage> {
String? _selected; // 当前角度,null = 展示全部
@override
Widget build(BuildContext context) {
final done = widget.images.where((i) => i.done).toList();
final pending = widget.images.where((i) => !i.done).toList();
final angles = widget.images.map((i) => i.angle).toSet();
return Scaffold(
appBar: AppBar(title: const Text('效果图')),
body: Column(
children: [
if (angles.length > 1)
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: const Text('全部'),
selected: _selected == null,
onSelected: (_) => setState(() => _selected = null),
),
),
for (final a in angles)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(_angleLabels[a] ?? a),
selected: _selected == a,
onSelected: (_) => setState(() => _selected = a),
),
),
],
),
),
Expanded(
child: done.isEmpty
? (pending.isEmpty
? const EmptyView(
message: '暂无效果图,选定主方案后自动生成(每日限 3 次)')
: const LoadingView(text: '效果图生成中,请稍后刷新...'))
: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: _selected == null
? done.length
: done.where((i) => i.angle == _selected).length,
itemBuilder: (ctx, idx) {
final shown = _selected == null
? done
: done.where((i) => i.angle == _selected).toList();
final img = shown[idx];
return Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Image.network(
AppConfig.resolveUrl(img.url),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(_angleLabels[img.angle] ?? img.angle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold)),
),
],
),
);
},
),
),
],
),
);
}
}
+287
View File
@@ -0,0 +1,287 @@
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 '../../shared/widgets/loading_view.dart';
import 'outfit_provider.dart';
/// 方案流:手指左右滑动切换方案,底部操作(效果图/选定/收藏)
class PlanViewerPage extends ConsumerWidget {
const PlanViewerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
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)),
);
},
),
);
}
}
class _PlanDetailView extends ConsumerStatefulWidget {
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: ', '')}');
}
}
@override
Widget build(BuildContext context) {
final detail = ref.watch(planDetailProvider(widget.planId));
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(16),
child: detail.when(
loading: () => const LoadingView(text: '加载方案详情...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (d) {
final plan = d.plan;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(plan.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold)),
),
if (plan.isMain)
const Chip(
label: Text('主方案'),
labelStyle:
TextStyle(color: Colors.white, fontSize: 11),
backgroundColor: Colors.indigo,
visualDensity: VisualDensity.compact,
),
if (!plan.isMain)
Chip(
label: Text(plan.fromAi ? 'AI 生成' : '规则生成'),
labelStyle: TextStyle(
color: scheme.primary, fontSize: 11),
backgroundColor: scheme.primaryContainer,
visualDensity: VisualDensity.compact,
),
],
),
const SizedBox(height: 4),
Text(
'${plan.dateRange} · ${plan.location} · 评分 ${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: 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),
),
),
],
),
),
),
const SizedBox(height: 12),
Text('穿衣清单',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: scheme.primary)),
const SizedBox(height: 8),
if (d.items.isEmpty)
const Text('暂无穿搭单品',
style: TextStyle(color: Colors.grey)),
for (final item in d.items)
Card(
child: ListTile(
dense: true,
leading: Icon(_slotIcon(item.slot)),
title: Text(item.name),
subtitle: Text(item.desc),
trailing: item.fromWardrobe
? const Chip(
label: Text('衣橱'),
labelStyle: TextStyle(
color: Colors.white, fontSize: 11),
backgroundColor: Colors.teal,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
)
: const Chip(
label: Text('新品'),
labelStyle: TextStyle(
color: Colors.white, fontSize: 11),
backgroundColor: Colors.orange,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
),
),
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),
),
),
),
),
),
],
),
),
],
),
),
),
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('收藏'),
),
),
],
),
],
);
},
),
);
}
IconData _slotIcon(String slot) {
switch (slot) {
case '上衣':
return Icons.checkroom;
case '下装':
return Icons.airline_seat_legroom_normal;
case '':
return Icons.directions_walk;
case '配饰':
return Icons.watch_outlined;
default:
return Icons.style_outlined;
}
}
}
@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import '../../shared/widgets/avatar_viewer.dart';
import '../../shared/widgets/loading_view.dart';
import 'avatar_provider.dart';
/// 3D 化身查看页
class AvatarViewerPage extends ConsumerWidget {
const AvatarViewerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final avatar = ref.watch(avatarProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的 3D 化身')),
body: avatar.when(
loading: () => const LoadingView(text: '加载化身...'),
error: (e, _) => Center(child: Text('加载失败:$e')),
data: (a) => ListView(
padding: const EdgeInsets.all(16),
children: [
AvatarViewer(
glbUrl: a.glbUrl,
title: '我的 3D 化身',
subtitle: a.built
? '脸型模板 ${a.faceTemplateId} · 体型模板 ${a.bodyTemplateId} · 肤色 ${a.skinToneIndex}'
: '尚未构建化身',
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('构建说明',
style: TextStyle(
fontSize: 15, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text(
'化身由脸型(20 档)× 体型(6 档)× 肤色(5 档)模板组合而成,'
'依据大头照、全身照与身形参数自动匹配。'
'3D 渲染在预烘焙 GLB 资产接入后启用。',
style: TextStyle(fontSize: 13, color: Colors.grey),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
await ref
.read(avatarProvider.notifier)
.buildAvatar();
if (context.mounted) {
Fluttertoast.showToast(msg: '化身构建完成');
}
},
icon: const Icon(Icons.refresh),
label: const Text('重新构建化身'),
),
),
],
),
),
),
],
),
),
);
}
}
+9 -7
View File
@@ -67,13 +67,15 @@ class ProfilePage extends ConsumerWidget {
text: a.built
? '已构建(模板 ${a.faceTemplateId}/${a.bodyTemplateId}'
: '尚未构建化身,需要先完成照片与身形',
actionText: a.built ? '重新构建' : '立即构建',
onAction: () async {
await ref.read(avatarProvider.notifier).buildAvatar();
if (context.mounted) {
Fluttertoast.showToast(msg: '化身构建完成');
}
},
actionText: a.built ? '查看' : '立即构建',
onAction: a.built
? () => context.go('/avatar-viewer')
: () async {
await ref.read(avatarProvider.notifier).buildAvatar();
if (context.mounted) {
Fluttertoast.showToast(msg: '化身构建完成');
}
},
),
),
);
+17
View File
@@ -6,6 +6,10 @@ import 'features/auth/login_page.dart';
import 'features/commercial/commercial_page.dart';
import 'features/home/home_page.dart';
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_viewer_page.dart';
import 'features/profile/body_tune_page.dart';
import 'features/profile/photo_guide_page.dart';
import 'features/profile/profile_page.dart';
@@ -75,6 +79,19 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/commercial',
builder: (ctx, state) => const CommercialPage(),
),
GoRoute(
path: '/plan-viewer',
builder: (ctx, state) => const PlanViewerPage(),
),
GoRoute(
path: '/plan-effect',
builder: (ctx, state) =>
PlanEffectPage(images: state.extra as List<PlanEffectImageInfo>),
),
GoRoute(
path: '/avatar-viewer',
builder: (ctx, state) => const AvatarViewerPage(),
),
],
);
});
+242
View File
@@ -0,0 +1,242 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gl/flutter_gl.dart';
import 'package:three_dart/three_dart.dart' as three;
import 'package:three_dart_jsm/three_dart_jsm.dart' as three_jsm;
import '../../core/config/app_config.dart';
/// 是否启用 three_dart 3D 渲染。
/// MVP 阶段服务端尚无预烘焙 GLB 资产(/workspace/templates/*.glb 为构建产物),
/// 默认关闭展示信息占位;接入真实 GLB 资源后置为 true 即可启用。
const bool kEnable3D = false;
/// 3D 化身查看组件:three_dart 渲染 GLB,加载失败/未启用时展示信息占位
class AvatarViewer extends StatelessWidget {
final String? glbUrl;
final String title;
final String subtitle;
final double height;
const AvatarViewer({
super.key,
required this.glbUrl,
required this.title,
required this.subtitle,
this.height = 360,
});
@override
Widget build(BuildContext context) {
if (!kEnable3D || glbUrl == null || glbUrl!.isEmpty) {
return _AvatarPlaceholder(
title: title, subtitle: subtitle, height: height);
}
return _ThreeDAvatarViewer(
url: AppConfig.resolveUrl(glbUrl!),
title: title,
subtitle: subtitle,
height: height,
);
}
}
/// three_dart 实现:flutter_gl 初始化 + GLTFLoader 加载 + 自动旋转渲染
class _ThreeDAvatarViewer extends StatefulWidget {
final String url;
final String title;
final String subtitle;
final double height;
const _ThreeDAvatarViewer({
required this.url,
required this.title,
required this.subtitle,
required this.height,
});
@override
State<_ThreeDAvatarViewer> createState() => _ThreeDAvatarViewerState();
}
class _ThreeDAvatarViewerState extends State<_ThreeDAvatarViewer> {
FlutterGlPlugin? _gl;
three.WebGLRenderer? _renderer;
three.Scene? _scene;
three.Camera? _camera;
three.Object3D? _model;
dynamic _sourceTexture;
double _width = 300;
double _dpr = 1;
bool _ready = false;
bool _failed = false;
bool _disposed = false;
Timer? _timer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _init());
}
@override
void dispose() {
_disposed = true;
_timer?.cancel();
super.dispose();
}
Future<void> _init() async {
final mq = MediaQuery.of(context);
_width = mq.size.width;
_dpr = mq.devicePixelRatio;
try {
final gl = FlutterGlPlugin();
await gl.initialize(options: {
'antialias': true,
'alpha': false,
'width': _width.toInt(),
'height': widget.height.toInt(),
'dpr': _dpr,
});
await gl.prepareContext();
final renderer = three.WebGLRenderer({
'width': _width,
'height': widget.height,
'gl': gl.gl,
'antialias': true,
'canvas': gl.element,
});
renderer.setPixelRatio(_dpr);
renderer.setSize(_width, widget.height, false);
final target = three.WebGLMultisampleRenderTarget(
(_width * _dpr).toInt(), (widget.height * _dpr).toInt(),
three.WebGLRenderTargetOptions({'format': three.RGBAFormat}));
target.samples = 4;
renderer.setRenderTarget(target);
final sourceTexture = renderer.getRenderTargetGLTexture(target);
await _loadModel(gl, renderer);
_gl = gl;
_renderer = renderer;
_sourceTexture = sourceTexture;
if (mounted) setState(() => _ready = true);
_timer = Timer.periodic(
const Duration(milliseconds: 33), (_) => _render());
} catch (e) {
debugPrint('3D 渲染不可用,回退占位:$e');
if (mounted) setState(() => _failed = true);
}
}
Future<void> _loadModel(
FlutterGlPlugin gl, three.WebGLRenderer renderer) async {
final scene = three.Scene();
final camera = three.PerspectiveCamera(45, _width / widget.height, 0.1, 100);
camera.position.set(0, 1.4, 3.2);
scene.add(three.AmbientLight(0xffffff, 0.9));
final keyLight = three.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(2, 4, 3);
scene.add(keyLight);
scene.add(camera);
camera.lookAt(three.Vector3(0, 1, 0));
final loader = three_jsm.GLTFLoader(null);
final result = await loader.loadAsync(widget.url);
final model = result['scene'] as three.Object3D?;
if (model != null) {
model.rotation.y = 0.6;
scene.add(model);
}
_scene = scene;
_camera = camera;
_model = model;
}
void _render() {
if (_disposed || _renderer == null || _scene == null || _camera == null) {
return;
}
final model = _model;
if (model != null) {
model.rotation.y += 0.01; // 自动旋转
}
_renderer!.render(_scene!, _camera!);
_gl!.gl.flush();
if (!kIsWeb) {
_gl!.updateTexture(_sourceTexture);
}
}
@override
Widget build(BuildContext context) {
if (_failed) {
return _AvatarPlaceholder(
title: widget.title, subtitle: widget.subtitle, height: widget.height);
}
return SizedBox(
height: widget.height,
width: double.infinity,
child: _ready
? (kIsWeb
? HtmlElementView(viewType: _gl!.textureId!.toString())
: Texture(textureId: _gl!.textureId!))
: const Center(child: CircularProgressIndicator()),
);
}
}
/// 占位:GLB 资产未接入或加载失败时展示化身信息
class _AvatarPlaceholder extends StatelessWidget {
final String title;
final String subtitle;
final double height;
const _AvatarPlaceholder({
required this.title,
required this.subtitle,
required this.height,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [scheme.primaryContainer, scheme.primary.withValues(alpha: 0.3)],
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.accessibility_new, size: 72, color: scheme.primary),
const SizedBox(height: 12),
Text(title,
style:
const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
subtitle.isEmpty ? '3D 渲染接入中(v2' : subtitle,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
),
],
),
);
}
}