458 lines
17 KiB
Dart
458 lines
17 KiB
Dart
import 'package:flutter/foundation.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
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/app_toast.dart';
|
||
import '../../shared/widgets/loading_view.dart';
|
||
import '../member/member_provider.dart';
|
||
import '../member/pay_page.dart';
|
||
|
||
/// 我的:个人信息管理(会员卡/免费权益/最近优惠 + 形象/衣橱入口 + 退出登录)
|
||
class MinePage extends ConsumerStatefulWidget {
|
||
const MinePage({super.key});
|
||
|
||
@override
|
||
ConsumerState<MinePage> createState() => _MinePageState();
|
||
}
|
||
|
||
class _MinePageState extends ConsumerState<MinePage> {
|
||
bool _rewarding = false;
|
||
|
||
// web 上无 Platform(dart:io 不可用),且 web 端默认走系统浏览器收银台
|
||
bool get _isIOS =>
|
||
!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS;
|
||
|
||
Future<void> _openPlans() async {
|
||
final plans = await showModalBottomSheet<MemberPlan>(
|
||
context: context,
|
||
builder: (ctx) => const _PlanSheet(),
|
||
);
|
||
if (plans == null || !mounted) return;
|
||
try {
|
||
final result = await createMemberOrder(ref, plans.id);
|
||
if (!mounted || result.payUrl.isEmpty) return;
|
||
await context.push(
|
||
'/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl));
|
||
ref.read(memberProvider.notifier).refresh();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
showToast(e.toString().replaceFirst('Exception: ', ''));
|
||
}
|
||
}
|
||
|
||
Future<void> _claimReward(String adType, String successMsg) async {
|
||
if (_rewarding) return;
|
||
final ads = ref.read(adsServiceProvider);
|
||
if (!ads.enabled) {
|
||
showToast('广告功能暂未开通');
|
||
return;
|
||
}
|
||
setState(() => _rewarding = true);
|
||
try {
|
||
final watched = await ads.showRewarded();
|
||
if (!watched) {
|
||
showToast('未完整观看,无法领取');
|
||
return;
|
||
}
|
||
final remaining =
|
||
await ref.read(memberProvider.notifier).claimReward(adType);
|
||
if (!mounted) return;
|
||
showToast('$successMsg(今日剩余 $remaining 次)');
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
showToast(e.toString().replaceFirst('Exception: ', ''));
|
||
} finally {
|
||
if (mounted) setState(() => _rewarding = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final member = ref.watch(memberProvider);
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('我的')),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
_MemberCard(
|
||
member: member,
|
||
isIOS: _isIOS,
|
||
onOpenPlans: _openPlans,
|
||
),
|
||
const SizedBox(height: 12),
|
||
const _RecentDealsCard(),
|
||
const SizedBox(height: 16),
|
||
Card(
|
||
child: Column(
|
||
children: [
|
||
ListTile(
|
||
leading: const Icon(Icons.face_retouching_natural),
|
||
title: const Text('我的形象'),
|
||
subtitle: const Text('拍摄照片 · 身形参数 · 生成 3D 化身'),
|
||
trailing:
|
||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||
onTap: () => context.push('/avatar-build'),
|
||
),
|
||
const Divider(height: 1, indent: 56),
|
||
ListTile(
|
||
leading: const Icon(Icons.checkroom),
|
||
title: const Text('我的衣橱'),
|
||
subtitle: const Text('管理服装单品,AI 生成时自动搭配'),
|
||
trailing:
|
||
const Icon(Icons.chevron_right, color: Colors.grey),
|
||
onTap: () => context.push('/wardrobe'),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (member.value?.isVip == false) ...[
|
||
const SizedBox(height: 12),
|
||
_AdsRewardCard(
|
||
rewarding: _rewarding,
|
||
onClaim: (adType, msg) => _claimReward(adType, msg),
|
||
),
|
||
],
|
||
const SizedBox(height: 24),
|
||
OutlinedButton.icon(
|
||
onPressed: () async {
|
||
await ref.read(authProvider.notifier).logout();
|
||
if (context.mounted) context.go('/login');
|
||
},
|
||
icon: const Icon(Icons.logout),
|
||
label: const Text('退出登录'),
|
||
style: OutlinedButton.styleFrom(foregroundColor: Colors.red),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _MemberCard extends ConsumerWidget {
|
||
final AsyncValue<MemberInfo> member;
|
||
final bool isIOS;
|
||
final VoidCallback onOpenPlans;
|
||
|
||
const _MemberCard(
|
||
{required this.member, required this.isIOS, required this.onOpenPlans});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final scheme = Theme.of(context).colorScheme;
|
||
return Card(
|
||
color: scheme.primaryContainer,
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: member.when(
|
||
loading: () => const Text('加载会员状态...',
|
||
style: TextStyle(fontSize: 13)),
|
||
error: (e, _) => Text('会员状态加载失败:$e',
|
||
style: const TextStyle(fontSize: 12)),
|
||
data: (m) {
|
||
if (m.isVip) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Icon(Icons.workspace_premium,
|
||
size: 30, color: scheme.primary),
|
||
const SizedBox(width: 10),
|
||
const Text('形象会员',
|
||
style: TextStyle(
|
||
fontSize: 17, fontWeight: FontWeight.bold)),
|
||
const Spacer(),
|
||
Chip(
|
||
label: Text(m.planName),
|
||
labelStyle:
|
||
TextStyle(color: scheme.primary, fontSize: 12),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
]),
|
||
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(
|
||
spacing: 6,
|
||
runSpacing: 6,
|
||
children: [
|
||
for (final b in benefitTexts(m))
|
||
Chip(
|
||
label: Text(b),
|
||
labelStyle: const TextStyle(fontSize: 11),
|
||
visualDensity: VisualDensity.compact,
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Row(children: [
|
||
Icon(Icons.workspace_premium, size: 30),
|
||
SizedBox(width: 10),
|
||
Text('形象会员',
|
||
style: TextStyle(
|
||
fontSize: 17, fontWeight: FontWeight.bold)),
|
||
]),
|
||
const SizedBox(height: 6),
|
||
const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣',
|
||
style: TextStyle(fontSize: 12)),
|
||
const SizedBox(height: 10),
|
||
if (isIOS)
|
||
const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员',
|
||
style: TextStyle(fontSize: 11, color: Colors.grey))
|
||
else
|
||
FilledButton.icon(
|
||
onPressed: onOpenPlans,
|
||
icon: const Icon(Icons.payment, size: 18),
|
||
label: const Text('开通会员'),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 会员码(纯客户端展示,门店出示核销)
|
||
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;
|
||
|
||
const _AdsRewardCard({required this.rewarding, required this.onClaim});
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('免费获取权益',
|
||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 8),
|
||
ListTile(
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple),
|
||
title: const Text('看视频 · 效果图 +1'),
|
||
subtitle: const Text('每日最多 2 次,次日重置'),
|
||
trailing: OutlinedButton(
|
||
onPressed: rewarding
|
||
? null
|
||
: () => onClaim('effect_extra', '已获得 1 次效果图'),
|
||
child: const Text('看视频'),
|
||
),
|
||
),
|
||
ListTile(
|
||
dense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
leading: const Icon(Icons.ondemand_video, color: Colors.teal),
|
||
title: const Text('看视频 · 体验会员 1 天'),
|
||
subtitle: const Text('每日最多 1 次,含无限效果图'),
|
||
trailing: OutlinedButton(
|
||
onPressed: rewarding
|
||
? null
|
||
: () => onClaim('vip_trial', '已获得 1 天体验会员'),
|
||
child: const Text('看视频'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PlanSheet extends ConsumerWidget {
|
||
const _PlanSheet();
|
||
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final plans = ref.watch(memberPlanProvider);
|
||
return SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Text('选择会员套餐',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||
const SizedBox(height: 12),
|
||
plans.when(
|
||
loading: () => const Padding(
|
||
padding: EdgeInsets.all(24), child: LoadingView()),
|
||
error: (e, _) => Text('套餐加载失败:$e',
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(color: Colors.grey)),
|
||
data: (list) => list.isEmpty
|
||
? const Padding(
|
||
padding: EdgeInsets.all(24),
|
||
child: Text('暂未开放套餐', textAlign: TextAlign.center),
|
||
)
|
||
: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
for (final p in list)
|
||
ListTile(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(8)),
|
||
tileColor: Theme.of(context)
|
||
.colorScheme
|
||
.primaryContainer
|
||
.withValues(alpha: 0.5),
|
||
title: Text(p.name,
|
||
style: const TextStyle(
|
||
fontSize: 15, fontWeight: FontWeight.w600)),
|
||
subtitle: Text(
|
||
'${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}',
|
||
style: const TextStyle(fontSize: 12),
|
||
maxLines: 2,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
trailing: Text(p.priceText,
|
||
style: TextStyle(
|
||
color:
|
||
Theme.of(context).colorScheme.primary,
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.bold)),
|
||
onTap: () => Navigator.pop(context, p),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|