From 5dda43a53007194fc514e06608d828d4a3590f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Fri, 31 Jul 2026 15:59:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=95=86=E4=B8=9A=E5=8C=96=20P1+P2=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=EF=BC=88CPS=20=E5=85=A5=E5=8F=A3=20?= =?UTF-8?q?+=20=E6=96=B9=E6=A1=88=E9=A9=B1=E5=8A=A8=E6=8E=A8=E8=8D=90=20+?= =?UTF-8?q?=20=E5=B9=BF=E5=91=8A=E4=BD=8D=20+=20=E9=99=8D=E7=BA=A7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- lib/core/ads/ads_service.dart | 32 ++- lib/core/config/app_config.dart | 3 + lib/features/cps/cps_product_list_page.dart | 238 ++++++++++++++++++++ lib/features/cps/cps_provider.dart | 200 ++++++++++++++++ lib/features/home/home_page.dart | 102 ++++++++- lib/features/member/member_center_page.dart | 128 ++++++++++- lib/features/outfit/outfit_provider.dart | 3 + lib/features/outfit/plan_effect_page.dart | 11 +- lib/features/outfit/plan_viewer_page.dart | 165 +++++++++++--- lib/features/wardrobe/wardrobe_page.dart | 51 ++++- lib/main.dart | 7 + 11 files changed, 895 insertions(+), 45 deletions(-) create mode 100644 lib/features/cps/cps_product_list_page.dart create mode 100644 lib/features/cps/cps_provider.dart diff --git a/lib/core/ads/ads_service.dart b/lib/core/ads/ads_service.dart index 5e6a968..84a70ad 100644 --- a/lib/core/ads/ads_service.dart +++ b/lib/core/ads/ads_service.dart @@ -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 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 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((ref) { diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart index 65ba6bf..d77b6ff 100644 --- a/lib/core/config/app_config.dart +++ b/lib/core/config/app_config.dart @@ -8,4 +8,7 @@ class AppConfig { if (path.startsWith('http')) return path; return '$baseUrl$path'; } + + /// 穿山甲 App ID(空 = 广告未开通,广告位不渲染) + static const String pangleAppId = ''; } diff --git a/lib/features/cps/cps_product_list_page.dart b/lib/features/cps/cps_product_list_page.dart new file mode 100644 index 0000000..e1ca0ac --- /dev/null +++ b/lib/features/cps/cps_product_list_page.dart @@ -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 createState() => + _CpsProductListPageState(); +} + +class _CpsProductListPageState extends ConsumerState { + 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 _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 _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), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/cps/cps_provider.dart b/lib/features/cps/cps_provider.dart new file mode 100644 index 0000000..99c8290 --- /dev/null +++ b/lib/features/cps/cps_provider.dart @@ -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 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>((ref) async { + final api = ref.read(apiClientProvider); + final list = await api.get>('/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> { + CpsProductListNotifier(this.arg); + + final CpsListArgs arg; + int _page = 1; + bool _hasMore = true; + + @override + Future> build() async { + _page = 1; + _hasMore = true; + return _fetch(1); + } + + Future loadMore() async { + if (!_hasMore || state.isLoading) return; + _page += 1; + try { + final next = await _fetch(_page); + state = AsyncData([...?state.value, ...next]); + } catch (_) { + _page -= 1; // 失败回退页码,允许下次重试 + } + } + + Future> _fetch(int page) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/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? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); + } +} + +final cpsProductProvider = AsyncNotifierProvider.family< + CpsProductListNotifier, List, CpsListArgs>( + CpsProductListNotifier.new); + +/// 方案驱动推荐(plan_id + scene) +final cpsRecommendProvider = FutureProvider.family, + ({int planId, String scene})>((ref, args) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/plan/recommend', + query: {'plan_id': args.planId, 'scene': args.scene}); + return (data['list'] as List? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); +}); + +/// 衣橱升级款 +final cpsUpgradeProvider = + FutureProvider.family, int>((ref, itemId) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/wardrobe/upgrade', + query: {'item_id': itemId}); + return (data['list'] as List? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); +}); + +/// 转链动作(POST /cps/product/link,返回 deeplink) +class CpsLinkAction { + Future link( + WidgetRef ref, { + required int productId, + String scene = '', + int planId = 0, + }) async { + final api = ref.read(apiClientProvider); + final data = await api.post>('/cps/product/link', { + 'product_id': productId, + 'scene': scene, + 'plan_id': planId, + }); + return data['deeplink'] as String? ?? ''; + } +} + +final cpsLinkProvider = Provider((ref) => CpsLinkAction()); + +/// 最近优惠(会员中心) +final cpsRecentProvider = FutureProvider>((ref) async { + final api = ref.read(apiClientProvider); + final data = await api.get>('/cps/my/recent'); + return (data['list'] as List? ?? []) + .map((e) => _parseProduct(e as Map)) + .toList(); +}); diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index e13aa3e..92d461f 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -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 createState() => _HomePageState(); + ConsumerState createState() => _HomePageState(); } -class _HomePageState extends State { +class _HomePageState extends ConsumerState { 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( + 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 { ); } } + +/// 开屏广告: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)'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/member/member_center_page.dart b/lib/features/member/member_center_page.dart index 0bfd961..f4d08f4 100644 --- a/lib/features/member/member_center_page.dart +++ b/lib/features/member/member_center_page.dart @@ -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 { 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 { 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( + 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; diff --git a/lib/features/outfit/outfit_provider.dart b/lib/features/outfit/outfit_provider.dart index ce51ee1..0188dea 100644 --- a/lib/features/outfit/outfit_provider.dart +++ b/lib/features/outfit/outfit_provider.dart @@ -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((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, diff --git a/lib/features/outfit/plan_effect_page.dart b/lib/features/outfit/plan_effect_page.dart index ac9db06..a3e4a9a 100644 --- a/lib/features/outfit/plan_effect_page.dart +++ b/lib/features/outfit/plan_effect_page.dart @@ -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 images; const PlanEffectPage({super.key, required this.images}); @override - State createState() => _PlanEffectPageState(); + ConsumerState createState() => _PlanEffectPageState(); } -class _PlanEffectPageState extends State { +class _PlanEffectPageState extends ConsumerState { String? _selected; // 当前角度,null = 展示全部 @override @@ -29,6 +31,7 @@ class _PlanEffectPageState extends State { 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 { }, ), ), + // 信息流广告位:广告未开通(pangleAppId 为空)不渲染 + if (ads.enabled) ads.showBanner(context), ], ), ); diff --git a/lib/features/outfit/plan_viewer_page.dart b/lib/features/outfit/plan_viewer_page.dart index bd30552..0884927 100644 --- a/lib/features/outfit/plan_viewer_page.dart +++ b/lib/features/outfit/plan_viewer_page.dart @@ -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 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 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 '上衣': diff --git a/lib/features/wardrobe/wardrobe_page.dart b/lib/features/wardrobe/wardrobe_page.dart index 1d83900..e1b808a 100644 --- a/lib/features/wardrobe/wardrobe_page.dart +++ b/lib/features/wardrobe/wardrobe_page.dart @@ -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 { String? _filter; // 当前分类筛选,null = 全部 + /// 长按菜单:找升级款 / 删除 + Future _showItemMenu(WardrobeItemInfo item) async { + final action = await showModalBottomSheet( + 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 _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 _confirmDelete(WardrobeItemInfo item) async { final ok = await showDialog( context: context, @@ -95,7 +144,7 @@ class _WardrobePageState extends ConsumerState { itemBuilder: (ctx, i) { final item = shown[i]; return GestureDetector( - onLongPress: () => _confirmDelete(item), + onLongPress: () => _showItemMenu(item), child: Card( clipBehavior: Clip.antiAlias, child: Column( diff --git a/lib/main.dart b/lib/main.dart index 375861c..75f193e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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((ref) { path: '/avatar-viewer', builder: (ctx, state) => const AvatarViewerPage(), ), + GoRoute( + path: '/cps-product-list', + builder: (ctx, state) => + CpsProductListPage(args: state.extra! as CpsListArgs), + ), ], ); });