Files
slogan/lib/features/outfit/plan_viewer_page.dart
T

387 lines
15 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 '../../features/cps/cps_provider.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;
// 商业化入口:推荐为空/未配置时 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: [
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: 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('做同款发型'),
),
),
],
),
),
),
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.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(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),
),
),
),
),
),
],
),
),
],
),
),
),
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 _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,
),
),
if (rec != null && rec.isNotEmpty)
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 ? '到店试穿' : '买同款'),
),
),
],
),
);
}
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;
}
}
}