feat: 商业化 P1+P2 客户端(CPS 入口 + 方案驱动推荐 + 广告位 + 降级)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -8,4 +8,7 @@ class AppConfig {
|
||||
if (path.startsWith('http')) return path;
|
||||
return '$baseUrl$path';
|
||||
}
|
||||
|
||||
/// 穿山甲 App ID(空 = 广告未开通,广告位不渲染)
|
||||
static const String pangleAppId = '';
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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 '上衣':
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user