subscription_controller.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  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. }
  109. @override
  110. void onClose() {
  111. videoController.dispose();
  112. // 注意: 不要在这里调用 _iapUtil.dispose()
  113. // 因为它是单例,可能在其他地方还在使用
  114. super.onClose();
  115. }
  116. /// 获取套餐列表
  117. Future<void> _getChannelPlanList() async {
  118. isLoadingPlans.value = true;
  119. try {
  120. final plans = await _apiController.getChannelPlanList();
  121. channelPlans.value = plans;
  122. // 设置默认选中项
  123. final defaultIndex = plans.indexWhere((p) => p.isDefault == true);
  124. if (defaultIndex >= 0) {
  125. selectedPlanIndex.value = defaultIndex;
  126. }
  127. // 加载对应的内购产品
  128. await _loadProductsFromPlans();
  129. } catch (e) {
  130. log(TAG, '获取套餐列表失败: $e');
  131. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '获取套餐列表失败');
  132. } finally {
  133. isLoadingPlans.value = false;
  134. }
  135. }
  136. /// 购买套餐
  137. Future<void> subscribe() async {
  138. await LoadingDialog.show(
  139. context: Get.context!,
  140. loadingText: 'Purchasing...',
  141. successText: 'Purchase successful',
  142. onRequest: () async {
  143. // 执行你的异步请求
  144. await _apiController.subscribe({
  145. 'channelItemId': selectedPlan?.channelItemId,
  146. });
  147. },
  148. onSuccess: () {
  149. // 成功后的操作
  150. },
  151. );
  152. }
  153. /// 初始化内购
  154. Future<void> _initializeInAppPurchase() async {
  155. try {
  156. // 初始化内购
  157. final success = await _iapUtil.initialize(
  158. onSuccess: _handlePurchaseSuccess,
  159. onError: _handlePurchaseError,
  160. onCancelled: _handlePurchaseCancelled,
  161. onPending: _handlePurchasePending,
  162. onRestore: _handleRestoreSuccess,
  163. );
  164. if (!success) {
  165. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '内购功能不可用');
  166. }
  167. } catch (e) {
  168. debugPrint('初始化内购失败: $e');
  169. }
  170. }
  171. /// 根据套餐列表加载内购产品
  172. Future<void> _loadProductsFromPlans() async {
  173. if (channelPlans.isEmpty) return;
  174. isLoadingProducts.value = true;
  175. try {
  176. // 从套餐列表中提取产品ID
  177. final productIdSet = channelPlans
  178. .where((p) => p.payoutData != null && p.payoutData!.isNotEmpty)
  179. .map((p) => p.payoutData!)
  180. .toSet();
  181. if (productIdSet.isEmpty) {
  182. debugPrint('没有需要加载的内购产品');
  183. return;
  184. }
  185. final success = await _iapUtil.loadProducts(productIdSet);
  186. if (success) {
  187. productDetails.value = _iapUtil.products;
  188. debugPrint('加载了 ${productDetails.length} 个产品');
  189. // 刷新套餐列表以更新价格显示
  190. channelPlans.refresh();
  191. }
  192. } catch (e) {
  193. debugPrint('加载产品失败: $e');
  194. } finally {
  195. isLoadingProducts.value = false;
  196. }
  197. }
  198. /// 处理购买成功
  199. void _handlePurchaseSuccess(PurchaseDetails purchaseDetails) {
  200. isPurchasing.value = false;
  201. IXSnackBar.showIXSnackBar(
  202. title: Strings.success.tr,
  203. message: '购买成功: ${purchaseDetails.productID}',
  204. );
  205. // TODO: 这里添加你的业务逻辑
  206. // 1. 更新用户订阅状态到服务器
  207. // 2. 更新本地存储
  208. // 3. 刷新 UI 显示
  209. }
  210. /// 处理购买失败
  211. void _handlePurchaseError(PurchaseDetails purchaseDetails) {
  212. isPurchasing.value = false;
  213. IXSnackBar.showIXSnackBar(
  214. title: Strings.error.tr,
  215. message: '购买失败: ${purchaseDetails.error?.message ?? "未知错误"}',
  216. );
  217. }
  218. /// 处理购买取消
  219. void _handlePurchaseCancelled(PurchaseDetails purchaseDetails) {
  220. isPurchasing.value = false;
  221. IXSnackBar.showIXSnackBar(title: Strings.info.tr, message: '购买已取消');
  222. }
  223. /// 处理购买等待中
  224. void _handlePurchasePending(PurchaseDetails purchaseDetails) {
  225. isPurchasing.value = true;
  226. IXSnackBar.showIXSnackBar(title: Strings.info.tr, message: '购买处理中...');
  227. }
  228. /// 处理恢复购买成功
  229. void _handleRestoreSuccess(PurchaseDetails purchaseDetails) {
  230. IXSnackBar.showIXSnackBar(
  231. title: Strings.success.tr,
  232. message: '恢复购买成功: ${purchaseDetails.productID}',
  233. );
  234. // TODO: 更新用户订阅状态
  235. }
  236. /// 发起购买
  237. Future<void> _requestPurchase(String productId) async {
  238. if (isPurchasing.value) {
  239. debugPrint('已有购买正在进行中');
  240. return;
  241. }
  242. isPurchasing.value = true;
  243. try {
  244. final success = await _iapUtil.purchaseProductById(productId);
  245. if (!success) {
  246. isPurchasing.value = false;
  247. IXSnackBar.showIXSnackBar(
  248. title: Strings.error.tr,
  249. message: '发起购买失败,请重试',
  250. );
  251. }
  252. } catch (e) {
  253. isPurchasing.value = false;
  254. debugPrint('购买异常: $e');
  255. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '购买异常: $e');
  256. }
  257. }
  258. /// 恢复购买
  259. Future<void> _restorePurchases() async {
  260. try {
  261. final success = await _iapUtil.restorePurchases();
  262. if (success) {
  263. IXSnackBar.showIXSnackBar(
  264. title: Strings.info.tr,
  265. message: Strings.restoringPurchases.tr,
  266. );
  267. } else {
  268. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '恢复购买失败');
  269. }
  270. } catch (e) {
  271. debugPrint('恢复购买异常: $e');
  272. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '恢复购买异常: $e');
  273. }
  274. }
  275. // 初始化视频播放器
  276. void _initializeVideoPlayer() {
  277. videoController = VideoPlayerController.asset(Assets.subscriptionBg)
  278. ..initialize()
  279. .then((_) {
  280. isVideoInitialized.value = true;
  281. videoController.setLooping(true);
  282. videoController.setVolume(0); // 静音播放
  283. videoController.play();
  284. })
  285. .catchError((error) {
  286. print('视频初始化失败: $error');
  287. });
  288. }
  289. // 选择订阅计划
  290. void selectPlan(int index) {
  291. if (index >= 0 && index < channelPlans.length) {
  292. selectedPlanIndex.value = index;
  293. }
  294. }
  295. // 获取当前选中的套餐
  296. ChannelPlan? get selectedPlan {
  297. if (selectedPlanIndex.value < channelPlans.length) {
  298. return channelPlans[selectedPlanIndex.value];
  299. }
  300. return null;
  301. }
  302. /// 获取当前选中套餐的设备限制数量
  303. String get selectedPlanDeviceLimit {
  304. return (selectedPlan?.deviceLimit ?? 0).toString();
  305. }
  306. // 确认变更/购买
  307. void confirmChange() {
  308. final plan = selectedPlan;
  309. if (plan == null) {
  310. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '请选择套餐');
  311. return;
  312. }
  313. final productId = plan.payoutData;
  314. if (productId == null || productId.isEmpty) {
  315. IXSnackBar.showIXSnackBar(title: Strings.error.tr, message: '产品ID未配置');
  316. return;
  317. }
  318. _requestPurchase(productId);
  319. }
  320. // 恢复购买
  321. void restorePurchases() {
  322. _restorePurchases();
  323. }
  324. // 支付问题
  325. void handlePaymentIssue() {
  326. // TODO: 实现支付问题处理逻辑
  327. IXSnackBar.showIXSnackBar(
  328. title: Strings.info.tr,
  329. message: Strings.openingPaymentSupport.tr,
  330. );
  331. }
  332. // ==================== 用于 UI 展示的便捷方法 ====================
  333. /// 获取套餐数量
  334. int get planCount => channelPlans.length;
  335. /// 获取指定索引的套餐标题
  336. String getPlanTitle(int index) {
  337. if (index < channelPlans.length) {
  338. return channelPlans[index].title ?? '';
  339. }
  340. return '';
  341. }
  342. /// 获取指定索引的套餐副标题
  343. String getPlanSubTitle(int index) {
  344. if (index < channelPlans.length) {
  345. return channelPlans[index].subTitle ?? '';
  346. }
  347. return '';
  348. }
  349. /// 获取指定索引的套餐介绍
  350. String getPlanIntroduce(int index) {
  351. if (index < channelPlans.length) {
  352. return channelPlans[index].introduce ?? '';
  353. }
  354. return '';
  355. }
  356. /// 获取指定索引的套餐价格显示
  357. String getPlanPrice(int index) {
  358. if (index < channelPlans.length) {
  359. return _getDisplayPrice(channelPlans[index]);
  360. }
  361. return '';
  362. }
  363. /// 获取指定索引的套餐周期显示
  364. String getPlanPeriod(int index) {
  365. if (index < channelPlans.length) {
  366. return _getPeriodText(channelPlans[index]);
  367. }
  368. return '';
  369. }
  370. /// 获取指定索引的套餐标签
  371. String getPlanBadge(int index) {
  372. if (index < channelPlans.length) {
  373. return channelPlans[index].tag ?? '';
  374. }
  375. return '';
  376. }
  377. /// 获取指定索引的标签背景色
  378. Color? getPlanBadgeBgColor(int index) {
  379. if (index < channelPlans.length) {
  380. return _getBadgeBgColor(channelPlans[index]);
  381. }
  382. return null;
  383. }
  384. /// 获取指定索引的标签文字颜色
  385. Color? getPlanBadgeTextColor(int index) {
  386. if (index < channelPlans.length) {
  387. return _getBadgeTextColor(channelPlans[index]);
  388. }
  389. return null;
  390. }
  391. /// 获取指定索引的标签边框颜色
  392. Color? getPlanBadgeBorderColor(int index) {
  393. if (index < channelPlans.length) {
  394. final plan = channelPlans[index];
  395. if (plan.recommend == true) {
  396. return DarkThemeColors.dividerColor;
  397. }
  398. }
  399. return null;
  400. }
  401. /// 是否显示原价(有折扣时显示)
  402. bool showOriginalPrice(int index) {
  403. if (index < channelPlans.length) {
  404. final plan = channelPlans[index];
  405. return plan.orgPrice != null &&
  406. plan.price != null &&
  407. plan.orgPrice! > plan.price!;
  408. }
  409. return false;
  410. }
  411. /// 获取原价显示
  412. String getPlanOriginalPrice(int index) {
  413. if (index < channelPlans.length) {
  414. final plan = channelPlans[index];
  415. return _formatPrice(plan.orgPrice, plan.currency);
  416. }
  417. return '';
  418. }
  419. }