-
Notifications
You must be signed in to change notification settings - Fork 29.7k
Open
flutter/packages
#10521Labels
P1High-priority issues at the top of the work listHigh-priority issues at the top of the work listp: in_app_purchasePlugin for in-app purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.flutter/packages repository. See also p: labels.platform-iosiOS applications specificallyiOS applications specificallyteam-iosOwned by iOS platform teamOwned by iOS platform teamtriaged-iosTriaged by iOS platform teamTriaged by iOS platform team
Description
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(
"✅ ĐÃ GỌI 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("🔴 LỖI KHI HOÀN TẤT GIAO DỊCH (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(
"LỖI CỤC BỘ IOS: 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
Labels
P1High-priority issues at the top of the work listHigh-priority issues at the top of the work listp: in_app_purchasePlugin for in-app purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.flutter/packages repository. See also p: labels.platform-iosiOS applications specificallyiOS applications specificallyteam-iosOwned by iOS platform teamOwned by iOS platform teamtriaged-iosTriaged by iOS platform teamTriaged by iOS platform team
Type
Projects
Status
Easy, High Priority