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

    in triagePresently being triaged by the triage teamwaiting for customer responseThe Flutter team cannot make further progress on this issue until the original reporter responds

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions