From f3d91cd548da583fae45daca4ffd6e7a0c4185cc 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 13:55:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E5=91=98=E4=B8=AD=E5=BF=83?= =?UTF-8?q?=E9=A1=B5=EF=BC=88=E4=BC=9A=E5=91=98=E5=8D=A1/=E5=A5=97?= =?UTF-8?q?=E9=A4=90=E5=BC=B9=E5=B1=82/=E5=B9=BF=E5=91=8A=E6=BF=80?= =?UTF-8?q?=E5=8A=B1/=E5=90=88=E4=BD=9C=E9=97=A8=E5=BA=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/commercial/commercial_page.dart | 178 -------- lib/features/home/home_page.dart | 8 +- lib/features/member/member_center_page.dart | 416 +++++++++++++++++++ lib/main.dart | 4 +- 4 files changed, 422 insertions(+), 184 deletions(-) delete mode 100644 lib/features/commercial/commercial_page.dart create mode 100644 lib/features/member/member_center_page.dart diff --git a/lib/features/commercial/commercial_page.dart b/lib/features/commercial/commercial_page.dart deleted file mode 100644 index fdf09f3..0000000 --- a/lib/features/commercial/commercial_page.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; -import '../../shared/widgets/error_view.dart'; -import '../../shared/widgets/loading_view.dart'; - -class PartnerStoreInfo { - final int id; - final String name; - final int type; // 1 造型/发型店,2 服装店 - final String address; - final String commissionPolicy; - - const PartnerStoreInfo({ - required this.id, - required this.name, - required this.type, - required this.address, - required this.commissionPolicy, - }); -} - -class StoreNotifier extends AsyncNotifier> { - @override - Future> build() async { - final api = ref.read(apiClientProvider); - final list = await api.get>('/partner-store/list'); - return list - .map((e) => PartnerStoreInfo( - id: (e['id'] as num).toInt(), - name: e['name'] as String? ?? '', - type: (e['type'] as num?)?.toInt() ?? 1, - address: e['address'] as String? ?? '', - commissionPolicy: e['commission_policy'] as String? ?? '', - )) - .toList(); - } - - Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(build); - } -} - -final storeProvider = - AsyncNotifierProvider>( - StoreNotifier.new); - -const _storeTypeLabels = {1: '造型', 2: '服装'}; - -/// 商业化:订阅权益 + 合作门店(佣金导流) -class CommercialPage extends ConsumerStatefulWidget { - const CommercialPage({super.key}); - - @override - ConsumerState createState() => _CommercialPageState(); -} - -class _CommercialPageState extends ConsumerState { - int? _typeFilter; - - @override - Widget build(BuildContext context) { - final stores = ref.watch(storeProvider); - final scheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar(title: const Text('门店与服务')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - // 订阅卡片(MVP 占位,支付接入后启用) - Card( - color: scheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.workspace_premium, - size: 36, color: scheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('形象会员', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Text( - '会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣(接入支付后开通)', - style: TextStyle( - fontSize: 12, color: scheme.primary), - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - Text('合作门店', - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.bold)), - const Spacer(), - ChoiceChip( - label: const Text('全部'), - selected: _typeFilter == null, - onSelected: (_) => setState(() => _typeFilter = null), - ), - const SizedBox(width: 8), - for (final entry in _storeTypeLabels.entries) ...[ - ChoiceChip( - label: Text(entry.value), - selected: _typeFilter == entry.key, - onSelected: (_) => - setState(() => _typeFilter = entry.key), - ), - const SizedBox(width: 8), - ], - ], - ), - const SizedBox(height: 8), - stores.when( - loading: () => const Padding( - padding: EdgeInsets.only(top: 32), - child: LoadingView(text: '加载门店...')), - error: (e, _) => Padding( - padding: const EdgeInsets.only(top: 32), - child: ErrorView( - message: e.toString().replaceFirst('Exception: ', ''), - onRetry: () => ref.read(storeProvider.notifier).refresh(), - ), - ), - data: (list) { - final shown = _typeFilter == null - ? list - : list.where((s) => s.type == _typeFilter).toList(); - if (shown.isEmpty) { - return const Padding( - padding: EdgeInsets.only(top: 32), - child: Center( - child: Text('附近暂无可合作门店', - style: TextStyle(color: Colors.grey))), - ); - } - return Column( - children: [ - for (final s in shown) ...[ - Card( - child: ListTile( - leading: CircleAvatar( - backgroundColor: scheme.primaryContainer, - child: Icon(s.type == 1 - ? Icons.content_cut - : Icons.checkroom), - ), - title: Text(s.name), - subtitle: Text('${s.address}\n${s.commissionPolicy}'), - isThreeLine: true, - trailing: const Icon(Icons.chevron_right, - color: Colors.grey), - ), - ), - const SizedBox(height: 8), - ], - ], - ); - }, - ), - ], - ), - ); - } -} diff --git a/lib/features/home/home_page.dart b/lib/features/home/home_page.dart index c0df186..e13aa3e 100644 --- a/lib/features/home/home_page.dart +++ b/lib/features/home/home_page.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import '../commercial/commercial_page.dart'; +import '../member/member_center_page.dart'; import '../outfit/outfit_page.dart'; import '../profile/profile_page.dart'; import '../wardrobe/wardrobe_page.dart'; @@ -16,7 +16,7 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { int _index = 0; - static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '门店电商']; + static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '会员中心']; @override Widget build(BuildContext context) { @@ -28,7 +28,7 @@ class _HomePageState extends State { ProfilePage(), WardrobePage(), OutfitPage(), - CommercialPage(), + MemberCenterPage(), ], ), bottomNavigationBar: NavigationBar( @@ -50,7 +50,7 @@ class _HomePageState extends State { NavigationDestination( icon: Icon(Icons.store_outlined), selectedIcon: Icon(Icons.store), - label: '门店'), + label: '会员'), ], ), ); diff --git a/lib/features/member/member_center_page.dart b/lib/features/member/member_center_page.dart new file mode 100644 index 0000000..0bfd961 --- /dev/null +++ b/lib/features/member/member_center_page.dart @@ -0,0 +1,416 @@ +import 'dart:io'; + +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/ads/ads_service.dart'; +import '../../core/auth/auth_provider.dart'; +import '../../shared/widgets/error_view.dart'; +import '../../shared/widgets/loading_view.dart'; +import 'member_provider.dart'; +import 'pay_page.dart'; + +class PartnerStoreInfo { + final int id; + final String name; + final int type; // 1 造型/发型店,2 服装店 + final String address; + final String commissionPolicy; + + const PartnerStoreInfo({ + required this.id, + required this.name, + required this.type, + required this.address, + required this.commissionPolicy, + }); +} + +class StoreNotifier extends AsyncNotifier> { + @override + Future> build() async { + final api = ref.read(apiClientProvider); + final list = await api.get>('/partner-store/list'); + return list + .map((e) => PartnerStoreInfo( + id: (e['id'] as num).toInt(), + name: e['name'] as String? ?? '', + type: (e['type'] as num?)?.toInt() ?? 1, + address: e['address'] as String? ?? '', + commissionPolicy: e['commission_policy'] as String? ?? '', + )) + .toList(); + } + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard(build); + } +} + +final storeProvider = + AsyncNotifierProvider>(StoreNotifier.new); + +const _storeTypeLabels = {1: '造型', 2: '服装'}; + +/// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1) +class MemberCenterPage extends ConsumerStatefulWidget { + const MemberCenterPage({super.key}); + + @override + ConsumerState createState() => _MemberCenterPageState(); +} + +class _MemberCenterPageState extends ConsumerState { + int? _typeFilter; + bool _rewarding = false; + + bool get _isIOS => Platform.isIOS; + + Future _openPlans() async { + final plans = await showModalBottomSheet( + 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; + Fluttertoast.showToast( + msg: e.toString().replaceFirst('Exception: ', '')); + } + } + + Future _claimReward(String adType, String successMsg) async { + if (_rewarding) return; + final ads = ref.read(adsServiceProvider); + if (!ads.enabled) { + Fluttertoast.showToast(msg: '广告功能暂未开通'); + return; + } + setState(() => _rewarding = true); + try { + final watched = await ads.showRewarded(); + if (!watched) { + Fluttertoast.showToast(msg: '未完整观看,无法领取'); + return; + } + final remaining = + await ref.read(memberProvider.notifier).claimReward(adType); + if (!mounted) return; + Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)'); + } catch (e) { + if (!mounted) return; + Fluttertoast.showToast( + msg: e.toString().replaceFirst('Exception: ', '')); + } finally { + if (mounted) setState(() => _rewarding = false); + } + } + + @override + Widget build(BuildContext context) { + final member = ref.watch(memberProvider); + final stores = ref.watch(storeProvider); + final scheme = Theme.of(context).colorScheme; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _MemberCard( + member: member, + isIOS: _isIOS, + onOpenPlans: _openPlans, + ), + if (member.value?.isVip == false) ...[ + const SizedBox(height: 12), + _AdsRewardCard( + rewarding: _rewarding, + onClaim: (adType, msg) => _claimReward(adType, msg), + ), + ], + const SizedBox(height: 16), + Row( + children: [ + Text('合作门店', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + const Spacer(), + ChoiceChip( + label: const Text('全部'), + selected: _typeFilter == null, + onSelected: (_) => setState(() => _typeFilter = null), + ), + const SizedBox(width: 8), + for (final entry in _storeTypeLabels.entries) ...[ + ChoiceChip( + label: Text(entry.value), + selected: _typeFilter == entry.key, + onSelected: (_) => setState(() => _typeFilter = entry.key), + ), + const SizedBox(width: 8), + ], + ], + ), + const SizedBox(height: 8), + stores.when( + loading: () => const Padding( + padding: EdgeInsets.only(top: 32), child: LoadingView(text: '加载门店...')), + error: (e, _) => Padding( + padding: const EdgeInsets.only(top: 32), + child: ErrorView( + message: e.toString().replaceFirst('Exception: ', ''), + onRetry: () => ref.read(storeProvider.notifier).refresh(), + ), + ), + data: (list) { + final shown = _typeFilter == null + ? list + : list.where((s) => s.type == _typeFilter).toList(); + if (shown.isEmpty) { + return const Padding( + padding: EdgeInsets.only(top: 32), + child: Center( + child: Text('附近暂无可合作门店', + style: TextStyle(color: Colors.grey))), + ); + } + return Column( + children: [ + for (final s in shown) ...[ + Card( + child: ListTile( + leading: CircleAvatar( + backgroundColor: scheme.primaryContainer, + child: Icon( + s.type == 1 ? Icons.content_cut : Icons.checkroom), + ), + title: Text(s.name), + subtitle: Text('${s.address}\n${s.commissionPolicy}'), + isThreeLine: true, + trailing: + const Icon(Icons.chevron_right, color: Colors.grey), + ), + ), + const SizedBox(height: 8), + ], + ], + ); + }, + ), + ], + ); + } +} + +class _MemberCard extends ConsumerWidget { + final AsyncValue 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)), + 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('开通会员'), + ), + ], + ); + }, + ), + ), + ); + } +} + +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), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 0cdebdc..14f37a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'features/auth/login_page.dart'; -import 'features/commercial/commercial_page.dart'; import 'features/home/home_page.dart'; +import 'features/member/member_center_page.dart'; import 'features/outfit/outfit_page.dart'; import 'features/outfit/outfit_provider.dart'; import 'features/outfit/plan_effect_page.dart'; @@ -77,7 +77,7 @@ final routerProvider = Provider((ref) { ), GoRoute( path: '/commercial', - builder: (ctx, state) => const CommercialPage(), + builder: (ctx, state) => const MemberCenterPage(), ), GoRoute( path: '/plan-viewer',