diff --git a/.vscode/launch.json b/.vscode/launch.json index 9ae82f1..b114bcf 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,12 +2,24 @@ "version": "0.2.0", "configurations": [ { + "name": "myket (debug)", + "request": "launch", + "type": "dart", + "args": ["--flavor", "myket", "--dart-define=BASE_URL=https://hakem.approagency.ir"] + }, + { + "name": "bazaar (debug)", + "request": "launch", + "type": "dart", + "args": ["--flavor", "bazaar", "--dart-define=BASE_URL=https://hakem.approagency.ir"] + }, + { "name": "حکم‌شو (موبایل واقعی - LAN)", "request": "launch", "type": "dart", "program": "lib/main.dart", "args": [ - "--dart-define=BASE_URL=http://192.168.1.105:8080", + "--dart-define=BASE_URL=https://hakem.approagency.ir", "--target-platform=android-arm64" ] }, diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 85dcd21..0e5acfc 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.FileInputStream +import java.util.Properties + plugins { id("com.android.application") id("kotlin-android") @@ -23,7 +26,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "ir.hakemsho.hakemsho" + applicationId = "com.approagency.hakemirani" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. // flutter_secure_storage حداقل SDK 23 را لازم دارد. @@ -31,14 +34,87 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + // مقادیرِ پیش‌فرض تا buildِ بدونِ flavor (مثلِ `flutter run`) هم merge شود؛ + // flavorها این‌ها را override می‌کنند. + manifestPlaceholders += mapOf( + "marketApplicationId" to "com.farsitel.bazaar", + "marketBindAddress" to "com.farsitel.bazaar.InAppBillingService.BIND", + "marketPermission" to "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR" + ) } + // یک flavor برای هر فروشگاه؛ هر کدام placeholderهای manifestِ افزونه‌ی + // پرداخت (myket_iap / flutter_poolakey) را پر می‌کند. STORE به‌صورتِ + // BuildConfig هم تزریق می‌شود تا سمتِ Dart بدونِ dart-define بداند کدام + // درگاه فعال است. + flavorDimensions += "store" + productFlavors { + create("myket") { + dimension = "store" + manifestPlaceholders += mapOf( + "marketApplicationId" to "ir.mservices.market", + "marketBindAddress" to "ir.mservices.market.InAppBillingService.BIND", + "marketPermission" to "ir.mservices.market.BILLING" + ) + resValue("string", "iap_store", "myket") + } + create("bazaar") { + dimension = "store" + manifestPlaceholders += mapOf( + "marketApplicationId" to "com.farsitel.bazaar", + "marketBindAddress" to "com.farsitel.bazaar.InAppBillingService.BIND", + "marketPermission" to "com.farsitel.bazaar.permission.PAY_THROUGH_BAZAAR" + ) + resValue("string", "iap_store", "bazaar") + } + } + + val keystoreProperties = Properties() + val keystorePropertiesFile = rootProject.file("key.properties") + if (keystorePropertiesFile.exists()) { + keystoreProperties.load(FileInputStream(keystorePropertiesFile)) + } + + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String? + keyPassword = keystoreProperties["keyPassword"] as String? + storeFile = keystoreProperties["storeFile"]?.let { + file(it) + } + storePassword = keystoreProperties["storePassword"] as String? + } + } buildTypes { release { // TODO: Add your own signing config for the release build. // Signing with the debug keys for now, so `flutter run --release` works. signingConfig = signingConfigs.getByName("debug") + getByName("release") { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true + isShrinkResources = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) } + } + } +} + +// هر دو افزونه‌ی پرداخت کلاسِ AIDLِ یکسانِ +// `com.android.vending.billing.IInAppBillingService` را در AARِ خود دارند؛ چون +// هر دو پکیجِ Dart همیشه در classpath هستند، هر flavor هر دو AAR را می‌کشد و +// «Duplicate class» می‌دهد. راه‌حل: از هر flavor فقط کتابخانه‌ی فروشگاهِ خودش را +// نگه می‌داریم و کتابخانه‌ی فروشگاهِ دیگر را حذف می‌کنیم (در آن flavor صدا زده +// نمی‌شود). +configurations.configureEach { + if (name.startsWith("myket")) { + exclude(group = "com.github.cafebazaar.Poolakey") + } + if (name.startsWith("bazaar")) { + exclude(group = "com.github.myketstore") } } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..3f92697 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,13 @@ +# هر flavor فقط کتابخانه‌ی پرداختِ فروشگاهِ خودش را دارد (کتابخانه‌ی فروشگاهِ +# دیگر در build.gradle.kts حذف شده تا «Duplicate class» رخ ندهد). در نتیجه، +# کلاسِ افزونه‌ی فروشگاهِ غایب به کلاس‌هایی ارجاع می‌دهد که در آن flavor نیستند؛ +# چون در آن flavor صدا زده نمی‌شوند، به R8 می‌گوییم درباره‌شان هشدار ندهد. +-dontwarn ir.cafebazaar.poolakey.** +-dontwarn ir.myket.billingclient.** + +# افزونه‌های پرداخت و کلاس‌های billing را دست‌نخورده نگه دار. +-keep class ir.cafebazaar.flutter_poolakey.** { *; } +-keep class ir.mservices.myketiap.** { *; } +-keep class ir.cafebazaar.poolakey.** { *; } +-keep class ir.myket.billingclient.** { *; } +-keep class com.android.vending.billing.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 39928d2..36be36f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,7 @@ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..4f9734b 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..bcb3775 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..d755e1a 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..56cb60c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..4d73837 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index b821408..1e9d4b7 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/app.dart b/lib/app.dart index 17e777c..2ec6de6 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -121,7 +121,7 @@ class HakemApp extends StatelessWidget { BlocProvider(create: (_) => locator()), ], child: MaterialApp.router( - title: 'سلطان حکم', + title: 'حاکم ایرانی', debugShowCheckedModeBanner: false, theme: AppTheme.build(), routerConfig: router, diff --git a/lib/core/config.dart b/lib/core/config.dart index 901693b..651ad94 100644 --- a/lib/core/config.dart +++ b/lib/core/config.dart @@ -1,5 +1,24 @@ +import 'package:flutter/services.dart' show appFlavor; + /// تنظیمات سراسری اپ. class AppConfig { + /// فروشگاهِ فعال از روی `--flavor` که اپ با آن ساخته شده + /// (`myket` | `bazaar`). اگر بدونِ flavor اجرا شود، `dev`. + static String get store => appFlavor ?? 'dev'; + + /// کلیدِ عمومیِ RSA مایکت (برای تأییدِ محلیِ افزونه‌ی myket_iap). + static const String myketRsaKey = String.fromEnvironment( + 'MYKET_RSA', + defaultValue: + 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDVCtWUXVZfxULqP3QKlTqsXYkqg5qJYf6KnRSsyk7STB223Ji95IonwHrSrheffrPXi4othieBwl7rDaP0KnvkZ0Le0kr7xHCgTQgYeRuPq15HMm9mrFaXD3HgTF27f1/DcAN8z7eE02ShwAjd0LjYC2u2+T6z8iuQ2oh9LbJPnQIDAQAB', + ); + + /// کلیدِ عمومیِ RSA کافه‌بازار (از پنلِ توسعه‌دهنده‌ی بازار). + static const String bazaarRsaKey = String.fromEnvironment( + 'BAZAAR_RSA', + defaultValue: '', + ); + /// آدرس پایه‌ی سرور. /// - موبایل واقعی روی همان Wi-Fi: IP محلی مک (پیش‌فرض فعلی). /// - امولاتور اندروید: `--dart-define=BASE_URL=http://10.0.2.2:8080` @@ -8,7 +27,7 @@ class AppConfig { /// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید. static const String baseUrl = String.fromEnvironment( 'BASE_URL', - defaultValue: 'http://192.168.1.105:8080', + defaultValue: 'https://hakem.approagency.ir', ); static String get apiUrl => '$baseUrl/api'; diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart index 5eda19e..bd80926 100644 --- a/lib/core/locator/locator.dart +++ b/lib/core/locator/locator.dart @@ -46,7 +46,12 @@ import '../../feature/wallet/domain/repository/wallet_repository.dart'; import '../../feature/wallet/domain/use_cases/claim_daily_usecase.dart'; import '../../feature/wallet/domain/use_cases/get_wallet_usecase.dart'; import '../../feature/wallet/presentation/bloc/wallet_bloc.dart'; +import '../config.dart'; import '../network/api_provider_imp.dart'; +import '../payment/bazaar_payment_gateway.dart'; +import '../payment/dev_payment_gateway.dart'; +import '../payment/myket_payment_gateway.dart'; +import '../payment/payment_gateway.dart'; import '../storage/token_storage.dart'; final GetIt locator = GetIt.instance; @@ -94,6 +99,9 @@ Future setupLocator() async { locator.registerSingleton(SelectCardUseCase(locator())); locator.registerSingleton(PurchaseUseCase(locator())); locator.registerSingleton(AdRewardUseCase(locator())); + + // درگاهِ پرداخت بر اساسِ flavorِ فعال (مایکت/کافه‌بازار/توسعه). + locator.registerSingleton(_gatewayForStore(AppConfig.store)); locator.registerSingleton(GetProfileUseCase(locator())); locator.registerSingleton(SaveProfileUseCase(locator())); locator.registerSingleton( @@ -106,8 +114,8 @@ Future setupLocator() async { locator.registerFactory( () => AuthBloc(locator(), locator(), locator(), locator())); locator.registerFactory(() => WalletBloc(locator(), locator())); - locator.registerFactory(() => - ShopBloc(locator(), locator(), locator(), locator(), locator())); + locator.registerFactory(() => ShopBloc( + locator(), locator(), locator(), locator(), locator(), locator())); locator.registerFactory( () => ProfileBloc(locator(), locator())); locator.registerFactory(() => LeaderboardBloc(locator())); @@ -115,3 +123,16 @@ Future setupLocator() async { locator.registerFactory(() => TierBloc(locator())); locator.registerFactory(() => PrivateInfoBloc(locator())); } + +/// درگاهِ پرداختِ متناظر با flavor. buildهای بدونِ flavor از درگاهِ توسعه +/// استفاده می‌کنند (بک‌اند در DevFallback می‌پذیرد). +PaymentGateway _gatewayForStore(String store) { + switch (store) { + case 'myket': + return MyketPaymentGateway(); + case 'bazaar': + return BazaarPaymentGateway(); + default: + return DevPaymentGateway(); + } +} diff --git a/lib/core/payment/bazaar_payment_gateway.dart b/lib/core/payment/bazaar_payment_gateway.dart new file mode 100644 index 0000000..54f2441 --- /dev/null +++ b/lib/core/payment/bazaar_payment_gateway.dart @@ -0,0 +1,46 @@ +import 'package:flutter_poolakey/flutter_poolakey.dart'; + +import '../config.dart'; +import 'payment_gateway.dart'; + +/// پرداختِ درون‌برنامه‌ایِ کافه‌بازار (Poolakey) — flavorِ `bazaar`. +class BazaarPaymentGateway implements PaymentGateway { + bool _connected = false; + + @override + String get store => 'bazaar'; + + @override + Future connect() async { + if (_connected) return; + await FlutterPoolakey.connect( + AppConfig.bazaarRsaKey, + onDisconnected: () => _connected = false, + ); + _connected = true; + } + + @override + Future purchase(String productSku) async { + try { + await connect(); + final info = await FlutterPoolakey.purchase( + productSku, + payload: 'hakemsho', + ); + if (info.purchaseToken.isEmpty) { + return const PurchaseResult.failure(); + } + return PurchaseResult.success( + token: info.purchaseToken, + purchaseData: info.originalJson, + signature: info.dataSignature, + // کالای مصرفی باید مصرف شود تا خریدِ مجدد ممکن باشد. + onConfirmed: () => FlutterPoolakey.consume(info.purchaseToken), + ); + } catch (_) { + // کنسل‌شدن یا خطای بازار ⇒ شکستِ خرید. + return const PurchaseResult.failure(); + } + } +} diff --git a/lib/core/payment/dev_payment_gateway.dart b/lib/core/payment/dev_payment_gateway.dart new file mode 100644 index 0000000..c4f4e3d --- /dev/null +++ b/lib/core/payment/dev_payment_gateway.dart @@ -0,0 +1,22 @@ +import 'payment_gateway.dart'; + +/// درگاهِ توسعه: بدونِ اتصال به فروشگاه، یک توکنِ ساختگی می‌سازد. برای اجرای +/// معمولیِ `flutter run` (بدونِ flavor) استفاده می‌شود؛ بک‌اند در حالتِ +/// DevFallback آن را می‌پذیرد. +class DevPaymentGateway implements PaymentGateway { + @override + String get store => 'dev'; + + @override + Future connect() async {} + + @override + Future purchase(String productSku) async { + final stamp = DateTime.now().millisecondsSinceEpoch; + return PurchaseResult.success( + token: 'dev-$productSku-$stamp', + purchaseData: '{"productId":"$productSku","purchaseToken":"dev-$stamp"}', + signature: 'dev', + ); + } +} diff --git a/lib/core/payment/myket_payment_gateway.dart b/lib/core/payment/myket_payment_gateway.dart new file mode 100644 index 0000000..fb1436a --- /dev/null +++ b/lib/core/payment/myket_payment_gateway.dart @@ -0,0 +1,40 @@ +import 'package:myket_iap/myket_iap.dart'; +import 'package:myket_iap/util/purchase.dart'; + +import '../config.dart'; +import 'payment_gateway.dart'; + +/// پرداختِ درون‌برنامه‌ایِ مایکت (flavorِ `myket`). +class MyketPaymentGateway implements PaymentGateway { + bool _inited = false; + + @override + String get store => 'myket'; + + @override + Future connect() async { + if (_inited) return; + await MyketIAP.init(rsaKey: AppConfig.myketRsaKey, enableDebugLogging: false); + _inited = true; + } + + @override + Future purchase(String productSku) async { + await connect(); + final Map result = + await MyketIAP.launchPurchaseFlow(sku: productSku, payload: 'hakemsho'); + + final Purchase? purchase = result[MyketIAP.PURCHASE] as Purchase?; + if (purchase == null || purchase.mToken.isEmpty) { + return const PurchaseResult.failure(); + } + + return PurchaseResult.success( + token: purchase.mToken, + purchaseData: purchase.mOriginalJson, + signature: purchase.mSignature, + // کالای مصرفی (سکه/بلیت) باید مصرف شود تا کاربر بتواند دوباره بخرد. + onConfirmed: () => MyketIAP.consume(purchase: purchase), + ); + } +} diff --git a/lib/core/payment/payment_gateway.dart b/lib/core/payment/payment_gateway.dart new file mode 100644 index 0000000..98e3d40 --- /dev/null +++ b/lib/core/payment/payment_gateway.dart @@ -0,0 +1,40 @@ +/// نتیجه‌ی یک خریدِ فروشگاهی، مستقل از اینکه کدام SDK آن را تولید کرده. +/// +/// بک‌اند برای تأییدِ آفلاینِ RSA به [purchaseData] (JSONِ خام) و [signature] +/// نیاز دارد؛ [token] هم برای تأییدِ سمت‌سرور/سازگاری فرستاده می‌شود. +class PurchaseResult { + final bool success; + final String token; + final String purchaseData; + final String signature; + + /// پاک‌سازیِ لازم پس از تأییدِ بک‌اند (مثلاً مایکت باید خرید را consume کند). + final Future Function()? onConfirmed; + + const PurchaseResult.success({ + required this.token, + required this.purchaseData, + required this.signature, + this.onConfirmed, + }) : success = true; + + const PurchaseResult.failure() + : success = false, + token = '', + purchaseData = '', + signature = '', + onConfirmed = null; +} + +/// انتزاعِ SDKِ پرداختِ درون‌برنامه‌ایِ مخصوصِ هر فروشگاه. هر flavor یک پیاده‌سازی +/// ثبت می‌کند؛ بقیه‌ی اپ از SDK بی‌خبر می‌ماند. +abstract class PaymentGateway { + /// شناسه‌ی فروشگاه که به بک‌اند فرستاده می‌شود (`myket` | `bazaar`). + String get store; + + /// جریانِ خریدِ [productSku] (همان UUIDِ محصول در پنلِ فروشگاه) را باز می‌کند. + Future purchase(String productSku); + + /// اتصالِ اولیه (در صورتِ نیاز). پیش از اولین خرید صدا زده می‌شود. + Future connect() async {} +} diff --git a/lib/core/usecase/use_case.dart b/lib/core/usecase/use_case.dart index bb3f27c..91b11b2 100644 --- a/lib/core/usecase/use_case.dart +++ b/lib/core/usecase/use_case.dart @@ -28,10 +28,14 @@ class PurchaseParams { final String kind; final String productId; final String token; + final String purchaseData; + final String signature; const PurchaseParams({ required this.store, required this.kind, required this.productId, required this.token, + this.purchaseData = '', + this.signature = '', }); } diff --git a/lib/feature/auth/presentation/screen/mobile_screen.dart b/lib/feature/auth/presentation/screen/mobile_screen.dart index 15286d5..c0208dd 100644 --- a/lib/feature/auth/presentation/screen/mobile_screen.dart +++ b/lib/feature/auth/presentation/screen/mobile_screen.dart @@ -52,7 +52,7 @@ class _MobileScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const GlowText('سلطان حکم', size: 40), + const GlowText('حاکم ایرانی', size: 40), const SizedBox(height: 28), GamePanel( padding: const EdgeInsets.all(22), diff --git a/lib/feature/shop/data/data_source/remote/shop_api_provider.dart b/lib/feature/shop/data/data_source/remote/shop_api_provider.dart index 6950748..b3aeeb2 100644 --- a/lib/feature/shop/data/data_source/remote/shop_api_provider.dart +++ b/lib/feature/shop/data/data_source/remote/shop_api_provider.dart @@ -19,12 +19,16 @@ class ShopApiProvider { required String kind, required String productId, required String token, + String purchaseData = '', + String signature = '', }) => _api.post('/shop/purchase', body: { 'store': store, 'kind': kind, 'product_id': productId, 'token': token, + 'purchase_data': purchaseData, + 'signature': signature, }); Future adReward(String token) => diff --git a/lib/feature/shop/data/model/shop_models.dart b/lib/feature/shop/data/model/shop_models.dart index 6502bff..5ae9228 100644 --- a/lib/feature/shop/data/model/shop_models.dart +++ b/lib/feature/shop/data/model/shop_models.dart @@ -9,6 +9,7 @@ class ShopMapper { vipDays: (j['vip_days'] ?? 0) as int, priceToman: (j['price_toman'] ?? 0) as int, bonusPct: (j['bonus_pct'] ?? 0) as int, + sku: (j['sku'] ?? '') as String, ); static TicketPackage ticket(Map j) => TicketPackage( @@ -16,6 +17,7 @@ class ShopMapper { title: j['title'] as String, tickets: (j['tickets'] ?? 0) as int, priceToman: (j['price_toman'] ?? 0) as int, + sku: (j['sku'] ?? '') as String, ); static CardSkin card(Map j) => CardSkin( @@ -30,6 +32,7 @@ class ShopMapper { multiplier: (j['multiplier'] ?? 1) as int, hours: (j['hours'] ?? 0) as int, priceToman: (j['price_toman'] ?? 0) as int, + sku: (j['sku'] ?? '') as String, ); static VipPackage vip(Map j) => VipPackage( @@ -37,6 +40,7 @@ class ShopMapper { title: j['title'] as String, months: (j['months'] ?? 1) as int, priceToman: (j['price_toman'] ?? 0) as int, + sku: (j['sku'] ?? '') as String, ); static ShopData shopData(Map j) { diff --git a/lib/feature/shop/data/repository/shop_repository_impl.dart b/lib/feature/shop/data/repository/shop_repository_impl.dart index 1d1db24..a1d4665 100644 --- a/lib/feature/shop/data/repository/shop_repository_impl.dart +++ b/lib/feature/shop/data/repository/shop_repository_impl.dart @@ -43,6 +43,8 @@ class ShopRepositoryImpl extends ShopRepository { kind: params.kind, productId: params.productId, token: params.token, + purchaseData: params.purchaseData, + signature: params.signature, ); if (res.statusCode == 200) return const DataSuccess('خرید با موفقیت انجام شد'); return DataError(errorConvertor(res.statusCode, _msg(res))); diff --git a/lib/feature/shop/domain/entities/shop_entities.dart b/lib/feature/shop/domain/entities/shop_entities.dart index cce472c..8016a72 100644 --- a/lib/feature/shop/domain/entities/shop_entities.dart +++ b/lib/feature/shop/domain/entities/shop_entities.dart @@ -6,6 +6,7 @@ class CoinPackage { final int vipDays; final int priceToman; final int bonusPct; + final String sku; // شناسه‌ی محصول در پنلِ فروشگاه (برای پرداختِ native) const CoinPackage({ required this.id, required this.title, @@ -13,6 +14,7 @@ class CoinPackage { required this.vipDays, required this.priceToman, required this.bonusPct, + this.sku = '', }); } @@ -21,11 +23,13 @@ class TicketPackage { final String title; final int tickets; final int priceToman; + final String sku; const TicketPackage({ required this.id, required this.title, required this.tickets, required this.priceToman, + this.sku = '', }); } @@ -43,12 +47,14 @@ class Booster { final int multiplier; final int hours; final int priceToman; + final String sku; const Booster({ required this.id, required this.title, required this.multiplier, required this.hours, required this.priceToman, + this.sku = '', }); } @@ -57,11 +63,13 @@ class VipPackage { final String title; final int months; final int priceToman; + final String sku; const VipPackage({ required this.id, required this.title, required this.months, required this.priceToman, + this.sku = '', }); } diff --git a/lib/feature/shop/presentation/bloc/shop_bloc.dart b/lib/feature/shop/presentation/bloc/shop_bloc.dart index b82655e..9c6a38f 100644 --- a/lib/feature/shop/presentation/bloc/shop_bloc.dart +++ b/lib/feature/shop/presentation/bloc/shop_bloc.dart @@ -1,5 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../../core/payment/payment_gateway.dart'; import '../../../../core/resources/data_state.dart'; import '../../../../core/usecase/use_case.dart'; import '../../domain/use_cases/ad_reward_usecase.dart'; @@ -17,6 +18,7 @@ class ShopBloc extends Bloc { final SelectCardUseCase selectCardUseCase; final PurchaseUseCase purchaseUseCase; final AdRewardUseCase adRewardUseCase; + final PaymentGateway gateway; ShopBloc( this.getShopUseCase, @@ -24,6 +26,7 @@ class ShopBloc extends Bloc { this.selectCardUseCase, this.purchaseUseCase, this.adRewardUseCase, + this.gateway, ) : super(ShopBlocState.initial()) { on((event, emit) => _load(emit)); @@ -33,15 +36,7 @@ class ShopBloc extends Bloc { on((event, emit) => _action(emit, () => selectCardUseCase(event.cardId))); - on((event, emit) => _action( - emit, - () => purchaseUseCase(PurchaseParams( - store: 'bazaar', - kind: event.kind, - productId: event.productId, - token: - 'dev-${event.kind}-${event.productId}-${DateTime.now().millisecondsSinceEpoch}', - )))); + on((event, emit) => _action(emit, () => _purchase(event))); on((event, emit) => _action( emit, @@ -56,6 +51,42 @@ class ShopBloc extends Bloc { )); } + /// جریانِ خرید: ابتدا پرداختِ فروشگاهی (native) اجرا می‌شود، سپس مدرکِ خرید + /// (token/purchaseData/signature) برای اعتبارسنجی به بک‌اند فرستاده می‌شود و + /// در پایان، خرید در فروشگاه consume می‌شود. + Future> _purchase(PurchaseEvent event) async { + // برای پرداختِ native به SKUِ فروشگاه نیاز است؛ اگر تنظیم نشده باشد از + // شناسه‌ی کاتالوگ استفاده می‌کنیم (حالتِ توسعه/تک‌فروشگاهی). + final sku = event.sku.isNotEmpty ? event.sku : event.productId; + + final PurchaseResult result; + try { + result = await gateway.purchase(sku); + } catch (e) { + return DataError('خطا در پرداخت: $e'); + } + if (!result.success) { + return DataError('خرید انجام نشد یا لغو شد'); + } + + final verify = await purchaseUseCase(PurchaseParams( + store: gateway.store, + kind: event.kind, + productId: event.productId, + token: result.token, + purchaseData: result.purchaseData, + signature: result.signature, + )); + + // پس از تأییدِ بک‌اند، خرید را در فروشگاه نهایی/مصرف می‌کنیم. + if (verify is DataSuccess && result.onConfirmed != null) { + try { + await result.onConfirmed!(); + } catch (_) {/* consume ناموفق حیاتی نیست */} + } + return verify; + } + Future _load(Emitter emit) async { emit(state.copyWith(loadStatus: ShopLoading())); final res = await getShopUseCase(const NoParams()); diff --git a/lib/feature/shop/presentation/bloc/shop_event.dart b/lib/feature/shop/presentation/bloc/shop_event.dart index 3f60964..6a4d838 100644 --- a/lib/feature/shop/presentation/bloc/shop_event.dart +++ b/lib/feature/shop/presentation/bloc/shop_event.dart @@ -14,8 +14,9 @@ class SelectCardEvent extends ShopEvent { class PurchaseEvent extends ShopEvent { final String kind; - final String productId; - PurchaseEvent(this.kind, this.productId); + final String productId; // شناسه‌ی کاتالوگ (برای اعتباردهی در بک‌اند) + final String sku; // شناسه‌ی محصول در پنلِ فروشگاه (برای پرداختِ native) + PurchaseEvent(this.kind, this.productId, {this.sku = ''}); } class AdRewardEvent extends ShopEvent {} diff --git a/lib/feature/shop/presentation/screen/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart index db85748..1205331 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -211,7 +211,7 @@ class _CoinsTab extends StatelessWidget { label: '${p.priceToman} تومان', onTap: () => - context.read().add(PurchaseEvent('coin', p.id)), + context.read().add(PurchaseEvent('coin', p.id, sku: p.sku)), ), ), ], @@ -237,7 +237,7 @@ class _TicketsTab extends StatelessWidget { label: '${p.priceToman} تومان', onTap: () => context.read().add( - PurchaseEvent('ticket', p.id), + PurchaseEvent('ticket', p.id, sku: p.sku), ), ), ), @@ -264,7 +264,7 @@ class _BoostersTab extends StatelessWidget { label: '${b.priceToman} تومان', onTap: () => context.read().add( - PurchaseEvent('booster', b.id), + PurchaseEvent('booster', b.id, sku: b.sku), ), ), ), @@ -292,7 +292,7 @@ class _VipTab extends StatelessWidget { label: '${p.priceToman} تومان', onTap: () => - context.read().add(PurchaseEvent('vip', p.id)), + context.read().add(PurchaseEvent('vip', p.id, sku: p.sku)), ), ), ], diff --git a/lib/feature/shop/presentation/screen/vip_screen.dart b/lib/feature/shop/presentation/screen/vip_screen.dart index d31f301..d7f4f19 100644 --- a/lib/feature/shop/presentation/screen/vip_screen.dart +++ b/lib/feature/shop/presentation/screen/vip_screen.dart @@ -110,7 +110,7 @@ class VipScreen extends StatelessWidget { busy: state.busy, onBuy: () => context .read() - .add(PurchaseEvent('vip', p.id)), + .add(PurchaseEvent('vip', p.id, sku: p.sku)), ), ), if (packages.isEmpty) diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.dart b/lib/feature/wallet/presentation/screen/lobby_screen.dart index f6964af..6c13504 100644 --- a/lib/feature/wallet/presentation/screen/lobby_screen.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.dart @@ -41,10 +41,12 @@ class _LobbyScreenState extends State { final d = state.dailyStatus; if (d is DailySuccess) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}'))); + SnackBar(content: Text('سکه روزانه دریافت شد: +${d.amount}')), + ); } else if (d is DailyError) { - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text(d.message))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(d.message))); } }, builder: (context, state) { @@ -60,13 +62,16 @@ class _LobbyScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const GlowText('سلطان حکم', size: 44), + const GlowText('حاکم ایرانی', size: 44), const SizedBox(height: 44), GameButton( label: 'بازی', icon: Icons.style, width: double.infinity, - colors: const [Color(0xFFC2185B), Color(0xFF6A0D38)], + colors: const [ + Color(0xFFC2185B), + Color(0xFF6A0D38), + ], onTap: () async { await context.push('/game/tiers'); if (context.mounted) _reload(); @@ -77,7 +82,10 @@ class _LobbyScreenState extends State { label: 'دورهمی', icon: Icons.group_add, width: double.infinity, - colors: const [Color(0xFF1565C0), Color(0xFF0A2E57)], + colors: const [ + Color(0xFF1565C0), + Color(0xFF0A2E57), + ], onTap: () async { await context.push('/private'); if (context.mounted) _reload(); @@ -88,7 +96,10 @@ class _LobbyScreenState extends State { label: 'رتبه‌بندی', icon: Icons.leaderboard, width: double.infinity, - colors: const [Color(0xFFE9952F), Color(0xFF9C5A00)], + colors: const [ + Color(0xFFE9952F), + Color(0xFF9C5A00), + ], onTap: () => context.push('/leaderboard'), ), const SizedBox(height: 16), @@ -96,7 +107,10 @@ class _LobbyScreenState extends State { label: 'فروشگاه', icon: Icons.storefront, width: double.infinity, - colors: const [Color(0xFF7B1FA2), Color(0xFF3E0A57)], + colors: const [ + Color(0xFF7B1FA2), + Color(0xFF3E0A57), + ], onTap: () => _openShop(context), ), const SizedBox(height: 16), @@ -104,9 +118,14 @@ class _LobbyScreenState extends State { label: 'سکه روزانه', icon: Icons.monetization_on, width: double.infinity, - colors: const [AppColors.success, AppColors.successDark], - onTap: () => - context.read().add(ClaimDailyEvent()), + colors: const [ + AppColors.success, + AppColors.successDark, + ], + onTap: + () => context.read().add( + ClaimDailyEvent(), + ), ), ], ), @@ -119,8 +138,10 @@ class _LobbyScreenState extends State { context.go('/login'); }, icon: const Icon(Icons.logout, color: Colors.white54), - label: const Text('خروج', - style: TextStyle(color: Colors.white54)), + label: const Text( + 'خروج', + style: TextStyle(color: Colors.white54), + ), ), const SizedBox(height: 8), ], @@ -176,9 +197,10 @@ class _TopBar extends StatelessWidget { color: AppColors.panel, border: Border.all(color: AppColors.gold, width: 2), ), - child: (w == null || w.avatar.isEmpty) - ? const Icon(Icons.person, color: AppColors.gold) - : ClipOval(child: RandomAvatar(w.avatar)), + child: + (w == null || w.avatar.isEmpty) + ? const Icon(Icons.person, color: AppColors.gold) + : ClipOval(child: RandomAvatar(w.avatar)), ), ), const SizedBox(width: 10), @@ -186,23 +208,35 @@ class _TopBar extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Row(children: [ - if (w != null && w.name.isNotEmpty) ...[ - Text(w.name, + Row( + children: [ + if (w != null && w.name.isNotEmpty) ...[ + Text( + w.name, style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold)), - const SizedBox(width: 6), + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + ], + if (w?.vip == true) const _VipTag(), ], - if (w?.vip == true) const _VipTag(), - ]), + ), const SizedBox(height: 2), - Row(children: [ - const Icon(Icons.star, color: AppColors.gold, size: 16), - const SizedBox(width: 4), - Text('سطح ${w?.level ?? '-'}', + Row( + children: [ + const Icon(Icons.star, color: AppColors.gold, size: 16), + const SizedBox(width: 4), + Text( + 'سطح ${w?.level ?? '-'}', style: const TextStyle( - color: AppColors.gold, fontWeight: FontWeight.bold)), - ]), + color: AppColors.gold, + fontWeight: FontWeight.bold, + ), + ), + ], + ), const SizedBox(height: 4), ClipRRect( borderRadius: BorderRadius.circular(4), @@ -210,9 +244,10 @@ class _TopBar extends StatelessWidget { width: 96, height: 7, child: LinearProgressIndicator( - value: (w == null || w.xpForNext == 0) - ? 0 - : w.xpIntoLevel / w.xpForNext, + value: + (w == null || w.xpForNext == 0) + ? 0 + : w.xpIntoLevel / w.xpForNext, backgroundColor: Colors.white10, valueColor: const AlwaysStoppedAnimation(AppColors.gold), ), @@ -224,13 +259,17 @@ class _TopBar extends StatelessWidget { GestureDetector( onTap: onCoinTap, child: StatChip( - icon: Icons.confirmation_number, value: '${w?.tickets ?? 0}'), + icon: Icons.confirmation_number, + value: '${w?.tickets ?? 0}', + ), ), const SizedBox(width: 8), GestureDetector( onTap: onCoinTap, child: StatChip( - icon: Icons.monetization_on, value: '${w?.coins ?? 0}'), + icon: Icons.monetization_on, + value: '${w?.coins ?? 0}', + ), ), ], ), @@ -246,14 +285,18 @@ class _VipTag extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( gradient: const LinearGradient( - colors: [Color(0xFFFFD54F), AppColors.goldDark]), + colors: [Color(0xFFFFD54F), AppColors.goldDark], + ), borderRadius: BorderRadius.circular(6), ), - child: const Text('VIP', - style: TextStyle( - color: AppColors.bg, - fontWeight: FontWeight.bold, - fontSize: 10)), + child: const Text( + 'VIP', + style: TextStyle( + color: AppColors.bg, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index b1bc2ed..c9518d6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -222,6 +222,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "5.0.0" + flutter_poolakey: + dependency: "direct main" + description: + name: flutter_poolakey + sha256: "141415a62f0b88465dc1ec852c641a373de34afb193613c2583835457801f185" + url: "https://pub.myket.ir" + source: hosted + version: "2.2.0+1.0.0" flutter_secure_storage: dependency: "direct main" description: @@ -392,6 +400,14 @@ packages: url: "https://pub.myket.ir" source: hosted version: "2.0.0" + myket_iap: + dependency: "direct main" + description: + name: myket_iap + sha256: "26e12cf257771e2bc0eef6af1ca45e926401d43124899c0118542b2bb6d31331" + url: "https://pub.myket.ir" + source: hosted + version: "1.1.12" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3de5c1f..b4bdbaf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,9 @@ dependencies: flame: ^1.30.1 flame_audio: ^2.11.14 random_avatar: ^0.0.8 + # خرید درون‌برنامه‌ای: هر flavor یکی را استفاده می‌کند (مایکت/کافه‌بازار). + myket_iap: ^1.1.11 + flutter_poolakey: ^2.2.0 # path_provider_android نسخه‌ی 2.3.x به jni (کد native + NDK) وابسته شد و build را # سنگین/کند می‌کند. تا نسخه‌ی پیش‌از‑jni پین می‌کنیم تا NDK لازم نباشد.