feat: 实现 iOS Apple Pay 内购支付功能
前端: - 集成 in_app_purchase 插件,实现 IAP 支付流程 - 添加支付模块 (payments/) 处理产品获取、购买、验证 - 积分中心页面集成 Apple Pay 购买入口 - 设置页面重构: 关于/隐私/协议直接展示,删除 legal_center 子页面 - 修复欢迎引导页滚动检测阈值问题 - 修复解卦结果页 iOS 侧滑返回手势被阻止的问题 - 邀请码绑定按钮临时禁用(待后端实现) 后端: - 新增 apple_iap_transactions 表记录交易 - 实现 Apple 服务器端验证 (App Store Server API) - 支付成功后自动发放积分 - 支持 Sandbox/Production 环境切换 - 添加退款处理和交易状态机 协议: - 更新积分流水协议,支持 purchase/refund 类型 - 新增 PAYMENT_* 错误码
This commit is contained in:
@@ -169,7 +169,7 @@ class _DivinationResultScreenState extends State<DivinationResultScreen> {
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return PopScope<void>(
|
||||
canPop: false,
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
return;
|
||||
|
||||
@@ -27,6 +27,7 @@ class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({
|
||||
super.key,
|
||||
required this.account,
|
||||
required this.userId,
|
||||
required this.sessionStore,
|
||||
required this.currentLocale,
|
||||
required this.profileSettings,
|
||||
@@ -43,9 +44,11 @@ class HomeScreen extends StatefulWidget {
|
||||
required this.onDeleteHistorySession,
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userId;
|
||||
final SessionStore sessionStore;
|
||||
final Locale currentLocale;
|
||||
final ProfileSettingsV1 profileSettings;
|
||||
@@ -56,15 +59,16 @@ class HomeScreen extends StatefulWidget {
|
||||
final NotificationRepository notificationRepository;
|
||||
final Future<void> Function(String languageTag) onLocaleChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings)
|
||||
onProfileSettingsChanged;
|
||||
onProfileSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
onSaveProfile;
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
final Future<void> Function(DivinationResultData result)
|
||||
onDivinationCompleted;
|
||||
onDivinationCompleted;
|
||||
final Future<void> Function(String threadId) onDeleteHistorySession;
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
@@ -132,6 +136,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
_ProfileTab(
|
||||
account: widget.account,
|
||||
userId: widget.userId,
|
||||
settings: widget.profileSettings,
|
||||
coinBalance: widget.coinBalance,
|
||||
inviteRepository: _inviteRepository,
|
||||
@@ -142,6 +147,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
onUploadAvatar: widget.onUploadAvatar,
|
||||
onLogout: widget.onLogout,
|
||||
onDeleteAccount: widget.onDeleteAccount,
|
||||
onBalanceChanged: widget.onBalanceChanged,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -561,6 +567,7 @@ class _DivinationHistoryScreenState extends State<DivinationHistoryScreen> {
|
||||
class _ProfileTab extends StatelessWidget {
|
||||
const _ProfileTab({
|
||||
required this.account,
|
||||
required this.userId,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
@@ -571,9 +578,11 @@ class _ProfileTab extends StatelessWidget {
|
||||
required this.onUploadAvatar,
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userId;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
@@ -581,15 +590,17 @@ class _ProfileTab extends StatelessWidget {
|
||||
final Future<void> Function(String languageTag) onLocaleChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
onSaveProfile;
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsScreen(
|
||||
account: account,
|
||||
userId: userId,
|
||||
settings: settings,
|
||||
coinBalance: coinBalance,
|
||||
inviteRepository: inviteRepository,
|
||||
@@ -600,6 +611,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
onUploadAvatar: onUploadAvatar,
|
||||
onLogout: onLogout,
|
||||
onDeleteAccount: onDeleteAccount,
|
||||
onBalanceChanged: onBalanceChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -703,14 +715,23 @@ class _WelcomeDialog extends StatefulWidget {
|
||||
class _WelcomeDialogState extends State<_WelcomeDialog> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _hasScrolledToBottom = false;
|
||||
bool _hasCheckedInitialScroll = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_handleScroll);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_syncScrollState();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (!_hasCheckedInitialScroll) {
|
||||
_hasCheckedInitialScroll = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_syncScrollState();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -730,7 +751,7 @@ class _WelcomeDialogState extends State<_WelcomeDialog> {
|
||||
}
|
||||
final max = _scrollController.position.maxScrollExtent;
|
||||
final current = _scrollController.offset;
|
||||
final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md;
|
||||
final canReadAll = max <= 50.0 || current >= max - AppSpacing.md;
|
||||
if (_hasScrolledToBottom == canReadAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../models/apple_purchase_models.dart';
|
||||
|
||||
class ApplePaymentApi {
|
||||
const ApplePaymentApi({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
final ApiClient _apiClient;
|
||||
|
||||
Future<VerifyTransactionResponse> verifyTransaction(
|
||||
VerifyTransactionRequest request,
|
||||
) async {
|
||||
final json = await _apiClient.postJson(
|
||||
'/api/v1/payments/apple/transactions/verify',
|
||||
data: request.toJson(),
|
||||
);
|
||||
return VerifyTransactionResponse.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
class VerifyTransactionRequest {
|
||||
const VerifyTransactionRequest({
|
||||
required this.productCode,
|
||||
required this.appStoreProductId,
|
||||
required this.transactionId,
|
||||
required this.signedTransactionInfo,
|
||||
this.appAccountToken,
|
||||
});
|
||||
|
||||
final String productCode;
|
||||
final String appStoreProductId;
|
||||
final String transactionId;
|
||||
final String signedTransactionInfo;
|
||||
final String? appAccountToken;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'productCode': productCode,
|
||||
'appStoreProductId': appStoreProductId,
|
||||
'transactionId': transactionId,
|
||||
'signedTransactionInfo': signedTransactionInfo,
|
||||
if (appAccountToken != null) 'appAccountToken': appAccountToken,
|
||||
};
|
||||
}
|
||||
|
||||
enum VerifyTransactionStatus { granted, alreadyGranted }
|
||||
|
||||
class VerifyTransactionResponse {
|
||||
const VerifyTransactionResponse({
|
||||
required this.status,
|
||||
required this.productCode,
|
||||
required this.transactionId,
|
||||
required this.creditsAdded,
|
||||
required this.newBalance,
|
||||
required this.ledgerEventId,
|
||||
});
|
||||
|
||||
final VerifyTransactionStatus status;
|
||||
final String productCode;
|
||||
final String transactionId;
|
||||
final int creditsAdded;
|
||||
final int newBalance;
|
||||
final String ledgerEventId;
|
||||
|
||||
factory VerifyTransactionResponse.fromJson(Map<String, dynamic> json) {
|
||||
return VerifyTransactionResponse(
|
||||
status: json['status'] == 'already_granted'
|
||||
? VerifyTransactionStatus.alreadyGranted
|
||||
: VerifyTransactionStatus.granted,
|
||||
productCode: json['productCode'] as String,
|
||||
transactionId: json['transactionId'] as String,
|
||||
creditsAdded: json['creditsAdded'] as int,
|
||||
newBalance: json['newBalance'] as int,
|
||||
ledgerEventId: json['ledgerEventId'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../apis/apple_payment_api.dart';
|
||||
import '../models/apple_purchase_models.dart';
|
||||
|
||||
enum PurchaseFlowState { idle, purchasing, verifying, success, failed }
|
||||
|
||||
class PurchaseResult {
|
||||
const PurchaseResult({
|
||||
required this.state,
|
||||
this.creditsAdded,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final PurchaseFlowState state;
|
||||
final int? creditsAdded;
|
||||
final String? errorMessage;
|
||||
}
|
||||
|
||||
class AppleIapService with ChangeNotifier {
|
||||
AppleIapService({
|
||||
required ApiClient apiClient,
|
||||
required String userId,
|
||||
}) : _paymentApi = ApplePaymentApi(apiClient: apiClient),
|
||||
_inAppPurchase = InAppPurchase.instance,
|
||||
_appAccountToken = _hashUserId(userId);
|
||||
|
||||
final ApplePaymentApi _paymentApi;
|
||||
final InAppPurchase _inAppPurchase;
|
||||
final String? _appAccountToken;
|
||||
final Logger _logger = getLogger('features.payments.apple_iap_service');
|
||||
|
||||
static String? _hashUserId(String userId) {
|
||||
if (userId.isEmpty) return null;
|
||||
final bytes = utf8.encode(userId);
|
||||
final digest = md5.convert(bytes);
|
||||
return digest.toString();
|
||||
}
|
||||
|
||||
StreamSubscription<List<PurchaseDetails>>? _subscription;
|
||||
Map<String, ProductDetails> _storeKitProducts = {};
|
||||
PurchaseFlowState _state = PurchaseFlowState.idle;
|
||||
String? _lastError;
|
||||
ApiProblem? _lastApiProblem;
|
||||
|
||||
PurchaseFlowState get state => _state;
|
||||
String? get lastError => _lastError;
|
||||
ApiProblem? get lastApiProblem => _lastApiProblem;
|
||||
|
||||
void init() {
|
||||
final Stream<List<PurchaseDetails>> purchaseUpdated;
|
||||
purchaseUpdated = _inAppPurchase.purchaseStream;
|
||||
_subscription = purchaseUpdated.listen(_onPurchaseUpdated);
|
||||
}
|
||||
|
||||
Future<void> loadStoreKitProducts(List<PackageInfo> packages) async {
|
||||
final ids = packages
|
||||
.map((p) => p.appStoreProductId)
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet();
|
||||
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final response = await _inAppPurchase.queryProductDetails(ids);
|
||||
if (response.notFoundIDs.isNotEmpty) {
|
||||
_logger.warning(
|
||||
message: 'Some StoreKit products not found',
|
||||
extra: {'notFound': response.notFoundIDs.join(', ')},
|
||||
);
|
||||
}
|
||||
|
||||
final products = <String, ProductDetails>{};
|
||||
for (final detail in response.productDetails) {
|
||||
products[detail.id] = detail;
|
||||
}
|
||||
_storeKitProducts = products;
|
||||
}
|
||||
|
||||
ProductDetails? getStoreKitProduct(String appStoreProductId) {
|
||||
return _storeKitProducts[appStoreProductId];
|
||||
}
|
||||
|
||||
Future<bool> purchase(PackageInfo package) async {
|
||||
if (_state == PurchaseFlowState.purchasing ||
|
||||
_state == PurchaseFlowState.verifying) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final product = _storeKitProducts[package.appStoreProductId];
|
||||
if (product == null) {
|
||||
_logger.warning(
|
||||
message: 'StoreKit product not found for purchase',
|
||||
extra: {'productId': package.appStoreProductId},
|
||||
);
|
||||
_setError('Product not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
_setState(PurchaseFlowState.purchasing);
|
||||
|
||||
final purchaseParam = PurchaseParam(
|
||||
productDetails: product,
|
||||
applicationUserName: _appAccountToken,
|
||||
);
|
||||
final bought = await _inAppPurchase.buyConsumable(
|
||||
purchaseParam: purchaseParam,
|
||||
);
|
||||
|
||||
if (!bought) {
|
||||
_setError('Failed to initiate purchase');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void _onPurchaseUpdated(List<PurchaseDetails> purchases) {
|
||||
for (final purchase in purchases) {
|
||||
_handlePurchase(purchase);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PurchaseDetails purchase) async {
|
||||
switch (purchase.status) {
|
||||
case PurchaseStatus.purchased:
|
||||
await _verifyAndComplete(purchase);
|
||||
case PurchaseStatus.canceled:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
_setState(PurchaseFlowState.idle);
|
||||
case PurchaseStatus.error:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
_setError(purchase.error?.message ?? 'Purchase failed');
|
||||
case PurchaseStatus.pending:
|
||||
_setState(PurchaseFlowState.purchasing);
|
||||
case PurchaseStatus.restored:
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyAndComplete(PurchaseDetails purchase) async {
|
||||
_setState(PurchaseFlowState.verifying);
|
||||
|
||||
try {
|
||||
final request = VerifyTransactionRequest(
|
||||
productCode: _productCodeFromStoreKitId(purchase.productID),
|
||||
appStoreProductId: purchase.productID,
|
||||
transactionId: purchase.purchaseID ?? '',
|
||||
signedTransactionInfo: purchase.verificationData.serverVerificationData,
|
||||
appAccountToken: _appAccountToken,
|
||||
);
|
||||
|
||||
final response = await _paymentApi.verifyTransaction(request);
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
|
||||
_state = PurchaseFlowState.success;
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
_logger.info(
|
||||
message: 'Purchase verified and completed',
|
||||
extra: {
|
||||
'transactionId': purchase.purchaseID,
|
||||
'creditsAdded': response.creditsAdded,
|
||||
'status': response.status.name,
|
||||
},
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Purchase verification failed',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
extra: {'transactionId': purchase.purchaseID},
|
||||
);
|
||||
|
||||
if (_isRetryableError(e)) {
|
||||
_setState(PurchaseFlowState.idle);
|
||||
return;
|
||||
}
|
||||
|
||||
await _inAppPurchase.completePurchase(purchase);
|
||||
if (e is ApiProblem) {
|
||||
_lastApiProblem = e;
|
||||
_lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed';
|
||||
} else {
|
||||
_lastApiProblem = null;
|
||||
_lastError = _extractErrorMessage(e);
|
||||
}
|
||||
_state = PurchaseFlowState.failed;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
bool _isRetryableError(Object error) {
|
||||
if (error is ApiProblem) {
|
||||
return error.status >= 500 || error.status == 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String _extractErrorMessage(Object error) {
|
||||
if (error is ApiProblem) {
|
||||
return error.detail.isNotEmpty ? error.detail : 'Verification failed';
|
||||
}
|
||||
return 'Verification failed';
|
||||
}
|
||||
|
||||
String _productCodeFromStoreKitId(String storeKitId) {
|
||||
return switch (storeKitId) {
|
||||
'com.meeyao.qianwen.new_user_pack' => 'new_user_pack',
|
||||
'com.meeyao.qianwen.basic_pack' => 'basic_pack',
|
||||
'com.meeyao.qianwen.popular_pack' => 'popular_pack',
|
||||
'com.meeyao.qianwen.premium_pack' => 'premium_pack',
|
||||
_ => storeKitId,
|
||||
};
|
||||
}
|
||||
|
||||
void _setState(PurchaseFlowState state) {
|
||||
_state = state;
|
||||
if (state != PurchaseFlowState.failed) {
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setError(String message) {
|
||||
_state = PurchaseFlowState.failed;
|
||||
_lastError = message;
|
||||
_lastApiProblem = null;
|
||||
_logger.warning(message: 'Purchase flow error', extra: {'error': message});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetState() {
|
||||
_state = PurchaseFlowState.idle;
|
||||
_lastError = null;
|
||||
_lastApiProblem = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ enum PackageType { starter, regular }
|
||||
class PackageInfo {
|
||||
const PackageInfo({
|
||||
required this.productCode,
|
||||
required this.appStoreProductId,
|
||||
required this.type,
|
||||
required this.price,
|
||||
required this.credits,
|
||||
@@ -14,6 +15,7 @@ class PackageInfo {
|
||||
});
|
||||
|
||||
final ProductCode productCode;
|
||||
final String appStoreProductId;
|
||||
final PackageType type;
|
||||
final double price;
|
||||
final int credits;
|
||||
@@ -24,6 +26,7 @@ class PackageInfo {
|
||||
factory PackageInfo.fromJson(Map<String, dynamic> json) {
|
||||
return PackageInfo(
|
||||
productCode: _parseProductCode(json['productCode'] as String),
|
||||
appStoreProductId: json['appStoreProductId'] as String,
|
||||
type: json['type'] == 'starter'
|
||||
? PackageType.starter
|
||||
: PackageType.regular,
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem_mapper.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../../data/storage/local_kv_store.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../../payments/data/services/apple_iap_service.dart';
|
||||
import '../../../points/data/apis/points_api.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class CoinCenterScreen extends StatefulWidget {
|
||||
const CoinCenterScreen({super.key, required this.balance});
|
||||
const CoinCenterScreen({
|
||||
super.key,
|
||||
required this.balance,
|
||||
required this.userId,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final int balance;
|
||||
final String userId;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
|
||||
@@ -25,6 +39,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
final Logger _logger = getLogger('features.settings.coin_center_screen');
|
||||
List<PackageInfo>? _packages;
|
||||
bool _isLoading = true;
|
||||
AppleIapService? _iapService;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,12 +54,26 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
|
||||
final api = PointsApi(apiClient.rawDio);
|
||||
final result = await api.getPackages();
|
||||
|
||||
final service = AppleIapService(
|
||||
apiClient: apiClient,
|
||||
userId: widget.userId,
|
||||
);
|
||||
service.init();
|
||||
service.addListener(_onPurchaseStateChanged);
|
||||
|
||||
if (await InAppPurchase.instance.isAvailable()) {
|
||||
await service.loadStoreKitProducts(result.packages);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = result.packages;
|
||||
_isLoading = false;
|
||||
_iapService = service;
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
@@ -61,6 +90,60 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _onPurchaseStateChanged() {
|
||||
final service = _iapService;
|
||||
if (service == null || !mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
switch (service.state) {
|
||||
case PurchaseFlowState.success:
|
||||
Toast.show(context, l10n.paymentSuccess, type: ToastType.success);
|
||||
_refreshBalance();
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.failed:
|
||||
final apiProblem = service.lastApiProblem;
|
||||
final error = apiProblem != null
|
||||
? mapApiProblemToMessage(apiProblem, l10n)
|
||||
: (service.lastError ?? l10n.paymentVerifyFailed);
|
||||
Toast.show(context, error, type: ToastType.error);
|
||||
service.resetState();
|
||||
break;
|
||||
case PurchaseFlowState.purchasing:
|
||||
case PurchaseFlowState.verifying:
|
||||
case PurchaseFlowState.idle:
|
||||
break;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _refreshBalance() async {
|
||||
try {
|
||||
final sessionStore = SessionStore(LocalKvStore());
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
final response = await apiClient.getJson('/api/v1/points/balance');
|
||||
final newBalance = response['availableBalance'] as int;
|
||||
widget.onBalanceChanged(newBalance);
|
||||
} catch (e, stackTrace) {
|
||||
_logger.warning(
|
||||
message: 'Failed to refresh balance after purchase: $e',
|
||||
extra: {'stackTrace': stackTrace.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_iapService?.removeListener(_onPurchaseStateChanged);
|
||||
_iapService?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -120,13 +203,13 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SectionLabel(text: l10n.settingsCoinRechargeSection),
|
||||
..._buildPackageCards(l10n),
|
||||
..._buildPackageCards(l10n, colors),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n) {
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n, ColorScheme colors) {
|
||||
if (_isLoading) {
|
||||
return [
|
||||
const Padding(
|
||||
@@ -140,22 +223,66 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
return [];
|
||||
}
|
||||
|
||||
final isBusy = _iapService?.state == PurchaseFlowState.purchasing ||
|
||||
_iapService?.state == PurchaseFlowState.verifying;
|
||||
final isPending = _iapService?.state == PurchaseFlowState.purchasing;
|
||||
|
||||
return List.generate(_packages!.length, (index) {
|
||||
final pkg = _packages![index];
|
||||
final storeKitProduct = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
|
||||
final isAvailable = storeKitProduct != null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (index > 0) const SizedBox(height: AppSpacing.md),
|
||||
if (isPending && !isBusy)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Text(
|
||||
l10n.paymentPending,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
CoinPackageCard(
|
||||
title: _getPackageTitle(pkg, l10n),
|
||||
price: pkg.priceDisplay,
|
||||
price: _getDisplayPrice(pkg),
|
||||
amount: pkg.credits,
|
||||
badge: _getPackageBadge(pkg, l10n),
|
||||
onPurchase: () => _handlePurchase(pkg),
|
||||
isPurchasing: isBusy,
|
||||
isAvailable: isAvailable,
|
||||
unavailableMessage: isAvailable ? null : l10n.paymentProductUnavailable,
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String _getDisplayPrice(PackageInfo pkg) {
|
||||
final product = _iapService?.getStoreKitProduct(pkg.appStoreProductId);
|
||||
if (product != null) {
|
||||
return product.price;
|
||||
}
|
||||
return pkg.priceDisplay;
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PackageInfo pkg) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
if (_iapService == null) {
|
||||
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Platform.isIOS) {
|
||||
Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error);
|
||||
return;
|
||||
}
|
||||
|
||||
await _iapService!.purchase(pkg);
|
||||
}
|
||||
|
||||
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
|
||||
if (pkg.productCode == ProductCode.popularPack) {
|
||||
return l10n.settingsCoinPackPopularBadge;
|
||||
|
||||
@@ -34,117 +34,108 @@ class _GeneralSettingsScreenState extends State<GeneralSettingsScreen> {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
return PopScope<ProfileSettingsV1>(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (didPop) {
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(_settings);
|
||||
},
|
||||
child: Scaffold(
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
title: Text(l10n.settingsGeneralTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(_settings),
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
SectionLabel(text: l10n.settingsSectionGeneral),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.language_rounded,
|
||||
title: l10n.settingsInterfaceLanguage,
|
||||
subtitle: displayLanguageLabel(
|
||||
l10n,
|
||||
_settings.preferences.interfaceLanguage,
|
||||
),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _selectLanguage(
|
||||
_settings.preferences.interfaceLanguage,
|
||||
(lang) => setState(() {
|
||||
_settings = _settings.copyWith(
|
||||
preferences: _settings.preferences.copyWith(
|
||||
interfaceLanguage: lang,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.smart_toy_rounded,
|
||||
title: l10n.settingsAiLanguage,
|
||||
subtitle: displayLanguageLabel(
|
||||
l10n,
|
||||
_settings.preferences.aiLanguage,
|
||||
),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => _selectLanguage(
|
||||
_settings.preferences.aiLanguage,
|
||||
(lang) => setState(() {
|
||||
_settings = _settings.copyWith(
|
||||
preferences: _settings.preferences.copyWith(
|
||||
aiLanguage: lang,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Text(l10n.settingsGeneralTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
SectionLabel(text: l10n.settingsSectionGeneral),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.language_rounded,
|
||||
title: l10n.settingsInterfaceLanguage,
|
||||
subtitle: displayLanguageLabel(
|
||||
l10n,
|
||||
_settings.preferences.interfaceLanguage,
|
||||
),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _selectLanguage(
|
||||
_settings.preferences.interfaceLanguage,
|
||||
(lang) => setState(() {
|
||||
_settings = _settings.copyWith(
|
||||
preferences: _settings.preferences.copyWith(
|
||||
interfaceLanguage: lang,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.smart_toy_rounded,
|
||||
title: l10n.settingsAiLanguage,
|
||||
subtitle: displayLanguageLabel(
|
||||
l10n,
|
||||
_settings.preferences.aiLanguage,
|
||||
),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => _selectLanguage(
|
||||
_settings.preferences.aiLanguage,
|
||||
(lang) => setState(() {
|
||||
_settings = _settings.copyWith(
|
||||
preferences: _settings.preferences.copyWith(
|
||||
aiLanguage: lang,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SectionLabel(text: l10n.settingsSectionPrivacy),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsSwitchTile(
|
||||
icon: Icons.security_rounded,
|
||||
title: l10n.settingsDoNotSellTitle,
|
||||
value: _settings.privacy.canSell,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onChanged: (value) => _updatePrivacy(canSell: value),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SectionLabel(text: l10n.settingsSectionNotification),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsSwitchTile(
|
||||
icon: Icons.notifications_rounded,
|
||||
title: l10n.settingsNotificationAllow,
|
||||
value: _settings.notification.allowNotifications,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onChanged: (value) =>
|
||||
_updateNotification(allowNotifications: value),
|
||||
),
|
||||
SettingsSwitchTile(
|
||||
icon: Icons.vibration_rounded,
|
||||
title: l10n.settingsNotificationVibration,
|
||||
value: _settings.notification.allowVibration,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onChanged: (value) =>
|
||||
_updateNotification(allowVibration: value),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SectionLabel(text: l10n.settingsSectionPrivacy),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsSwitchTile(
|
||||
icon: Icons.security_rounded,
|
||||
title: l10n.settingsDoNotSellTitle,
|
||||
value: _settings.privacy.canSell,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onChanged: (value) => _updatePrivacy(canSell: value),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SectionLabel(text: l10n.settingsSectionNotification),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsSwitchTile(
|
||||
icon: Icons.notifications_rounded,
|
||||
title: l10n.settingsNotificationAllow,
|
||||
value: _settings.notification.allowNotifications,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onChanged: (value) =>
|
||||
_updateNotification(allowNotifications: value),
|
||||
),
|
||||
SettingsSwitchTile(
|
||||
icon: Icons.vibration_rounded,
|
||||
title: l10n.settingsNotificationVibration,
|
||||
value: _settings.notification.allowVibration,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onChanged: (value) =>
|
||||
_updateNotification(allowVibration: value),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ class _BindCodeSection extends StatelessWidget {
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: isBinding ? null : onBind,
|
||||
onPressed: null,
|
||||
style: FilledButton.styleFrom(
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -415,16 +415,7 @@ class _BindCodeSection extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: isBinding
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(l10n.settingsInviteBindButton),
|
||||
child: Text(l10n.settingsComingSoon),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../models/legal_document_type.dart';
|
||||
import '../utils/legal_document_assets.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'legal_document_screen.dart';
|
||||
|
||||
class LegalCenterScreen extends StatelessWidget {
|
||||
const LegalCenterScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final locale = Localizations.localeOf(context);
|
||||
final documents = [
|
||||
LegalDocumentType.aboutUs,
|
||||
LegalDocumentType.privacyPolicy,
|
||||
LegalDocumentType.termsOfService,
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsLegalCenterTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
SectionLabel(text: l10n.settingsSectionAbout),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
for (int i = 0; i < documents.length; i++)
|
||||
SettingsMenuTile(
|
||||
icon: legalDocumentIcon(documents[i]),
|
||||
title: legalDocumentTitle(l10n, documents[i]),
|
||||
subtitle: legalDocumentSubtitle(l10n, documents[i]),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: i != documents.length - 1,
|
||||
onTap: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LegalDocumentScreen(
|
||||
title: legalDocumentTitle(l10n, documents[i]),
|
||||
assetPath: legalDocumentAssetPath(locale, documents[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,22 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../data/models/profile_settings.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
import '../models/legal_document_type.dart';
|
||||
import '../utils/legal_document_assets.dart';
|
||||
import 'account_delete_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'coin_center_screen.dart';
|
||||
import 'feedback_screen.dart';
|
||||
import 'general_settings_screen.dart';
|
||||
import 'invite_screen.dart';
|
||||
import 'legal_center_screen.dart';
|
||||
import 'legal_document_screen.dart';
|
||||
import 'profile_edit_screen.dart';
|
||||
|
||||
class SettingsScreen extends StatefulWidget {
|
||||
const SettingsScreen({
|
||||
super.key,
|
||||
required this.account,
|
||||
required this.userId,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
@@ -30,9 +33,11 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.onLogout,
|
||||
required this.onDeleteAccount,
|
||||
required this.onSaveProfile,
|
||||
required this.onBalanceChanged,
|
||||
});
|
||||
|
||||
final String account;
|
||||
final String userId;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
@@ -43,7 +48,8 @@ class SettingsScreen extends StatefulWidget {
|
||||
final Future<void> Function() onLogout;
|
||||
final Future<void> Function() onDeleteAccount;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
onSaveProfile;
|
||||
onSaveProfile;
|
||||
final void Function(int newBalance) onBalanceChanged;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -119,35 +125,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: _openInvite,
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: l10n.settingsLegalCenterTitle,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: _openLegalCenter,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.feedback_outlined,
|
||||
title: l10n.settingsFeedbackTitle,
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => FeedbackScreen(apiClient: widget.apiClient),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.person_rounded,
|
||||
title: l10n.settingsAccountAndDataTitle,
|
||||
@@ -159,6 +147,33 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SettingsGroupCard(
|
||||
children: [
|
||||
SettingsMenuTile(
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: l10n.aboutUs,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.aboutUs),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.security_rounded,
|
||||
title: l10n.privacyPolicy,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.privacyPolicy),
|
||||
),
|
||||
SettingsMenuTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: l10n.termsOfService,
|
||||
tint: colors.secondary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => _openLegalDocument(LegalDocumentType.termsOfService),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
FilledButton(
|
||||
onPressed: _confirmLogout,
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -180,7 +195,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Future<void> _openCoinCenter() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => CoinCenterScreen(balance: widget.coinBalance),
|
||||
builder: (_) => CoinCenterScreen(
|
||||
balance: widget.coinBalance,
|
||||
userId: widget.userId,
|
||||
onBalanceChanged: widget.onBalanceChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -229,9 +248,16 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _openLegalCenter() async {
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(builder: (_) => const LegalCenterScreen()),
|
||||
void _openLegalDocument(LegalDocumentType type) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final locale = Localizations.localeOf(context);
|
||||
Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LegalDocumentScreen(
|
||||
title: legalDocumentTitle(l10n, type),
|
||||
assetPath: legalDocumentAssetPath(locale, type),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,6 @@ import 'package:flutter/material.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
|
||||
class SectionLabel extends StatelessWidget {
|
||||
const SectionLabel({super.key, required this.text});
|
||||
@@ -420,12 +418,20 @@ class CoinPackageCard extends StatelessWidget {
|
||||
required this.price,
|
||||
required this.amount,
|
||||
this.badge,
|
||||
this.onPurchase,
|
||||
this.isPurchasing = false,
|
||||
this.isAvailable = true,
|
||||
this.unavailableMessage,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String price;
|
||||
final int amount;
|
||||
final String? badge;
|
||||
final VoidCallback? onPurchase;
|
||||
final bool isPurchasing;
|
||||
final bool isAvailable;
|
||||
final String? unavailableMessage;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -483,32 +489,43 @@ class CoinPackageCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
price,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(color: colors.primary),
|
||||
if (!isAvailable && unavailableMessage != null)
|
||||
Text(
|
||||
unavailableMessage!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colors.error,
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Toast.show(
|
||||
)
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
price,
|
||||
style: Theme.of(
|
||||
context,
|
||||
l10n.settingsPurchasePending,
|
||||
type: ToastType.info,
|
||||
);
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
).textTheme.headlineMedium?.copyWith(color: colors.primary),
|
||||
),
|
||||
child: Text(l10n.settingsPurchaseButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
),
|
||||
child: isPurchasing
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colors.onPrimary,
|
||||
),
|
||||
)
|
||||
: Text(l10n.settingsPurchaseButton),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user