Files
slogan/app/lib/features/outfit/plan_viewer_page.dart
T
admin b53c57e02d Add 'app/' from commit 'a11a66886941bba128d89c1124115e1b1c87a128'
git-subtree-dir: app
git-subtree-mainline: 6ebd902c6b
git-subtree-split: a11a668869
2026-08-04 14:59:55 +08:00

310 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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});
@override
Widget build(BuildContext context, WidgetRef ref) {
final plans = ref.watch(outfitPlanProvider);
return Scaffold(
appBar: AppBar(title: const Text('穿搭方案')),
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 ConsumerWidget {
final int planId;
const _PlanDetailView({required this.planId, super.key});
/// 商业化入口:推荐为空时也跳转(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, WidgetRef ref) {
final detail = ref.watch(planDetailProvider(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.score}',
style: const TextStyle(color: Colors.grey)),
const SizedBox(height: 12),
// 穿搭清单:标题行 + 场景延伸优惠入口
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('延伸优惠'),
),
],
),
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)),
for (final item in d.items)
_ItemEntryCard(
planId: plan.id,
item: item,
onOpen: (title, scene, rec) =>
_openCpsList(context, title, scene, rec),
),
],
),
),
),
],
);
},
),
);
}
}
/// 穿搭清单项:发型(含"做同款发型"入口,推荐为空也跳转)
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;
final void Function(String title, String scene, List<CpsProductInfo> rec)
onOpen;
const _ItemEntryCard({
required this.planId,
required this.item,
required this.onOpen,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final scene = item.fromWardrobe ? 'item_upgrade' : 'item_buy';
final rec = ref
.watch(cpsRecommendProvider((planId: planId, scene: scene)))
.value;
return Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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,
),
),
// 衣橱已有单品无需入口;仅 AI 推荐新品展示购买入口
if (!item.fromWardrobe)
Align(
alignment: Alignment.centerRight,
child: TextButton.icon(
onPressed: () => onOpen('去购买', scene, rec ?? []),
icon: const Icon(Icons.shopping_cart_outlined, size: 16),
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;
}
}
}