purchase_controller.dart 10 KB
import 'dart:async';

import 'package:doublefeel_flutter/app/routes/app_pages.dart';
import 'package:doublefeel_flutter/core/constants/app_const.dart';
import 'package:doublefeel_flutter/core/network/api/pay_api.dart';
import 'package:doublefeel_flutter/core/network/api/vip_api.dart';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/util/app_toast.dart';
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
import 'package:doublefeel_flutter/data/models/local/user_preferences.dart';
import 'package:doublefeel_flutter/data/models/pay/apple_pay_order_response.dart';
import 'package:doublefeel_flutter/data/models/pay/pay_models.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';

class PurchasePlan {
  const PurchasePlan({
    required this.title,
    required this.subtitle,
    required this.price,
    required this.badge,
    this.product,
    this.appleProductId,
    this.actualPrice,
  });

  final String title;
  final String subtitle;
  final String price;
  final String badge;
  final PayProduct? product;
  final String? appleProductId;

  /// Real product price in cents, based on the Chinese price baseline.
  final int? actualPrice;
}

class PurchaseController extends GetxController {
  final PayApi _payApi = Get.find<PayApi>();
  final VipApi _vipApi = Get.find<VipApi>();
  final UserPreferencesStorage _userPreferences =
      Get.find<UserPreferencesStorage>();
  final PlatformHostApi _platformHostApi = PlatformHostApi();

  final scrollController = ScrollController();
  final planScrollController = ScrollController();
  final plans = <PurchasePlan>[].obs;
  final selectedIndex = 0.obs;
  final isScrolled = false.obs;
  final isLoadingProducts = false.obs;
  final isUnlocking = false.obs;
  final isRestoring = false.obs;

  @override
  void onInit() {
    super.onInit();
    scrollController.addListener(_handleScroll);
    unawaited(loadProductList());
  }

  @override
  void onClose() {
    planScrollController.dispose();
    scrollController
      ..removeListener(_handleScroll)
      ..dispose();
    super.onClose();
  }

  void _handleScroll() {
    final next = scrollController.hasClients && scrollController.offset > 250;
    if (next != isScrolled.value) {
      isScrolled.value = next;
    }
  }

  void selectPlan(int index) {
    if (index == selectedIndex.value) return;
    if (index < 0 || index >= plans.length) return;
    selectedIndex.value = index;
    if (!planScrollController.hasClients) return;
    planScrollController.animateTo(
      index * 232,
      duration: const Duration(milliseconds: 220),
      curve: Curves.easeOutCubic,
    );
  }

  Future<void> restorePurchase() async {
    if (isRestoring.value || isUnlocking.value) return;

    isRestoring.value = true;
    try {
      final success = await _performRestore();
      if (success) {
        await _completeSuccessfulPurchase();
      }
    } finally {
      isRestoring.value = false;
    }
  }

  Future<void> unlock() async {
    if (isUnlocking.value || isRestoring.value) return;

    final plan = _selectedPlan;
    final productId = plan?.product?.id;
    final appleProductId = plan?.appleProductId;
    if (productId == null || appleProductId == null || appleProductId.isEmpty) {
      AppToast.show(l10n.purchaseProductInfoUnavailable);
      return;
    }

    isUnlocking.value = true;
    try {
      final orderResult = await _payApi.createOrderByApple(productId);
      if (orderResult case AppFailure<ApplePayOrderResponse>(:final error)) {
        AppToast.show(error.displayMessage);
        return;
      }
      if (orderResult is! AppSuccess<ApplePayOrderResponse>) return;

      final orderUuid = orderResult.data.orderInfo?.orderUuid?.trim();
      if (orderUuid == null || orderUuid.isEmpty) {
        AppToast.show(l10n.purchaseOrderInfoUnavailable);
        return;
      }

      final paymentResult = await _performApplePayment(
        productId: appleProductId,
        orderUuid: orderUuid,
      );
      await _handleApplePaymentResult(paymentResult);
    } finally {
      isUnlocking.value = false;
    }
  }

  Future<void> loadProductList() async {
    isLoadingProducts.value = true;
    plans.clear();
    selectedIndex.value = 0;
    try {
      final result = await _payApi.getProductList(10);
      if (result is! AppSuccess<PayProductListResponse>) return;

      final products = (result.data.productList ?? const <PayProduct>[])
          .where((product) => _nonEmpty(product.appleId) != null)
          .toList();
      plans.assignAll(products.map(_buildPlan));
      isLoadingProducts.value = false;

      await Future.wait(
        products.indexed.map(
          (entry) => _loadAppleProductInfo(entry.$1, entry.$2),
        ),
      );
    } finally {
      isLoadingProducts.value = false;
    }
  }

  Future<void> _loadAppleProductInfo(int index, PayProduct product) async {
    final appleProductId = _nonEmpty(product.appleId);
    if (appleProductId == null) return;

    final appleInfo = await _requestAppleProductInfo(
        productId: appleProductId, baseUnit: product.content?.baseUnit() ?? 1);
    if (appleInfo == null || index >= plans.length) return;
    if (plans[index].product?.id != product.id) return;

    plans[index] = _buildPlan(product, appleInfo);
  }

