subscription_controller.dart 14 KB

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