subscription_controller.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. import 'dart:async';
  2. import 'package:flutter/material.dart';
  3. import 'package:flutter/services.dart';
  4. import 'package:get/get.dart';
  5. import 'package:in_app_purchase/in_app_purchase.dart';
  6. import 'package:nomo/config/theme/theme_extensions/theme_extension.dart';
  7. import 'package:video_player/video_player.dart';
  8. import '../../../../config/theme/dark_theme_colors.dart';
  9. import '../../../../config/translations/strings_enum.dart';
  10. import '../../../../utils/in_app_purchase_util.dart';
  11. import '../../../../utils/log/logger.dart';
  12. import '../../../../utils/system_helper.dart';
  13. import '../../../components/ix_snackbar.dart';
  14. import '../../../constants/assets.dart';
  15. import '../../../controllers/api_controller.dart';
  16. import '../../../data/models/channelplan/channel_plan_list.dart';
  17. import '../../../data/sp/ix_sp.dart';
  18. import '../../../dialog/all_dialog.dart';
  19. import '../../../dialog/loading/loading_dialog.dart';
  20. class SubscriptionController extends GetxController {
  21. static const String TAG = 'SubscriptionController';
  22. final ApiController apiController = Get.find<ApiController>();
  23. // 内购工具实例
  24. final InAppPurchaseUtil _iapUtil = InAppPurchaseUtil.instance;
  25. // 视频播放器控制器
  26. late VideoPlayerController videoController;
  27. final isVideoInitialized = false.obs;
  28. // 套餐列表加载状态
  29. final isLoadingPlans = false.obs;
  30. // 产品加载状态
  31. final isLoadingProducts = false.obs;
  32. // 购买处理中状态
  33. final isPurchasing = false.obs;
  34. // 产品列表(内购产品)
  35. final productDetails = <ProductDetails>[].obs;
  36. // 套餐列表(API 返回)
  37. final channelPlans = <ChannelPlan>[].obs;
  38. // 当前选中的订阅计划索引
  39. final selectedPlanIndex = 0.obs;
  40. /// 是否显示套餐变更信息(仅 userLevel == 3 时显示)
  41. bool get showPlanChangeInfo {
  42. final user = IXSP.getUser();
  43. return user?.userLevel == 3;
  44. }
  45. // 获取产品价格(优先使用内购价格,否则使用 API 返回的价格)
  46. String _getDisplayPrice(ChannelPlan plan) {
  47. // 尝试从内购获取价格
  48. final productId = plan.payoutData;
  49. if (productId != null && productId.isNotEmpty) {
  50. final product = _iapUtil.getProductById(productId);
  51. if (product != null) {
  52. return product.price;
  53. }
  54. }
  55. // 使用 API 返回的价格
  56. return _formatPrice(plan.price, plan.currency);
  57. }
  58. // 格式化价格
  59. String _formatPrice(double? price, int? currency) {
  60. if (price == null) return '';
  61. // currency: 1=USD, 2=CNY, etc. 根据实际情况调整
  62. final symbol = currency == 2 ? '¥' : '\$';
  63. return '$symbol${price.toStringAsFixed(2)}';
  64. }
  65. // 获取订阅周期显示文本
  66. String _getPeriodText(ChannelPlan plan) {
  67. if (plan.isSubscribe != true) {
  68. return Strings.once.tr; // 一次性购买(终身)
  69. }
  70. // 根据 subscribeType 和 subscribePeriodValue 确定周期
  71. // subscribeType: 1=天, 2=周, 3=月, 4=年
  72. switch (plan.subscribeType) {
  73. case 1:
  74. final days = plan.subscribePeriodValue ?? 1;
  75. return '/$days day${days > 1 ? 's' : ''}';
  76. case 2:
  77. return Strings.perWeek.tr;
  78. case 3:
  79. return '/month';
  80. case 4:
  81. return Strings.perYear.tr;
  82. default:
  83. return '';
  84. }
  85. }
  86. // 获取标签背景色
  87. Color? _getBadgeBgColor(ChannelPlan plan) {
  88. if (plan.tagType == 1) {
  89. return DarkThemeColors.bg1;
  90. } else if (plan.tagType == 2) {
  91. return DarkThemeColors.primaryColor;
  92. }
  93. return null;
  94. }
  95. // 获取标签文字颜色
  96. Color? _getBadgeTextColor(ChannelPlan plan) {
  97. if (plan.tagType == 1) {
  98. return DarkThemeColors.subscriptionColor;
  99. } else if (plan.tagType == 2) {
  100. return Colors.white;
  101. }
  102. return null;
  103. }
  104. Color? _getBadgeBorderColor(ChannelPlan plan) {
  105. if (plan.tagType == 1) {
  106. return ReactiveTheme.isLightTheme
  107. ? DarkThemeColors.subscriptionColor
  108. : DarkThemeColors.dividerColor;
  109. } else if (plan.tagType == 2) {
  110. return null;
  111. }
  112. return null;
  113. }
  114. @override
  115. void onInit() {
  116. super.onInit();
  117. _initializeVideoPlayer();
  118. // _initializeInAppPurchase();
  119. _getChannelPlanList();
  120. refreshSubscriptionStatus();
  121. // 设置状态栏颜色
  122. _setSystemUIOverlayStyle(ReactiveTheme.isLightTheme, true);
  123. }
  124. void _setSystemUIOverlayStyle(bool isLight, bool changeDarkMode) {
  125. if (isLight) {
  126. if (changeDarkMode) {
  127. SystemChrome.setSystemUIOverlayStyle(
  128. SystemUiOverlayStyle(
  129. statusBarColor: Get.reactiveTheme.scaffoldBackgroundColor,
  130. statusBarIconBrightness: Brightness.light,
  131. statusBarBrightness: Brightness.light,
  132. ),
  133. );
  134. } else {
  135. SystemChrome.setSystemUIOverlayStyle(
  136. SystemUiOverlayStyle(
  137. statusBarColor: Get.reactiveTheme.scaffoldBackgroundColor,
  138. statusBarIconBrightness: Brightness.dark,
  139. statusBarBrightness: Brightness.dark,
  140. ),
  141. );
  142. }
  143. }
  144. }
  145. @override
  146. void onClose() {
  147. videoController.dispose();
  148. _setSystemUIOverlayStyle(ReactiveTheme.isLightTheme, false);
  149. // 注意: 不要在这里调用 _iapUtil.dispose()
  150. // 因为它是单例,可能在其他地方还在使用
  151. super.onClose();
  152. }
  153. /// 获取套餐列表
  154. Future<void> _getChannelPlanList() async {
  155. isLoadingPlans.value = true;
  156. try {
  157. final plans = await apiController.getChannelPlanList();
  158. channelPlans.value = plans;
  159. // 设置默认选中项
  160. final defaultIndex = plans.indexWhere((p) => p.isDefault == true);
  161. if (defaultIndex >= 0) {
  162. selectedPlanIndex.value = defaultIndex;
  163. }
  164. // 加载对应的内购产品
  165. // await _loadProductsFromPlans();
  166. } catch (e) {
  167. log(TAG, '获取套餐列表失败: $e');
  168. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '获取套餐列表失败');
  169. } finally {
  170. isLoadingPlans.value = false;
  171. }
  172. }
  173. /// 购买套餐
  174. Future<void> subscribe() async {
  175. if (selectedPlan?.payoutType == 'default') {
  176. await LoadingDialog.show(
  177. context: Get.context!,
  178. loadingText: Strings.purchasing.tr,
  179. successText: Strings.purchaseSuccess.tr,
  180. onRequest: () async {
  181. // 执行你的异步请求
  182. await apiController.subscribe({
  183. 'channelItemId': selectedPlan?.channelItemId,
  184. });
  185. },
  186. onSuccess: () async {
  187. // 刷新用户信息
  188. refreshSubscriptionStatus();
  189. },
  190. );
  191. } else if (selectedPlan?.payoutType == 'web') {
  192. SystemHelper.openWebPage(selectedPlan?.payoutData ?? '');
  193. AllDialog.showSubscriptionForWeb(() {
  194. apiController.refreshLaunch();
  195. });
  196. }
  197. }
  198. /// 初始化内购
  199. Future<void> _initializeInAppPurchase() async {
  200. try {
  201. // 初始化内购
  202. final success = await _iapUtil.initialize(
  203. onSuccess: _handlePurchaseSuccess,
  204. onError: _handlePurchaseError,
  205. onCancelled: _handlePurchaseCancelled,
  206. onPending: _handlePurchasePending,
  207. onRestore: _handleRestoreSuccess,
  208. );
  209. if (!success) {
  210. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '内购功能不可用');
  211. }
  212. } catch (e) {
  213. debugPrint('初始化内购失败: $e');
  214. }
  215. }
  216. /// 根据套餐列表加载内购产品
  217. Future<void> _loadProductsFromPlans() async {
  218. if (channelPlans.isEmpty) return;
  219. isLoadingProducts.value = true;
  220. try {
  221. // 从套餐列表中提取产品ID
  222. final productIdSet = channelPlans
  223. .where((p) => p.payoutData != null && p.payoutData!.isNotEmpty)
  224. .map((p) => p.payoutData!)
  225. .toSet();
  226. if (productIdSet.isEmpty) {
  227. debugPrint('没有需要加载的内购产品');
  228. return;
  229. }
  230. final success = await _iapUtil.loadProducts(productIdSet);
  231. if (success) {
  232. productDetails.value = _iapUtil.products;
  233. debugPrint('加载了 ${productDetails.length} 个产品');
  234. // 刷新套餐列表以更新价格显示
  235. channelPlans.refresh();
  236. }
  237. } catch (e) {
  238. debugPrint('加载产品失败: $e');
  239. } finally {
  240. isLoadingProducts.value = false;
  241. }
  242. }
  243. /// 处理购买成功
  244. void _handlePurchaseSuccess(PurchaseDetails purchaseDetails) {
  245. isPurchasing.value = false;
  246. IXSnackBar.showIXSnackBar(
  247. title: Strings.success.tr,
  248. message: '购买成功: ${purchaseDetails.productID}',
  249. );
  250. // TODO: 这里添加你的业务逻辑
  251. // 1. 更新用户订阅状态到服务器
  252. // 2. 更新本地存储
  253. // 3. 刷新 UI 显示
  254. }
  255. /// 处理购买失败
  256. void _handlePurchaseError(PurchaseDetails purchaseDetails) {
  257. isPurchasing.value = false;
  258. IXSnackBar.showIXSnackBar(
  259. title: Strings.error.tr,
  260. message: '购买失败: ${purchaseDetails.error?.message ?? "未知错误"}',
  261. );
  262. }
  263. /// 处理购买取消
  264. void _handlePurchaseCancelled(PurchaseDetails purchaseDetails) {
  265. isPurchasing.value = false;
  266. IXSnackBar.showIXSnackBar(title: Strings.info.tr, message: '购买已取消');
  267. }
  268. /// 处理购买等待中
  269. void _handlePurchasePending(PurchaseDetails purchaseDetails) {
  270. isPurchasing.value = true;
  271. IXSnackBar.showIXSnackBar(title: Strings.info.tr, message: '购买处理中...');
  272. }
  273. /// 处理恢复购买成功
  274. void _handleRestoreSuccess(PurchaseDetails purchaseDetails) {
  275. IXSnackBar.showIXSnackBar(
  276. title: Strings.success.tr,
  277. message: '恢复购买成功: ${purchaseDetails.productID}',
  278. );
  279. // TODO: 更新用户订阅状态
  280. }
  281. /// 发起购买
  282. Future<void> _requestPurchase(String productId) async {
  283. if (isPurchasing.value) {
  284. debugPrint('已有购买正在进行中');
  285. return;
  286. }
  287. isPurchasing.value = true;
  288. try {
  289. final success = await _iapUtil.purchaseProductById(productId);
  290. if (!success) {
  291. isPurchasing.value = false;
  292. IXSnackBar.showIXSnackBar(
  293. title: Strings.error.tr,
  294. message: '发起购买失败,请重试',
  295. );
  296. }
  297. } catch (e) {
  298. isPurchasing.value = false;
  299. debugPrint('购买异常: $e');
  300. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '购买异常: $e');
  301. }
  302. }
  303. /// 恢复购买
  304. Future<void> _restorePurchases() async {
  305. try {
  306. final success = await _iapUtil.restorePurchases();
  307. if (success) {
  308. IXSnackBar.showIXSnackBar(
  309. title: Strings.info.tr,
  310. message: Strings.restoringPurchases.tr,
  311. );
  312. } else {
  313. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '恢复购买失败');
  314. }
  315. } catch (e) {
  316. debugPrint('恢复购买异常: $e');
  317. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '恢复购买异常: $e');
  318. }
  319. }
  320. // 初始化视频播放器
  321. void _initializeVideoPlayer() {
  322. videoController = VideoPlayerController.asset(Assets.subscriptionBg)
  323. ..initialize()
  324. .then((_) {
  325. isVideoInitialized.value = true;
  326. videoController.setLooping(true);
  327. videoController.setVolume(0); // 静音播放
  328. videoController.play();
  329. })
  330. .catchError((error) {
  331. print('视频初始化失败: $error');
  332. });
  333. }
  334. // 选择订阅计划
  335. void selectPlan(int index) {
  336. if (index >= 0 && index < channelPlans.length) {
  337. selectedPlanIndex.value = index;
  338. }
  339. }
  340. // 获取当前选中的套餐
  341. ChannelPlan? get selectedPlan {
  342. if (selectedPlanIndex.value < channelPlans.length) {
  343. return channelPlans[selectedPlanIndex.value];
  344. }
  345. return null;
  346. }
  347. /// 获取当前选中套餐的设备限制数量
  348. String get selectedPlanDeviceLimit {
  349. return (selectedPlan?.deviceLimit ?? 0).toString();
  350. }
  351. // 确认变更/购买
  352. void confirmChange() {
  353. final plan = selectedPlan;
  354. if (plan == null) {
  355. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '请选择套餐');
  356. return;
  357. }
  358. final productId = plan.payoutData;
  359. if (productId == null || productId.isEmpty) {
  360. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '产品ID未配置');
  361. return;
  362. }
  363. _requestPurchase(productId);
  364. }
  365. // 恢复购买
  366. void restorePurchases() {
  367. _restorePurchases();
  368. }
  369. // 支付问题
  370. void handlePaymentIssue() {
  371. // TODO: 实现支付问题处理逻辑
  372. IXSnackBar.showIXSnackBar(
  373. title: Strings.info.tr,
  374. message: Strings.openingPaymentSupport.tr,
  375. );
  376. }
  377. // ==================== 用于 UI 展示的便捷方法 ====================
  378. /// 获取套餐数量
  379. int get planCount => channelPlans.length;
  380. /// 获取指定索引的套餐标题
  381. String getPlanTitle(int index) {
  382. if (index < channelPlans.length) {
  383. return channelPlans[index].title ?? '';
  384. }
  385. return '';
  386. }
  387. /// 获取指定索引的套餐副标题
  388. String getPlanSubTitle(int index) {
  389. if (index < channelPlans.length) {
  390. return channelPlans[index].subTitle ?? '';
  391. }
  392. return '';
  393. }
  394. /// 获取指定索引的套餐介绍
  395. String getPlanIntroduce(int index) {
  396. if (index < channelPlans.length) {
  397. return channelPlans[index].introduce ?? '';
  398. }
  399. return '';
  400. }
  401. /// 获取指定索引的套餐价格显示
  402. String getPlanPrice(int index) {
  403. if (index < channelPlans.length) {
  404. return _getDisplayPrice(channelPlans[index]);
  405. }
  406. return '';
  407. }
  408. /// 获取指定索引的套餐周期显示
  409. String getPlanPeriod(int index) {
  410. if (index < channelPlans.length) {
  411. return _getPeriodText(channelPlans[index]);
  412. }
  413. return '';
  414. }
  415. /// 获取指定索引的套餐标签
  416. String getPlanBadge(int index) {
  417. if (index < channelPlans.length) {
  418. return channelPlans[index].tag ?? '';
  419. }
  420. return '';
  421. }
  422. /// 获取指定索引的标签背景色
  423. Color? getPlanBadgeBgColor(int index) {
  424. if (index < channelPlans.length) {
  425. return _getBadgeBgColor(channelPlans[index]);
  426. }
  427. return null;
  428. }
  429. /// 获取指定索引的标签文字颜色
  430. Color? getPlanBadgeTextColor(int index) {
  431. if (index < channelPlans.length) {
  432. return _getBadgeTextColor(channelPlans[index]);
  433. }
  434. return null;
  435. }
  436. /// 获取指定索引的标签边框颜色
  437. Color? getPlanBadgeBorderColor(int index) {
  438. if (index < channelPlans.length) {
  439. return _getBadgeBorderColor(channelPlans[index]);
  440. }
  441. return null;
  442. }
  443. /// 是否显示原价(有折扣时显示)
  444. bool showOriginalPrice(int index) {
  445. if (index < channelPlans.length) {
  446. final plan = channelPlans[index];
  447. return plan.orgPrice != null &&
  448. plan.price != null &&
  449. plan.orgPrice! > plan.price!;
  450. }
  451. return false;
  452. }
  453. /// 获取原价显示
  454. String getPlanOriginalPrice(int index) {
  455. if (index < channelPlans.length) {
  456. final plan = channelPlans[index];
  457. return _formatPrice(plan.orgPrice, plan.currency);
  458. }
  459. return '';
  460. }
  461. // ==================== 当前订阅相关方法 ====================
  462. /// 当前订阅状态(响应式)
  463. final _hasCurrentSubscription = false.obs;
  464. bool get hasCurrentSubscription => _hasCurrentSubscription.value;
  465. /// 当前订阅套餐信息(响应式)
  466. final Rxn<ChannelPlan> _currentPlanInfo = Rxn<ChannelPlan>();
  467. ChannelPlan? get currentPlanInfo => _currentPlanInfo.value;
  468. /// 获取当前订阅套餐标题
  469. String get currentPlanTitle {
  470. return currentPlanInfo?.title ?? '';
  471. }
  472. /// 获取当前订阅套餐价格显示
  473. String get currentPlanPriceDisplay {
  474. final plan = currentPlanInfo;
  475. if (plan == null) return '';
  476. return '${plan.title}-${plan.subTitle}';
  477. }
  478. /// 刷新当前订阅状态
  479. void refreshSubscriptionStatus() {
  480. final user = IXSP.getUser();
  481. _hasCurrentSubscription.value =
  482. user?.userLevel == 3 &&
  483. user?.planInfo != null &&
  484. user?.planInfo?.channelItemId?.isNotEmpty == true;
  485. _currentPlanInfo.value = user?.userLevel == 3 ? user?.planInfo : null;
  486. }
  487. }