Files
slogan/app/lib/features/mine/mine_page.dart
T
2026-08-04 17:04:18 +08:00

458 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 上无 Platformdart: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),
),
],
),
),
],
),
),
);
}
}