Skip to content

The latest version of In_app_purchase has an error that only allows one-time payment, and cannot be paid a second time (Item Consumable) #179111

@trinhnguyen93

Description

@trinhnguyen93

Steps to reproduce

The latest version of In_app_purchase has an error that only allows one-time payment, and cannot be paid a second time (Item Consumable)

Expected results

I wish someone could tell me how to fix the error so the user can pay a second time.

Actual results

The latest version of In_app_purchase has an error that only allows one-time payment, and cannot be paid a second time (Item Consumable)

Code sample

// Vị trí: lib/screens/pet_token_screen.dart
"import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:mochinest_final/models/user_profile_model.dart';
import 'package:mochinest_final/services/firestore_service.dart';
import 'package:mochinest_final/services/purchase_service.dart';
import 'package:provider/provider.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

// THÊM IMPORT ĐA NGÔN NGỮ
import 'package:mochinest_final/l10n/gen_l10n/app_localizations.dart';

class PetTokenScreen extends StatefulWidget {
  const PetTokenScreen({super.key});

  @override
  State<PetTokenScreen> createState() => _PetTokenScreenState();
}

class _PetTokenScreenState extends State<PetTokenScreen> {
  // Khởi tạo Service
  final PurchaseService _purchaseService = PurchaseService();
  final FirestoreService _firestoreService = FirestoreService();

  List<ProductDetails> _products = [];
  bool _isLoadingProducts = true;

  // TRẠNG THÁI MỚI: Quản lý loading khi đang mua hàng (chống double click)
  bool _isBuying = false;

  @override
  void initState() {
    super.initState();
    // Gán callback để cập nhật UI khi giao dịch hoàn tất (thành công/thất bại)
    _purchaseService.onPurchaseStatusChanged = _handlePurchaseStatusUpdate;
    _purchaseService.initialize();
    _loadProducts();
  }

  @override
  void dispose() {
    _purchaseService.dispose();
    super.dispose();
  }

  // HÀM CALLBACK: Xử lý cập nhật UI sau khi PurchaseService hoàn tất giao dịch
  void _handlePurchaseStatusUpdate(bool success, String message) {
    if (mounted) {
      // Đảm bảo UI được cập nhật sau khi giao dịch kết thúc
      setState(() {
        _isBuying = false;
      });

      // Hiển thị thông báo (Yêu cầu 2 - phần hiển thị dấu tích)
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(
          content: Row(
            children: [
              Icon(
                success ? Icons.check_circle_outline : Icons.error_outline,
                color: Colors.white,
              ),
              const SizedBox(width: 10),
              Expanded(child: Text(message)),
            ],
          ),
          backgroundColor: success ? Colors.green : Colors.red,
          duration: const Duration(seconds: 4),
        ),
      );
    }
  }

  Future<void> _loadProducts() async {
    final products = await _purchaseService.getProducts();
    if (mounted) {
      setState(() {
        _products = products;
        _isLoadingProducts = false;
      });
    }
  }

  // HÀM MỚI: Xử lý việc bấm nút mua (Yêu cầu 1: Quản lý trạng thái)
  Future<void> _startPurchase(ProductDetails product) async {
    // Ngăn chặn nếu đang có giao dịch khác diễn ra
    if (_isBuying) return;

    // Yêu cầu 1: Hiển thị biểu tượng loading và ẩn nút mua
    setState(() {
      _isBuying = true;
    });

    // Yêu cầu 2: Logic xử lý pending/complete purchase nằm trong PurchaseService.buyProduct
    await _purchaseService.buyProduct(product);

    // Trạng thái _isBuying sẽ được reset trong _handlePurchaseStatusUpdate khi giao dịch kết thúc.
  }

  @override
  Widget build(BuildContext context) {
    final user = Provider.of<User?>(context);
    final l10n = AppLocalizations.of(context)!; // Lấy l10n

    if (user == null) {
      return Scaffold(
        appBar: AppBar(),
        body: Center(child: Text(l10n.pleaseLogIn)),
      );
    }

    return Scaffold(
      appBar: AppBar(title: Text(l10n.petTokenScreenTitle)),
      body: StreamBuilder<UserProfile?>(
        stream: _firestoreService.getUserProfile(
          user.uid,
        ), // Sử dụng _firestoreService
        builder: (context, snapshot) {
          if (!snapshot.hasData) {
            return const Center(child: CircularProgressIndicator());
          }
          final userProfile = snapshot.data!;

          return SingleChildScrollView(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                Card(
                  child: Padding(
                    padding: const EdgeInsets.all(24.0),
                    child: Column(
                      children: [
                        Text(
                          l10n.yourTokenCountLabel,
                          style: const TextStyle(
                            fontSize: 16,
                            color: Colors.grey,
                          ),
                        ),
                        const SizedBox(height: 8),
                        Text(
                          userProfile.tokens.toString(),
                          style: const TextStyle(
                            fontSize: 48,
                            fontWeight: FontWeight.bold,
                            color: Colors.teal,
                          ),
                        ),
                      ],
                    ),
                  ),
                ),
                const SizedBox(height: 32),
                Text(
                  l10n.shareReferralCodeInstruction,
                  textAlign: TextAlign.center,
                  style: const TextStyle(color: Colors.grey),
                ),
                const SizedBox(height: 16),
                Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 16,
                    vertical: 12,
                  ),
                  decoration: BoxDecoration(
                    color: Colors.grey[200],
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Expanded(
                        child: Text(
                          userProfile.referralCode,
                          style: const TextStyle(
                            fontSize: 20,
                            fontWeight: FontWeight.bold,
                            letterSpacing: 2,
                          ),
                        ),
                      ),
                      IconButton(
                        icon: const Icon(Icons.copy),
                        onPressed: () {
                          Clipboard.setData(
                            ClipboardData(text: userProfile.referralCode),
                          );
                          ScaffoldMessenger.of(context).showSnackBar(
                            SnackBar(content: Text(l10n.referralCodeCopied)),
                          );
                        },
                      ),
                    ],
                  ),
                ),
                const Divider(height: 48),
                Text(
                  l10n.buyMoreTokensTitle,
                  style: Theme.of(context).textTheme.headlineSmall,
                  textAlign: TextAlign.center,
                ),
                const SizedBox(height: 16),
                _isLoadingProducts
                    ? const Center(child: CircularProgressIndicator())
                    : Column(
                        children: _products.map((product) {
                          return _buildPurchaseButton(
                            context,
                            l10n, // Pass l10n
                            productDetails: product, // THÊM productDetails
                            title: product.title,
                            price: product.price,
                            // YÊU CẦU 1: Ẩn nút mua khi đang xử lý (_isBuying = true)
                            onPressed: _isBuying
                                ? null
                                : () => _startPurchase(product),
                          );
                        }).toList(),
                      ),
              ],
            ),
          );
        },
      ),
    );
  }

  // Sửa đổi _buildPurchaseButton để xử lý trạng thái loading/disabled
  Widget _buildPurchaseButton(
    BuildContext context,
    AppLocalizations l10n, {
    required ProductDetails productDetails,
    required String title,
    required String price,
    required VoidCallback? onPressed, // onPressed có thể là null
  }) {
    // Điều kiện hiển thị loading: Đang có giao dịch và nút này đang bị disabled
    final isCurrentProductBeingProcessed = _isBuying && onPressed == null;

    return Card(
      margin: const EdgeInsets.symmetric(vertical: 8),
      child: ListTile(
        leading: const Icon(
          Icons.monetization_on,
          color: Colors.amber,
          size: 40,
        ),
        title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
        subtitle: Text(price),
        trailing: ElevatedButton(
          onPressed: onPressed, // Sẽ là null khi _isBuying = true
          child: isCurrentProductBeingProcessed
              ? const SizedBox(
                  // Hiển thị loading khi đang mua
                  width: 20,
                  height: 20,
                  child: CircularProgressIndicator(
                    strokeWidth: 2,
                    color: Colors.white,
                  ),
                )
              : Text(l10n.buyButton),
        ),
      ),
    );
  }
}
"
"// Vị trí: lib/services/purchase_service.dart

import 'dart:async';
import 'dart:io';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'dart:math';

class PurchaseService {
  final InAppPurchase _inAppPurchase = InAppPurchase.instance;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final Set<String> _productIds = {'100_tokens', '500_tokens', '1000_tokens'};
  // Sử dụng Completer để đánh dấu liệu quá trình xử lý giao dịch pending ban đầu đã hoàn tất chưa.
  Completer<void> _initialQueueProcessed = Completer<void>();

  // Callback này được dùng để thông báo cho UI (PetTokenScreen) về trạng thái giao dịch
  Function(bool success, String message)? onPurchaseStatusChanged;

  // --- HÀM KHỞI TẠO ---
  void initialize() async {
    // 1. Lắng nghe Stream giao dịch (xử lý giao dịch mới, lỗi và restored)
    _subscription = _inAppPurchase.purchaseStream.listen(
      (purchaseDetailsList) {
        _listenToPurchaseUpdated(purchaseDetailsList);

        // 🟢 CẢI TIẾN: Đảm bảo Completer hoàn thành sau lần gọi đầu tiên, dù danh sách trống hay không.
        if (!_initialQueueProcessed.isCompleted) {
          _initialQueueProcessed.complete();
        }
      },
      onDone: () => _subscription.cancel(),
      onError: (error) {
        print("Lỗi Purchase Stream: $error");
        // Đảm bảo Completer được hoàn thành ngay cả khi có lỗi ban đầu
        if (!_initialQueueProcessed.isCompleted) {
          _initialQueueProcessed.completeError(error);
        }
      },
    );

    // 2. Kích hoạt xử lý giao dịch còn sót (pending/restored)
    await _triggerPendingPurchasesProcessing();

    print(
      "PurchaseService đã khởi tạo và kích hoạt kiểm tra giao dịch pending.",
    );
  }

  void dispose() {
    _subscription.cancel();
  }

  // --- HÀM KÍCH HOẠT QUÁ TRÌNH RESTORE ---
  Future<void> _triggerPendingPurchasesProcessing() async {
    await _inAppPurchase.restorePurchases();
    print(
      "Đã gọi restorePurchases() để kích hoạt khôi phục giao dịch pending.",
    );
  }

  Future<List<ProductDetails>> getProducts() async {
    final ProductDetailsResponse response = await _inAppPurchase
        .queryProductDetails(_productIds);
    if (response.error != null) {
      print("Lỗi tải sản phẩm: ${response.error}");
      return [];
    }
    return response.productDetails;
  }

  // Hàm sinh chuỗi ngẫu nhiên
  String _generateRandomString(int length) {
    const chars =
        'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
    Random rnd = Random();
    return String.fromCharCodes(
      Iterable.generate(
        length,
        (_) => chars.codeUnitAt(rnd.nextInt(chars.length)),
      ),
    );
  }

  // --- HÀM MUA SẢN PHẨM (ĐÃ THÊM LOGIC DỌN DẸP TRƯỚC VÀ LOG) ---
  Future<void> buyProduct(ProductDetails productDetails) async {
    final startTime = DateTime.now(); // LOG: Bấm mua

    // 1. DỌN DẸP BẮT BUỘC: Đảm bảo hàng đợi giao dịch cũ đã được xử lý (và hoàn tất)
    if (!_initialQueueProcessed.isCompleted) {
      print("Chờ hoàn tất xử lý hàng đợi giao dịch pending ban đầu...");
      await _initialQueueProcessed.future.catchError(
        (_) => null,
      ); // Bắt lỗi để không crash
    }

    // 2. TẠO REQUEST MUA HÀNG MỚI
    final String uniqueRequestID =
        "${DateTime.now().millisecondsSinceEpoch}_${_generateRandomString(6)}";

    final PurchaseParam purchaseParam = PurchaseParam(
      productDetails: productDetails,
      applicationUserName: uniqueRequestID,
    );

    print(
      'LOG: [${startTime.toIso8601String()}] User gọi buyConsumable cho ${productDetails.id}',
    );
    await _inAppPurchase.buyConsumable(purchaseParam: purchaseParam);
  }

  // --- HÀM TRỢ GIÚP HOÀN TẤT GIAO DỊCH AN TOÀN (VỚI CƠ CHẾ RETRY) ---
  Future<void> _safeCompletePurchase(
    PurchaseDetails purchaseDetails, {
    String reason = "Unknown",
    int maxRetries = 3,
  }) async {
    final completeCallTime = DateTime.now(); // LOG: Gọi complete purchase
    print(
      'LOG: [${completeCallTime.toIso8601String()}] Bắt đầu completePurchase cho ${purchaseDetails.purchaseID}',
    );

    if (!purchaseDetails.pendingCompletePurchase) {
      print(
        "ℹ️ Giao dịch ${purchaseDetails.purchaseID} không cần complete (pendingCompletePurchase = false).",
      );
      return;
    }

    for (int attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        await _inAppPurchase.completePurchase(purchaseDetails);

        print(
          "✅ ĐÃ GI COMPLETE PURCHASE THÀNH CÔNG (Lần $attempt) ($reason) cho ${purchaseDetails.productID}",
        );
        print("     TransactionID đã hoàn tất: ${purchaseDetails.purchaseID}");
        return; // Thành công, thoát khỏi hàm
      } catch (e) {
        print("🔴 LI KHI HOÀN TT GIAO DCH (Lần $attempt): $e");

        if (attempt < maxRetries) {
          print("     Thử lại sau 2 giây...");
          await Future.delayed(const Duration(seconds: 2));
        } else {
          print("     Đã hết số lần thử lại. Giao dịch vẫn có nguy cơ bị kẹt.");
        }
      }
    }
  }

  // --- HÀM LẮNG NGHE STREAM (CÓ LOG VÀ CẬP NHẬT CALLBACK) ---
  void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) {
    for (var purchaseDetails in purchaseDetailsList) {
      final receiveTime = DateTime.now(); // LOG: Nhận purchase
      print(
        'LOG: [${receiveTime.toIso8601String()}] Purchase Stream nhận: ${purchaseDetails.productID} - Status: ${purchaseDetails.status.name}',
      );

      if (purchaseDetails.status == PurchaseStatus.pending) {
        print("Giao dịch đang chờ: ${purchaseDetails.productID}");
      } else {
        if (purchaseDetails.status == PurchaseStatus.error) {
          print("Lỗi mua hàng: ${purchaseDetails.error}");
          // Gọi callback thất bại
          onPurchaseStatusChanged?.call(
            false,
            "Lỗi: ${purchaseDetails.error?.message ?? 'Giao dịch thất bại.'}",
          );
          // Luôn hoàn tất giao dịch lỗi để xóa khỏi hàng đợi.
          _safeCompletePurchase(purchaseDetails, reason: "Error");
        } else if (purchaseDetails.status == PurchaseStatus.purchased ||
            purchaseDetails.status == PurchaseStatus.restored) {
          // Xác minh VÀ HOÀN THÀNH giao dịch
          _verifyAndCompletePurchase(purchaseDetails);
        }
      }

      // 🟢 BỔ SUNG LỚP BẢO VỆ CUỐI CÙNG (THEO VÍ DỤ MỚI NHẤT CỦA IAP)
      // Nếu giao dịch vẫn còn trong trạng thái pendingCompletePurchase sau khi xử lý (kể cả sau khi _verifyAndCompletePurchase chạy/thất bại),
      // hãy cố gắng hoàn tất lại lần nữa để dọn dẹp hàng đợi của StoreKit.
      if (purchaseDetails.pendingCompletePurchase) {
        print(
          "⚠️ Dọn dẹp cuối cùng: Giao dịch ${purchaseDetails.purchaseID} vẫn pendingCompletePurchase.",
        );
        // Chúng ta không dùng await ở đây để tránh làm tắc nghẽn luồng lắng nghe.
        _safeCompletePurchase(purchaseDetails, reason: "Final Clean Up");
      }
    }
  }

  // --- HÀM XÁC MINH VÀ HOÀN TẤT (CÓ LOG VÀ CẬP NHẬT CALLBACK) ---
  Future<void> _verifyAndCompletePurchase(
    PurchaseDetails purchaseDetails,
  ) async {
    final serverCallTime = DateTime.now(); // LOG: Gửi server
    bool skipVerification = false;
    bool verificationSuccess = false; // Mặc định là thất bại
    String message = "Lỗi không xác định khi xác minh.";
    Map<String, dynamic> payload = {}; // Khởi tạo payload

    try {
      final callable = FirebaseFunctions.instance.httpsCallable(
        'verifyPurchase',
      );

      // 1. CHUẨN BỊ PAYLOAD TÙY THEO NỀN TẢNG
      if (Platform.isIOS) {
        final signedTransactionData =
            purchaseDetails.verificationData.serverVerificationData;

        // KIỂM TRA QUAN TRỌNG: JWS rỗng
        if (signedTransactionData == null || signedTransactionData.isEmpty) {
          print(
            "LI CC BIOS: Dữ liệu giao dịch (JWS) bị null hoặc rỗng. Bỏ qua xác minh server.",
          );
          skipVerification = true;
          message =
              "Lỗi cục bộ iOS: Dữ liệu giao dịch rỗng."; // Cập nhật message
        }

        if (!skipVerification) {
          payload = {
            'platform': 'ios',
            'signedTransactionData': signedTransactionData,
            'productId': purchaseDetails.productID,
          };
        }
      } else if (Platform.isAndroid) {
        payload = {
          'platform': 'android',
          'purchaseToken':
              purchaseDetails.verificationData.serverVerificationData,
          'purchaseId': purchaseDetails.purchaseID,
          'productId': purchaseDetails.productID,
        };
      } else {
        print("Lỗi: Nền tảng không được hỗ trợ.");
        message = "Nền tảng không được hỗ trợ.";
        return; // Nền tảng không hỗ trợ thì thoát luôn
      }

      // 2. GỌI CLOUD FUNCTION CHỈ MỘT LẦN (NẾU KHÔNG SKIP)
      if (!skipVerification) {
        print(
          'LOG: [${serverCallTime.toIso8601String()}] Gửi server verifyPurchase cho ${purchaseDetails.productID}',
        );
        final result = await callable.call(payload);

        message = result.data['message'];
        verificationSuccess = true; // Thành công nếu không ném lỗi
        print('LOG: Server trả về thành công: ${message}');
      } else {
        print("Bỏ qua xác minh server (do JWS rỗng).");
        verificationSuccess =
            false; // Bỏ qua coi như không thành công cấp token
      }
    } on FirebaseFunctionsException catch (e) {
      // Lỗi xử lý từ Cloud Function (bao gồm ALREADY_PROCESSED)
      message = "Lỗi Server: ${e.code} - ${e.message}";
      print("Lỗi Cloud Function: ${e.code} - ${e.message}");

      // Nếu là lỗi đã xử lý, vẫn coi là thành công cho giao diện để gọi completePurchase.
      if (e.message?.startsWith('ALREADY_PROCESSED') ?? false) {
        verificationSuccess = true;
        message = "Giao dịch đã được ghi nhận trước đó. Cảm ơn bạn.";
      } else {
        verificationSuccess = false;
      }
    } catch (e) {
      message = "Lỗi xác minh cục bộ: $e";
      print("Lỗi xác minh cục bộ: $e");
      verificationSuccess = false;
    } finally {
      // *** ĐẢM BẢO HOÀN TẤT GIAO DỊCH (KEY SOLUTION) ***
      // Lệnh này chạy _safeCompletePurchase (có Retry) để xóa giao dịch khỏi hàng đợi StoreKit
      // Ghi chú: Nếu _listenToPurchaseUpdated đã gọi Final Clean Up, lệnh này vẫn chạy
      // nhưng sẽ không thực sự chạy completePurchase lần thứ hai nếu nó đã thành công.
      await _safeCompletePurchase(
        purchaseDetails,
        reason: "Verification Complete/Failed",
      );

      final finishTime = DateTime.now(); // LOG: Hoàn tất
      print(
        'LOG: [${finishTime.toIso8601String()}] Hoàn tất luồng giao dịch. Thành công: $verificationSuccess',
      );

      // GỌI CALLBACK CUỐI CÙNG cho UI
      onPurchaseStatusChanged?.call(verificationSuccess, message);
    }
  }
}

Screenshots or Video

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[Paste your output here]

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority issues at the top of the work listp: in_app_purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.platform-iosiOS applications specificallyteam-iosOwned by iOS platform teamtriaged-iosTriaged by iOS platform team

    Type

    No type

    Projects

    Status

    Easy, High Priority

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions