- 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 路由
288 lines
12 KiB
Dart
288 lines
12 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|