feat: 商业化 P1+P2 客户端(CPS 入口 + 方案驱动推荐 + 广告位 + 降级)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:59:21 +08:00
co-authored by Claude Opus 4.7
parent f43f402381
commit 5dda43a530
11 changed files with 895 additions and 45 deletions
+30 -2
View File
@@ -1,21 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../config/app_config.dart';
/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK
abstract class AdsService {
/// 广告是否开通(AppConfig.pangleAppId 非空);未开通时广告位与激励均不渲染
bool get enabled;
Future<bool> showRewarded();
/// 横幅/信息流广告位(Mock 返回占位卡,P1 换原生广告组件)
Widget showBanner(BuildContext context);
}
/// 本地模拟激励视频(约 1 秒"播放"后返回完整观看)
/// 本地模拟广告(约 1 秒"播放"后返回完整观看)
class MockAdsService implements AdsService {
@override
bool get enabled => true;
bool get enabled => AppConfig.pangleAppId.isNotEmpty;
@override
Future<bool> showRewarded() async {
await Future.delayed(const Duration(milliseconds: 900));
return true;
}
@override
Widget showBanner(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
height: 90,
margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
decoration: BoxDecoration(
color: scheme.primaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.campaign_outlined, size: 20),
SizedBox(width: 8),
Text('广告位(P1 接入穿山甲)', style: TextStyle(fontSize: 12)),
],
),
);
}
}
final adsServiceProvider = Provider<AdsService>((ref) {
+3
View File
@@ -8,4 +8,7 @@ class AppConfig {
if (path.startsWith('http')) return path;
return '$baseUrl$path';
}
/// 穿山甲 App ID(空 = 广告未开通,广告位不渲染)
static const String pangleAppId = '';
}
+238
View File
@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
import '../../features/member/member_provider.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'cps_provider.dart';
/// 联盟选品池:分类 chips + 上滑分页 + 转链跳转
class CpsProductListPage extends ConsumerStatefulWidget {
final CpsListArgs args;
const CpsProductListPage({required this.args, super.key});
@override
ConsumerState<CpsProductListPage> createState() =>
_CpsProductListPageState();
}
class _CpsProductListPageState extends ConsumerState<CpsProductListPage> {
final _scrollController = ScrollController();
String? _catCode; // null = 全部
@override
void initState() {
super.initState();
_scrollController.addListener(() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref
.read(cpsProductProvider(widget.args).notifier)
.loadMore();
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
Future<void> _openLink(CpsProductInfo product, String scene, int planId) async {
try {
final link = await ref.read(cpsLinkProvider).link(ref,
productId: product.id, scene: scene, planId: planId);
if (link.isEmpty) {
Fluttertoast.showToast(msg: '暂无可跳转链接');
return;
}
final uri = Uri.parse(link);
if (uri.scheme.startsWith('http') &&
await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
// 淘口令等非 URL 内容:复制到剪贴板
await _copy(link);
}
} catch (e) {
if (!mounted) return;
Fluttertoast.showToast(
msg: '跳转失败:${e.toString().replaceFirst('Exception: ', '')}');
}
}
Future<void> _copy(String text) async {
final data = ClipboardData(text: text);
await Clipboard.setData(data);
if (mounted) Fluttertoast.showToast(msg: '已复制,打开淘宝即可购买');
}
@override
Widget build(BuildContext context) {
final categories = ref.watch(cpsCategoryProvider);
final products = ref.watch(cpsProductProvider(widget.args));
final member = ref.watch(memberProvider).value;
final isVip = member?.isVip ?? false;
return Scaffold(
appBar: AppBar(title: Text(widget.args.title)),
body: Column(
children: [
categories.when(
loading: () => const SizedBox(height: 48),
error: (_, _) => const SizedBox(height: 48),
data: (cats) {
final shown = widget.args.categoryCode.isNotEmpty
? cats.where((c) => c.code == widget.args.categoryCode).toList()
: cats;
if (shown.isEmpty) return const SizedBox(height: 48);
return 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: _catCode == null,
onSelected: (_) => setState(() => _catCode = null),
),
),
for (final c in shown)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(c.name),
selected: _catCode == c.code,
onSelected: (_) => setState(() => _catCode = c.code),
),
),
],
),
);
},
),
Expanded(
child: products.when(
loading: () => const LoadingView(text: '加载商品...'),
error: (e, _) => ErrorView(
message: e.toString().replaceFirst('Exception: ', ''),
onRetry: () =>
ref.refresh(cpsProductProvider(widget.args)),
),
data: (list) {
final shown = _catCode == null
? list
: list.where((p) => p.categoryCode == _catCode).toList();
if (shown.isEmpty) {
return const EmptyView(message: '暂无联盟商品');
}
return GridView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(12),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.72,
),
itemCount: shown.length,
itemBuilder: (ctx, i) {
final p = shown[i];
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () =>
_openLink(p, widget.args.scene, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
fit: StackFit.expand,
children: [
p.coverUrl.isEmpty
? const Icon(Icons.shopping_bag_outlined,
size: 48, color: Colors.grey)
: Image.network(
AppConfig.resolveUrl(p.coverUrl),
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
size: 48,
color: Colors.grey),
),
if (isVip)
Positioned(
top: 6,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.amber.shade600,
borderRadius:
BorderRadius.circular(6),
),
child: const Text('VIP 返现 1.5x',
style: TextStyle(
color: Colors.white,
fontSize: 10)),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
p.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
'¥${p.priceText}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.red.shade600),
),
if (p.commissionText.isNotEmpty)
Text(
p.commissionText,
style: const TextStyle(
fontSize: 11, color: Colors.grey),
),
],
),
),
],
),
),
);
},
);
},
),
),
],
),
);
}
}
+200
View File
@@ -0,0 +1,200 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/auth/auth_provider.dart';
/// CPS 联盟商品(后端 /cps/* 模型)
class CpsProductInfo {
final int id;
final String source;
final String outerId;
final String categoryCode;
final String name;
final String coverUrl;
final int priceFen;
final String shopName;
final int commissionRate; // 万分比
final String city;
const CpsProductInfo({
required this.id,
required this.source,
required this.outerId,
required this.categoryCode,
required this.name,
required this.coverUrl,
required this.priceFen,
required this.shopName,
required this.commissionRate,
required this.city,
});
String get priceText => (priceFen / 100).toStringAsFixed(0);
String get commissionText {
final pct = commissionRate / 100;
return pct > 0 ? '佣金 ${pct.toStringAsFixed(1)}%' : '';
}
}
CpsProductInfo _parseProduct(Map<String, dynamic> e) => CpsProductInfo(
id: (e['id'] as num).toInt(),
source: e['source'] as String? ?? '',
outerId: e['outer_id'] as String? ?? '',
categoryCode: e['category_code'] as String? ?? '',
name: e['name'] as String? ?? '',
coverUrl: e['cover_url'] as String? ?? '',
priceFen: (e['price_fen'] as num?)?.toInt() ?? 0,
shopName: e['shop_name'] as String? ?? '',
commissionRate: (e['commission_rate'] as num?)?.toInt() ?? 0,
city: e['city'] as String? ?? '',
);
/// 联盟分类(chips
class CpsCategoryInfo {
final String code;
final String name;
final String source;
const CpsCategoryInfo({
required this.code,
required this.name,
required this.source,
});
}
final cpsCategoryProvider = FutureProvider<List<CpsCategoryInfo>>((ref) async {
final api = ref.read(apiClientProvider);
final list = await api.get<List<dynamic>>('/cps/category/list');
return list
.map((e) => CpsCategoryInfo(
code: e['code'] as String? ?? '',
name: e['name'] as String? ?? '',
source: e['source'] as String? ?? '',
))
.toList();
});
/// 商品列表页参数
class CpsListArgs {
final String title;
final String source;
final String categoryCode;
final String city;
final String scene; // 点击日志场景标记(haircut/item_buy/...
const CpsListArgs({
required this.title,
this.source = '',
this.categoryCode = '',
this.city = '',
this.scene = '',
});
@override
bool operator ==(Object other) =>
other is CpsListArgs &&
other.source == source &&
other.categoryCode == categoryCode &&
other.city == city &&
other.scene == scene;
@override
int get hashCode => Object.hash(source, categoryCode, city, scene);
}
/// 选品池分页(上滑加载更多;Riverpod 3 family 参数经构造函数注入)
class CpsProductListNotifier extends AsyncNotifier<List<CpsProductInfo>> {
CpsProductListNotifier(this.arg);
final CpsListArgs arg;
int _page = 1;
bool _hasMore = true;
@override
Future<List<CpsProductInfo>> build() async {
_page = 1;
_hasMore = true;
return _fetch(1);
}
Future<void> loadMore() async {
if (!_hasMore || state.isLoading) return;
_page += 1;
try {
final next = await _fetch(_page);
state = AsyncData([...?state.value, ...next]);
} catch (_) {
_page -= 1; // 失败回退页码,允许下次重试
}
}
Future<List<CpsProductInfo>> _fetch(int page) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/product/list',
query: {
if (arg.source.isNotEmpty) 'source': arg.source,
if (arg.categoryCode.isNotEmpty) 'category_code': arg.categoryCode,
if (arg.city.isNotEmpty) 'city': arg.city,
'page': page,
});
_hasMore = data['has_more'] as bool? ?? false;
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
}
}
final cpsProductProvider = AsyncNotifierProvider.family<
CpsProductListNotifier, List<CpsProductInfo>, CpsListArgs>(
CpsProductListNotifier.new);
/// 方案驱动推荐(plan_id + scene
final cpsRecommendProvider = FutureProvider.family<List<CpsProductInfo>,
({int planId, String scene})>((ref, args) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/plan/recommend',
query: {'plan_id': args.planId, 'scene': args.scene});
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
});
/// 衣橱升级款
final cpsUpgradeProvider =
FutureProvider.family<List<CpsProductInfo>, int>((ref, itemId) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/wardrobe/upgrade',
query: {'item_id': itemId});
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
});
/// 转链动作(POST /cps/product/link,返回 deeplink
class CpsLinkAction {
Future<String> link(
WidgetRef ref, {
required int productId,
String scene = '',
int planId = 0,
}) async {
final api = ref.read(apiClientProvider);
final data = await api.post<Map<String, dynamic>>('/cps/product/link', {
'product_id': productId,
'scene': scene,
'plan_id': planId,
});
return data['deeplink'] as String? ?? '';
}
}
final cpsLinkProvider = Provider<CpsLinkAction>((ref) => CpsLinkAction());
/// 最近优惠(会员中心)
final cpsRecentProvider = FutureProvider<List<CpsProductInfo>>((ref) async {
final api = ref.read(apiClientProvider);
final data = await api.get<Map<String, dynamic>>('/cps/my/recent');
return (data['list'] as List<dynamic>? ?? [])
.map((e) => _parseProduct(e as Map<String, dynamic>))
.toList();
});
+98 -4
View File
@@ -1,23 +1,46 @@
import 'package:flutter/material.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/ads/ads_service.dart';
import '../member/member_center_page.dart';
import '../outfit/outfit_page.dart';
import '../profile/profile_page.dart';
import '../wardrobe/wardrobe_page.dart';
/// 主框架:4 Tab(我的形象 / 我的衣橱 / 穿搭方案 / 门店电商)
class HomePage extends StatefulWidget {
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
class _HomePageState extends ConsumerState<HomePage> {
int _index = 0;
bool _splashShown = false;
static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '会员中心'];
@override
void initState() {
super.initState();
// 开屏广告:仅广告开通时展示一次(P0 Mock 下 pangleAppId 为空 → 跳过)
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_splashShown) return;
if (!ref.read(adsServiceProvider).enabled) return;
_splashShown = true;
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => _SplashAdOverlay(
onSkip: () => Navigator.of(context).pop(),
),
);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
@@ -56,3 +79,74 @@ class _HomePageState extends State<HomePage> {
);
}
}
/// 开屏广告:3 秒倒计时后自动关闭,右上角可手动跳过
class _SplashAdOverlay extends StatefulWidget {
final VoidCallback onSkip;
const _SplashAdOverlay({required this.onSkip});
@override
State<_SplashAdOverlay> createState() => _SplashAdOverlayState();
}
class _SplashAdOverlayState extends State<_SplashAdOverlay> {
Timer? _timer;
int _seconds = 3;
@override
void initState() {
super.initState();
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (_seconds <= 1) {
_timer?.cancel();
if (mounted) widget.onSkip();
return;
}
setState(() => _seconds -= 1);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: false,
child: ColoredBox(
color: Theme.of(context).colorScheme.primaryContainer,
child: Stack(
children: [
const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.campaign_outlined, size: 64),
SizedBox(height: 12),
Text('开屏广告位',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('P1 接入穿山甲开屏广告',
style: TextStyle(fontSize: 12, color: Colors.grey)),
],
),
),
Positioned(
top: 24,
right: 24,
child: OutlinedButton(
onPressed: widget.onSkip,
child: Text('跳过($_seconds'),
),
),
],
),
),
);
}
}
+126 -2
View File
@@ -7,6 +7,8 @@ import 'package:go_router/go_router.dart';
import '../../core/ads/ads_service.dart';
import '../../core/auth/auth_provider.dart';
import '../../core/config/app_config.dart';
import '../../features/cps/cps_provider.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
import 'member_provider.dart';
@@ -127,6 +129,8 @@ class _MemberCenterPageState extends ConsumerState<MemberCenterPage> {
isIOS: _isIOS,
onOpenPlans: _openPlans,
),
const SizedBox(height: 12),
const _RecentDealsCard(),
if (member.value?.isVip == false) ...[
const SizedBox(height: 12),
_AdsRewardCard(
@@ -192,8 +196,17 @@ class _MemberCenterPageState extends ConsumerState<MemberCenterPage> {
title: Text(s.name),
subtitle: Text('${s.address}\n${s.commissionPolicy}'),
isThreeLine: true,
trailing:
const Icon(Icons.chevron_right, color: Colors.grey),
trailing: member.value?.isVip == true
? const Chip(
label: Text('会员价'),
labelStyle: TextStyle(
color: Colors.white, fontSize: 10),
backgroundColor: Colors.amber,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
)
: const Icon(Icons.chevron_right,
color: Colors.grey),
),
),
const SizedBox(height: 8),
@@ -249,6 +262,15 @@ class _MemberCard extends ConsumerWidget {
const SizedBox(height: 6),
Text('有效期至 ${m.expireAt}',
style: TextStyle(fontSize: 12, color: scheme.primary)),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: FilledButton.tonalIcon(
onPressed: () => _showMemberCode(context, m),
icon: const Icon(Icons.qr_code_2, size: 18),
label: const Text('会员码'),
),
),
if (benefitTexts(m).isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
@@ -299,6 +321,108 @@ class _MemberCard extends ConsumerWidget {
}
}
/// 会员码(纯客户端展示,门店出示核销)
void _showMemberCode(BuildContext context, MemberInfo m) {
showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('会员码'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Theme.of(ctx).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.qr_code_2, size: 96),
),
const SizedBox(height: 12),
Text('${m.planName} · 有效期至 ${m.expireAt}',
style: const TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 4),
const Text('到店出示即可享受会员价与专属折扣',
style: TextStyle(fontSize: 12)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭'),
),
],
),
);
}
/// 最近优惠卡:接口错误或空列表时整卡隐藏
class _RecentDealsCard extends ConsumerWidget {
const _RecentDealsCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final deals = ref.watch(cpsRecentProvider).value;
if (deals == null || deals.isEmpty) return const SizedBox.shrink();
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('最近优惠',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
for (final p in deals.take(5))
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: p.coverUrl.isEmpty
? const SizedBox(
width: 40,
height: 40,
child: Icon(Icons.shopping_bag_outlined,
color: Colors.grey),
)
: Image.network(
AppConfig.resolveUrl(p.coverUrl),
width: 40,
height: 40,
fit: BoxFit.cover,
errorBuilder: (_, _, _) => const SizedBox(
width: 40,
height: 40,
child: Icon(Icons.broken_image_outlined,
color: Colors.grey),
),
),
),
title: Text(p.name,
maxLines: 1, overflow: TextOverflow.ellipsis),
subtitle: Text(
'¥${p.priceText}${p.shopName.isNotEmpty ? ' · ${p.shopName}' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis),
trailing:
const Icon(Icons.chevron_right, size: 18, color: Colors.grey),
onTap: () => context.push('/cps-product-list', extra: CpsListArgs(
title: '最近优惠',
source: p.source,
categoryCode: p.categoryCode,
city: p.city,
scene: 'member_benefit',
)),
),
],
),
),
);
}
}
class _AdsRewardCard extends ConsumerWidget {
final bool rewarding;
final void Function(String adType, String msg) onClaim;
+3
View File
@@ -12,6 +12,7 @@ class OutfitPlanInfo {
final String location;
final int hairstyleId;
final String hairColor;
final String occasion;
const OutfitPlanInfo({
required this.id,
@@ -23,6 +24,7 @@ class OutfitPlanInfo {
required this.location,
required this.hairstyleId,
required this.hairColor,
this.occasion = '',
});
bool get isMain => mainFlag == 1;
@@ -237,6 +239,7 @@ final planDetailProvider = FutureProvider.family<PlanDetail, int>((ref, planId)
location: planData['location'] as String? ?? '',
hairstyleId: (planData['hairstyle_id'] as num?)?.toInt() ?? 0,
hairColor: planData['hair_color'] as String? ?? '',
occasion: planData['occasion'] as String? ?? '',
),
items: items,
images: images,
+8 -3
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/ads/ads_service.dart';
import '../../core/config/app_config.dart';
import '../../shared/widgets/empty_view.dart';
import '../../shared/widgets/loading_view.dart';
@@ -12,16 +14,16 @@ const _angleLabels = {
};
/// 效果图查看:3 视角(正面/侧面/背面)切换
class PlanEffectPage extends StatefulWidget {
class PlanEffectPage extends ConsumerStatefulWidget {
final List<PlanEffectImageInfo> images;
const PlanEffectPage({super.key, required this.images});
@override
State<PlanEffectPage> createState() => _PlanEffectPageState();
ConsumerState<PlanEffectPage> createState() => _PlanEffectPageState();
}
class _PlanEffectPageState extends State<PlanEffectPage> {
class _PlanEffectPageState extends ConsumerState<PlanEffectPage> {
String? _selected; // 当前角度,null = 展示全部
@override
@@ -29,6 +31,7 @@ class _PlanEffectPageState extends State<PlanEffectPage> {
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();
final ads = ref.watch(adsServiceProvider);
return Scaffold(
appBar: AppBar(title: const Text('效果图')),
@@ -113,6 +116,8 @@ class _PlanEffectPageState extends State<PlanEffectPage> {
},
),
),
// 信息流广告位:广告未开通(pangleAppId 为空)不渲染
if (ads.enabled) ads.showBanner(context),
],
),
);
+132 -33
View File
@@ -4,6 +4,7 @@ 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';
@@ -85,6 +86,15 @@ 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,21 +140,50 @@ class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
color: scheme.primaryContainer.withValues(alpha: 0.4),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
),
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(
@@ -156,30 +195,11 @@ class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
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,
),
),
_ItemEntryCard(
planId: plan.id,
item: item,
onOpen: (title, scene, rec) =>
_openCpsList(title, scene, rec),
),
const SizedBox(height: 8),
Text('效果图',
@@ -270,6 +290,85 @@ class _PlanDetailViewState extends ConsumerState<_PlanDetailView> {
);
}
/// 商业化入口:推荐为空时不跳转
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 '上衣':
+50 -1
View File
@@ -1,8 +1,10 @@
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/empty_view.dart';
import '../../shared/widgets/error_view.dart';
import '../../shared/widgets/loading_view.dart';
@@ -19,6 +21,53 @@ class WardrobePage extends ConsumerStatefulWidget {
class _WardrobePageState extends ConsumerState<WardrobePage> {
String? _filter; // 当前分类筛选,null = 全部
/// 长按菜单:找升级款 / 删除
Future<void> _showItemMenu(WardrobeItemInfo item) async {
final action = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.upgrade_outlined),
title: const Text('找升级款'),
onTap: () => Navigator.pop(ctx, 'upgrade'),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('删除'),
onTap: () => Navigator.pop(ctx, 'delete'),
),
],
),
),
);
if (action == 'upgrade') {
await _openUpgrade(item);
} else if (action == 'delete') {
await _confirmDelete(item);
}
}
/// 升级款推荐:空结果提示后不跳转
Future<void> _openUpgrade(WardrobeItemInfo item) async {
final rec = await ref.read(cpsUpgradeProvider(item.id).future);
if (!mounted) return;
if (rec.isEmpty) {
Fluttertoast.showToast(msg: '暂无升级款');
return;
}
final first = rec.first;
context.push('/cps-product-list', extra: CpsListArgs(
title: '${item.category}升级款',
source: first.source,
categoryCode: first.categoryCode,
city: first.city,
scene: 'wardrobe_upgrade',
));
}
Future<void> _confirmDelete(WardrobeItemInfo item) async {
final ok = await showDialog<bool>(
context: context,
@@ -95,7 +144,7 @@ class _WardrobePageState extends ConsumerState<WardrobePage> {
itemBuilder: (ctx, i) {
final item = shown[i];
return GestureDetector(
onLongPress: () => _confirmDelete(item),
onLongPress: () => _showItemMenu(item),
child: Card(
clipBehavior: Clip.antiAlias,
child: Column(
+7
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'features/auth/login_page.dart';
import 'features/cps/cps_product_list_page.dart';
import 'features/cps/cps_provider.dart';
import 'features/home/home_page.dart';
import 'features/member/member_center_page.dart';
import 'features/member/pay_page.dart';
@@ -97,6 +99,11 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/avatar-viewer',
builder: (ctx, state) => const AvatarViewerPage(),
),
GoRoute(
path: '/cps-product-list',
builder: (ctx, state) =>
CpsProductListPage(args: state.extra! as CpsListArgs),
),
],
);
});