  Future<AppleProductInfo?> _requestAppleProductInfo({
    required String productId,
    required int baseUnit,
  }) async {
    try {
      return await _platformHostApi.requestAppleProductInfo(
        productId,
        baseUnit,
      );
    } catch (_) {
      return null;
    }
  }

  Future<AppleProductPaymentResult?> _performApplePayment({
    required String productId,
    required String orderUuid,
  }) async {
    try {
      return await _platformHostApi.performApplePayment(productId, orderUuid);
    } catch (_) {
      return null;
    }
  }

  Future<bool> _performRestore() async {
    try {
      return await _platformHostApi.performRestore();
    } catch (_) {
      return false;
    }
  }

  Future<void> _handleApplePaymentResult(
    AppleProductPaymentResult? result,
  ) async {
    if (result?.success == true) {
      await _completeSuccessfulPurchase();
      return;
    }
    if (result?.success != false) return;

    AppToast.show(_applePaymentFailureMessage(result!));
  }

  Future<void> _completeSuccessfulPurchase() async {
    try {
      await _refreshVipInfo();
    } finally {
      Get.offNamed(Routes.PREMIUM_ACTIVATED);
    }
  }

  Future<void> _refreshVipInfo() async {
    final result = await _vipApi.getVipInfo();
    if (result case AppSuccess(data: final vipInfo)) {
      await _userPreferences.updateVipInfo(
        UserPreferencesVipInfo.fromVipInfo(vipInfo),
      );
    }
  }

  String _applePaymentFailureMessage(AppleProductPaymentResult result) {
    final errorCode = result.errorCode;
    if (errorCode != null) {
      return _localizedApplePaymentErrorCode(errorCode);
    }

    final errorMessage = _nonEmpty(result.errorMessage);
    if (errorMessage == null) return l10n.purchaseApplePaymentFailed;
    return _localizedApplePaymentErrorMessage(errorMessage);
  }

  String _localizedApplePaymentErrorCode(int errorCode) {
    return switch (errorCode) {
      -1 => l10n.purchaseApplePaymentInvalidOrder,
      -2 => l10n.purchaseApplePaymentProductNotFound,
      -3 => l10n.purchaseApplePaymentCancelled,
      -4 => l10n.purchaseApplePaymentVerificationFailed,
      -5 => l10n.purchaseApplePaymentFailed,
      _ => l10n.purchaseApplePaymentFailed,
    };
  }

  String _localizedApplePaymentErrorMessage(String errorMessage) {
    if (errorMessage == 'missingUUID') {
      return l10n.purchaseApplePaymentInvalidOrder;
    }
    if (errorMessage == 'productNotFound') {
      return l10n.purchaseApplePaymentProductNotFound;
    }
    if (errorMessage == 'userCancelled') {
      return l10n.purchaseApplePaymentCancelled;
    }
    if (errorMessage == 'failedVerification') {
      return l10n.purchaseApplePaymentVerificationFailed;
    }
    if (errorMessage == 'unknown') {
      return l10n.purchaseApplePaymentFailed;
    }
    return errorMessage;
  }

  PurchasePlan? get _selectedPlan {
    final index = selectedIndex.value;
    if (index < 0 || index >= plans.length) return null;
    return plans[index];
  }

  PurchasePlan _buildPlan(PayProduct product, [AppleProductInfo? appleInfo]) {
    return PurchasePlan(
      title: _nonEmpty(product.name) ?? '',
      subtitle: _planSubtitle(product, appleInfo),
      price: appleInfo == null ? '' : _displayPrice(appleInfo),
      badge: _nonEmpty(product.content?.label) ?? '',
      product: product,
      appleProductId: product.appleId,
      actualPrice: appleInfo == null ? null : _actualPrice(appleInfo),
    );
  }

  String _planSubtitle(PayProduct product, AppleProductInfo? appleInfo) {
    if (product.content?.isYearProduct() != true) {
      return _nonEmpty(product.content?.description) ?? '';
    }

    final unitPrice = _nonEmpty(appleInfo?.unitPrice);
    return unitPrice == null ? '' : l10n.purchaseMonthlyUnitPrice(unitPrice);
  }

  String _displayPrice(AppleProductInfo appleInfo) {
    final price = appleInfo.price;
    if (price > 0) {
      final priceDescription = appleInfo.priceDescription.trim();
      if (priceDescription.isNotEmpty) {
        return priceDescription;
      }
    }
    return appleInfo.originPriceDescription;
  }

  int _actualPrice(AppleProductInfo appleInfo) {
    final price = appleInfo.price;
    if (price > 0) return price.round();
    return appleInfo.originPrice.round();
  }

  String? _nonEmpty(String? value) {
    final trimmed = value?.trim();
    if (trimmed == null || trimmed.isEmpty) return null;
    return trimmed;
  }

  void openContactUs() {
    Get.toNamed(Routes.SUBMIT_FEEDBACK);
  }

  void openUserTerms() {
    Get.toNamed(
      AppRoutes.webview,
      parameters: {'url': AppConst.userTerms},
    );
  }

  void openPrivacyPolicy() {
    Get.toNamed(
      AppRoutes.webview,
      parameters: {'url': AppConst.privacyPolicy},
    );
  }
}