Compare commits

..
35 Commits
Author SHA1 Message Date
Amirmahdi 4b266e732d feat: vr 3 2026-07-13 08:02:06 +03:30
Amirmahdi 5a3dbb0466 feat: featch cards to storage 2026-07-12 09:05:39 +03:30
Amirmahdi 2ada58d7e6 feat: add bazar rsa 2026-07-10 16:59:23 +03:30
Amirmahdi 8cd90f4a5f feat: update device info plus 2026-07-10 12:39:10 +03:30
Amirmahdi f13b196971 fix: ic launcher splash screen 2026-07-10 12:01:36 +03:30
Amirmahdi a7fb3ed8f9 fix:profile 2026-07-10 11:21:44 +03:30
Amirmahdi b008ec6056 fix: appbar 2026-07-10 10:23:02 +03:30
Amirmahdi f5e51ed44d feat: dialig for conectivity 2026-07-10 10:20:17 +03:30
Amirmahdi 7f420e6ae1 feat: selected after buy 2026-07-10 01:26:29 +03:30
Amirmahdi 97d156c492 feat: add characters 2026-07-10 00:56:58 +03:30
Amirmahdi 7c067d36f2 style: shop 2026-07-09 07:24:49 +03:30
Amirmahdi b9d21c505e feat: show hand result ui 2026-07-09 05:54:10 +03:30
Amirmahdi adc2d1fc47 refactor: all screens refactor 2026-07-08 20:12:04 +03:30
Amirmahdi 2496580e86 fix: delay after fire 2026-07-08 19:20:30 +03:30
Amirmahdi 6b9b9a0f3a fix: frames 2026-07-08 13:48:29 +03:30
Amirmahdi df2ae2a215 feat:add frames 2026-07-08 12:15:06 +03:30
Amirmahdi 46a649166b feat: add game graphic 2026-07-08 09:06:32 +03:30
Amirmahdi ffd2646eea feat: phase 1 of improve 2026-07-08 00:17:46 +03:30
Amirmahdi 0bd2b4a18a feat: add steak 2026-07-07 21:07:42 +03:30
Amirmahdi 5424695f25 feat: add tournoment feature 2026-07-07 15:18:55 +03:30
Amirmahdi 5b0a66bb54 feat: terms and condition 2026-07-06 18:27:06 +03:30
Amirmahdi 3e7e069487 style: ui for record table 2026-07-05 20:05:25 +03:30
Amirmahdi df5cc699b6 feat: shop is change 2026-07-05 19:49:59 +03:30
Amirmahdi e94f03c499 fix: some problems are fixed 2026-07-05 18:40:53 +03:30
Amirmahdi 5017e9a613 feat: add payment 2026-07-05 16:03:15 +03:30
Amirmahdi c2356b3242 feat: add coin private table 2026-07-04 12:45:52 +03:30
Amirmahdi 7acd72c286 feat: add carpet 2026-07-03 21:56:34 +03:30
Amirmahdi 5160deb973 feat: change theme 2026-06-26 17:29:21 +03:30
Amirmahdi 153b4caa3d feat: add front chat 2026-06-25 23:34:10 +03:30
Amirmahdi fa45ef0737 feat: add message feature 2026-06-25 01:11:34 +03:30
Amirmahdi e8cde8e1f7 feat: cleaning code 2026-06-24 22:56:32 +03:30
Amirmahdi e73333344f feat: add reshuffle for ticket 2026-06-24 21:00:18 +03:30
Amirmahdi 500c61a409 fix: trump trick 2026-06-24 14:17:21 +03:30
Amirmahdi e0d6763c00 feat: won trick ui 2026-06-24 10:47:17 +03:30
Amirmahdi 08937a66cb style: ui perfection 2026-06-24 09:09:00 +03:30
150 changed files with 9493 additions and 2343 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"flutter": "3.38.5"
}
+3
View File
@@ -43,3 +43,6 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
# FVM Version Cache
.fvm/
+13 -1
View File
@@ -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.104:8080",
"--dart-define=BASE_URL=https://hakem.approagency.ir",
"--target-platform=android-arm64"
]
},
+3
View File
@@ -0,0 +1,3 @@
{
"dart.flutterSdkPath": ".fvm/versions/3.38.5"
}
+77 -1
View File
@@ -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")
}
}
+13
View File
@@ -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.** { *; }
+1 -1
View File
@@ -1,7 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="hakemsho"
android:label="حاکم ایرانی"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -1,12 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<bitmap android:gravity="fill" android:src="@drawable/background"/>
</item>
<item>
<bitmap android:gravity="center" android:src="@drawable/splash"/>
</item>
</layer-list>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground>
<inset
android:drawable="@drawable/ic_launcher_foreground"
android:inset="16%" />
</foreground>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 86 KiB

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#081428</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#081428</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
<item name="android:windowSplashScreenBackground">#081428</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
<item name="android:windowSplashScreenIconBackgroundColor">#081428</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#081428</color>
</resources>
@@ -5,6 +5,10 @@
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:forceDarkAllowed">false</item>
<item name="android:windowFullscreen">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 604 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

@@ -1,23 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 384 KiB

+12 -5
View File
@@ -16,13 +16,19 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleToFill" image="LaunchBackground" translatesAutoresizingMaskIntoConstraints="NO" id="tWc-Dq-wcI"/>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"></imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="3T2-ad-Qdv"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="RPx-PI-7Xg"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="SdS-ul-q2q"/>
<constraint firstAttribute="trailing" secondItem="tWc-Dq-wcI" secondAttribute="trailing" id="Swv-Gf-Rwn"/>
<constraint firstAttribute="trailing" secondItem="YRO-k0-Ey4" secondAttribute="trailing" id="TQA-XW-tRk"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="duK-uY-Gun"/>
<constraint firstItem="tWc-Dq-wcI" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="kV7-tw-vXt"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="xPn-NY-SIU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +38,7 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="1024" height="1024"/>
<image name="LaunchBackground" width="1" height="1"/>
</resources>
</document>
+47 -45
View File
@@ -1,49 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hakemsho</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hakemsho</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Hakemsho</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>hakemsho</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>
+9 -2
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'core/locator/locator.dart';
import 'core/theme/app_theme.dart';
import 'core/widgets/offline_gate.dart';
import 'feature/auth/presentation/bloc/auth_bloc.dart';
import 'feature/auth/presentation/screen/mobile_screen.dart';
import 'feature/auth/presentation/screen/otp_screen.dart';
@@ -16,6 +17,8 @@ import 'feature/game/presentation/screen/game_screen.dart';
import 'feature/game/presentation/screen/private_entry_screen.dart';
import 'feature/game/presentation/screen/private_table_screen.dart';
import 'feature/game/presentation/screen/tier_list_screen.dart';
import 'feature/legal/terms_screen.dart';
import 'feature/tournament/presentation/screen/tournaments_screen.dart';
import 'feature/profile/presentation/bloc/profile_bloc.dart';
import 'feature/profile/presentation/bloc/profile_event.dart';
import 'feature/profile/presentation/screen/profile_screen.dart';
@@ -41,6 +44,10 @@ class HakemApp extends StatelessWidget {
GoRoute(path: '/otp', builder: (_, __) => const OtpScreen()),
GoRoute(path: '/setup', builder: (_, __) => const ProfileSetupScreen()),
GoRoute(path: '/lobby', builder: (_, __) => const LobbyScreen()),
GoRoute(path: '/terms', builder: (_, __) => const TermsScreen()),
GoRoute(
path: '/tournaments',
builder: (_, __) => const TournamentsScreen()),
GoRoute(
path: '/profile',
builder: (_, __) => BlocProvider(
@@ -121,13 +128,13 @@ class HakemApp extends StatelessWidget {
BlocProvider(create: (_) => locator<WalletBloc>()),
],
child: MaterialApp.router(
title: 'سلطان حکم',
title: 'حاکم ایرانی',
debugShowCheckedModeBanner: false,
theme: AppTheme.build(),
routerConfig: router,
builder: (context, child) => Directionality(
textDirection: TextDirection.rtl,
child: child!,
child: OfflineGate(child: child!),
),
),
);
+38 -1
View File
@@ -1,5 +1,42 @@
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:
'MIHNMA0GCSqGSIb3DQEBAQUAA4G7ADCBtwKBrwDhrDiI+8V+gCxmPJJl5ZkW81U/i8EdFmZYqhUtMz2pj2vEmmkIkfKTIKlWJFynku8BKWT+ZkwH/sqBw4AHpnrC8KFc2ud526O8cxqJU54I8rW8vBqalrT2lHBMMhvEE3RnvC769ty09thKNGztsN+vZN+O4F6q/A1EPNwwRSj8QeJJKAhVO0Z480gfrRQCe5PzUGYtUzJpftH9tg7uoZSzxOwScu5BdY+iWn/CXJMCAwEAAQ==',
);
/// App ID تپسل (از پنلِ تپسل). تا زمانی که تنظیم نشود، تبلیغِ ویدیویی-جایزه‌ای
/// («سکه رایگان») نمایش داده نمی‌شود. با `--dart-define=TAPSELL_APP_ID=...`
/// یا مقداردهیِ پیش‌فرض این‌جا فعال می‌شود.
static const String tapsellAppId = String.fromEnvironment(
'TAPSELL_APP_ID',
defaultValue: '',
);
/// شناسه‌ی جایگاهِ (zone) تبلیغِ ویدیویی-جایزه‌ای تپسل.
static const String tapsellRewardedZoneId = String.fromEnvironment(
'TAPSELL_REWARDED_ZONE',
defaultValue: '',
);
/// آیا تبلیغِ جایزه‌ای فعال است؟ فقط وقتی App ID تپسل موجود باشد.
static bool get adsEnabled => tapsellAppId.isNotEmpty;
/// آدرس پایه‌ی سرور.
/// - موبایل واقعی روی همان Wi-Fi: IP محلی مک (پیش‌فرض فعلی).
/// - امولاتور اندروید: `--dart-define=BASE_URL=http://10.0.2.2:8080`
@@ -8,7 +45,7 @@ class AppConfig {
/// با تغییر شبکه/IP مک، مقدار dart-define یا همین پیش‌فرض را عوض کنید.
static const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'http://192.168.1.104:8080',
defaultValue: 'https://hakem.approagency.ir',
);
static String get apiUrl => '$baseUrl/api';
+33 -2
View File
@@ -25,11 +25,16 @@ import '../../feature/profile/domain/use_cases/get_profile_usecase.dart';
import '../../feature/profile/domain/use_cases/save_profile_usecase.dart';
import '../../feature/profile/presentation/bloc/profile_bloc.dart';
import '../../feature/ranked/data/data_source/remote/ranked_api_provider.dart';
import '../../feature/chat/data/data_source/remote/chat_api_provider.dart';
import '../../feature/ranked/data/repository/ranked_repository_impl.dart';
import '../../feature/ranked/domain/repository/ranked_repository.dart';
import '../../feature/ranked/domain/use_cases/get_leaderboard_usecase.dart';
import '../../feature/ranked/presentation/bloc/leaderboard_bloc.dart';
import '../../feature/shop/data/data_source/remote/shop_api_provider.dart';
import '../../feature/shop/data/data_source/remote/avatar_api_provider.dart';
import '../../feature/shop/data/data_source/remote/carpet_api_provider.dart';
import '../../feature/shop/data/data_source/remote/frame_api_provider.dart';
import '../../feature/tournament/data/data_source/remote/tournament_api_provider.dart';
import '../../feature/shop/data/repository/shop_repository_impl.dart';
import '../../feature/shop/domain/repository/shop_repository.dart';
import '../../feature/shop/domain/use_cases/ad_reward_usecase.dart';
@@ -44,7 +49,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;
@@ -60,8 +70,13 @@ Future<void> setupLocator() async {
locator.registerSingleton<AuthApiProvider>(AuthApiProvider());
locator.registerSingleton<WalletApiProvider>(WalletApiProvider());
locator.registerSingleton<ShopApiProvider>(ShopApiProvider());
locator.registerSingleton<CarpetApiProvider>(CarpetApiProvider());
locator.registerSingleton<FrameApiProvider>(FrameApiProvider());
locator.registerSingleton<AvatarApiProvider>(AvatarApiProvider());
locator.registerSingleton<ProfileApiProvider>(ProfileApiProvider());
locator.registerSingleton<RankedApiProvider>(RankedApiProvider());
locator.registerSingleton<ChatApiProvider>(ChatApiProvider());
locator.registerSingleton<TournamentApiProvider>(TournamentApiProvider());
locator.registerSingleton<GameApiProvider>(GameApiProvider());
locator.registerSingleton<GameWsProvider>(GameWsProvider(locator()));
@@ -90,6 +105,9 @@ Future<void> setupLocator() async {
locator.registerSingleton<SelectCardUseCase>(SelectCardUseCase(locator()));
locator.registerSingleton<PurchaseUseCase>(PurchaseUseCase(locator()));
locator.registerSingleton<AdRewardUseCase>(AdRewardUseCase(locator()));
// درگاهِ پرداخت بر اساسِ flavorِ فعال (مایکت/کافه‌بازار/توسعه).
locator.registerSingleton<PaymentGateway>(_gatewayForStore(AppConfig.store));
locator.registerSingleton<GetProfileUseCase>(GetProfileUseCase(locator()));
locator.registerSingleton<SaveProfileUseCase>(SaveProfileUseCase(locator()));
locator.registerSingleton<GetLeaderboardUseCase>(
@@ -102,8 +120,8 @@ Future<void> setupLocator() async {
locator.registerFactory<AuthBloc>(
() => AuthBloc(locator(), locator(), locator(), locator()));
locator.registerFactory<WalletBloc>(() => WalletBloc(locator(), locator()));
locator.registerFactory<ShopBloc>(() =>
ShopBloc(locator(), locator(), locator(), locator(), locator()));
locator.registerFactory<ShopBloc>(() => ShopBloc(locator(), locator(),
locator(), locator(), locator(), locator(), locator()));
locator.registerFactory<ProfileBloc>(
() => ProfileBloc(locator(), locator()));
locator.registerFactory<LeaderboardBloc>(() => LeaderboardBloc(locator()));
@@ -111,3 +129,16 @@ Future<void> setupLocator() async {
locator.registerFactory<TierBloc>(() => TierBloc(locator()));
locator.registerFactory<PrivateInfoBloc>(() => PrivateInfoBloc(locator()));
}
/// درگاهِ پرداختِ متناظر با flavor. buildهای بدونِ flavor از درگاهِ توسعه
/// استفاده می‌کنند (بک‌اند در DevFallback می‌پذیرد).
PaymentGateway _gatewayForStore(String store) {
switch (store) {
case 'myket':
return MyketPaymentGateway();
case 'bazaar':
return BazaarPaymentGateway();
default:
return DevPaymentGateway();
}
}
+110 -19
View File
@@ -1,14 +1,20 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:dio/dio.dart';
import 'package:flame/components.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../config.dart';
/// بارگذاریِ تصاویرِ کارت از backend (نه bundle داخل اپ) تا حجم اپ کم بماند.
/// هر اسکین یک پوشه روی سرور دارد: `/cards/<skin>/<code>.png` و `/cards/<skin>/back.jpg`.
/// تصاویرِ دانلودشده در حافظه کش می‌شوند تا هر کارت فقط یک‌بار از شبکه بیاید.
/// تصاویرِ کارت از backend می‌آیند (نه bundle داخل اپ) تا حجمِ اپ کم بماند، اما هر
/// تصویر فقط **یک‌بار برای همیشه** دانلود می‌شود: بایت‌ها روی دیسکِ ماندگار ذخیره و
/// در جلسه‌های بعد از دیسک خوانده می‌شوند (بدونِ شبکه). یک کشِ درون‌حافظه‌ایِ
/// همگام ([_ready]) هم نگه می‌داریم تا کارت‌ها بدونِ پرش/انتظار رندر شوند.
///
/// مسیرِ سرور: `/cards/<skin>/<code>.png` و `/cards/<skin>/back.jpg`.
class CardImages {
CardImages._();
@@ -19,47 +25,132 @@ class CardImages {
receiveTimeout: const Duration(seconds: 12),
validateStatus: (s) => s != null && s >= 200 && s < 300,
));
static final Map<String, Future<Sprite?>> _cache = {};
// اسپرایتِ آماده و decode‌شده (کلید: "<skin>/<file>") — دسترسیِ همگام برای رندرِ فوری.
static final Map<String, Sprite> _ready = {};
// Futureهای در حالِ اجرا/تمام‌شده برای جلوگیری از بارگذاریِ تکراری.
static final Map<String, Future<Sprite?>> _inflight = {};
static Future<String>? _dir;
static String _url(String skin, String file) =>
'${AppConfig.baseUrl}/cards/$skin/$file';
/// اسپرایتِ رویِ یک کارت (مثل "AS"). در صورت خطا/۴۰۴ به اسکینِ پیش‌فرض و
/// سپس به `null` (کشیدنِ برداری در CardComponent) برمی‌گردد.
// ===== دسترسیِ همگام (اگر از قبل در حافظه آماده باشد) =====
/// اسپرایتِ رویِ کارت اگر همین حالا در حافظه آماده باشد، وگرنه null.
static Sprite? cachedFront(String skin, String code) => _ready['$skin/$code.png'];
/// اسپرایتِ پشتِ کارت اگر در حافظه آماده باشد، وگرنه null.
static Sprite? cachedBack(String skin) => _ready['$skin/back.jpg'];
// ===== بارگذاریِ ناهمگام (دیسک → شبکه) =====
/// اسپرایتِ رویِ یک کارت (مثل "AS")؛ در صورت خطا null (کشیدنِ برداری در CardComponent).
static Future<Sprite?> front(String skin, String code) =>
_load(skin, '$code.png');
/// اسپرایتِ پشتِ کارت برای اسکین داده‌شده.
/// اسپرایتِ پشتِ کارت برای اسکین.
static Future<Sprite?> back(String skin) => _load(skin, 'back.jpg');
/// کلِ دسته (۵۲ کارت + پشت) را گرم می‌کند: از دیسک (فوری) یا در اولین بار از شبکه.
/// fire-and-forget؛ در طولِ صفحه‌ی «جستجوی حریف» اجرا می‌شود تا هنگامِ پخش آماده باشند.
static void preloadDeck(String skin) {
const ranks = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
const suits = ['S', 'H', 'D', 'C'];
for (final s in suits) {
for (final r in ranks) {
_load(skin, '$r$s.png');
}
}
_load(skin, 'back.jpg');
}
static Future<Sprite?> _load(String skin, String file) {
final key = '$skin/$file';
return _cache.putIfAbsent(key, () => _fetch(skin, file));
final ready = _ready[key];
if (ready != null) return Future.value(ready);
return _inflight.putIfAbsent(key, () => _fetch(skin, file));
}
static Future<Sprite?> _fetch(String skin, String file) async {
final sprite = await _fetchFrom(skin, file);
if (sprite != null) return sprite;
if (skin != _fallbackSkin) return _fetchFrom(_fallbackSkin, file);
return null;
var s = await _fromDiskOrNet(skin, file);
if (s == null && skin != _fallbackSkin) {
s = await _fromDiskOrNet(_fallbackSkin, file);
}
if (s != null) _ready['$skin/$file'] = s;
return s;
}
static Future<Sprite?> _fetchFrom(String skin, String file) async {
static Future<Sprite?> _fromDiskOrNet(String skin, String file) async {
// ۱) دیسکِ ماندگار (اگر قبلاً دانلود شده).
final disk = await _readDisk(skin, file);
if (disk != null) {
final sp = await _toSprite(disk);
if (sp != null) return sp;
}
// ۲) شبکه — سپس ذخیره روی دیسک برای دفعه‌ی بعد.
try {
final res = await _dio.get<List<int>>(_url(skin, file));
final data = res.data;
if (data == null || data.isEmpty) return null;
final image = await _decode(Uint8List.fromList(data));
return Sprite(image);
final bytes = Uint8List.fromList(data);
unawaited(_writeDisk(skin, file, bytes));
return _toSprite(bytes);
} catch (e) {
debugPrint('CardImages: $skin/$file failed: $e');
return null;
}
}
static Future<ui.Image> _decode(Uint8List bytes) async {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return frame.image;
/// تصویرِ فرش از URL کاملِ backend (کش‌شده در حافظه با کلیدِ URL).
static Future<Sprite?> carpetByUrl(String url) {
if (url.isEmpty) return Future.value(null);
final key = 'carpet/$url';
final ready = _ready[key];
if (ready != null) return Future.value(ready);
return _inflight.putIfAbsent(key, () async {
try {
final res = await _dio.get<List<int>>(url);
final data = res.data;
if (data == null || data.isEmpty) return null;
final sp = await _toSprite(Uint8List.fromList(data));
if (sp != null) _ready[key] = sp;
return sp;
} catch (e) {
debugPrint('CardImages: carpet $url failed: $e');
return null;
}
});
}
// ===== دیسک =====
static Future<String> _cacheDir() =>
_dir ??= getApplicationSupportDirectory().then((d) => '${d.path}/hakem_cards');
static Future<Uint8List?> _readDisk(String skin, String file) async {
try {
final f = File('${await _cacheDir()}/$skin/$file');
if (await f.exists()) return f.readAsBytes();
} catch (_) {}
return null;
}
static Future<void> _writeDisk(String skin, String file, Uint8List bytes) async {
try {
final f = File('${await _cacheDir()}/$skin/$file');
await f.parent.create(recursive: true);
await f.writeAsBytes(bytes, flush: false);
} catch (_) {}
}
static Future<Sprite?> _toSprite(Uint8List bytes) async {
try {
final codec = await ui.instantiateImageCodec(bytes);
final frame = await codec.getNextFrame();
return Sprite(frame.image);
} catch (_) {
return null;
}
}
}
@@ -0,0 +1,65 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../config.dart';
/// وضعیتِ اینترنت را نگه می‌دارد: هم قطعِ رابطِ شبکه (وای‌فای/دیتا) و هم دسترسیِ
/// واقعی به سرور (با پینگِ سبکِ /health). [online] برای نمایشِ اوورلیِ «اتصال نیست».
class ConnectivityService {
ConnectivityService._();
static final ConnectivityService I = ConnectivityService._();
final ValueNotifier<bool> online = ValueNotifier<bool>(true);
final Connectivity _conn = Connectivity();
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 4),
receiveTimeout: const Duration(seconds: 4),
));
StreamSubscription? _sub;
bool _checking = false;
Future<void> init() async {
_sub = _conn.onConnectivityChanged.listen(_onChange);
await recheck();
}
void _onChange(List<ConnectivityResult> results) {
final hasInterface =
results.any((r) => r != ConnectivityResult.none);
if (!hasInterface) {
online.value = false; // بی‌درنگ: رابطِ شبکه قطع است
} else {
_verify(); // رابط هست؛ دسترسیِ واقعی را بررسی کن
}
}
/// بررسیِ دستیِ اتصال (دکمه‌ی «تلاش مجدد»).
Future<void> recheck() async {
final results = await _conn.checkConnectivity();
final hasInterface = results.any((r) => r != ConnectivityResult.none);
if (!hasInterface) {
online.value = false;
return;
}
await _verify();
}
Future<void> _verify() async {
if (_checking) return;
_checking = true;
try {
final res = await _dio.get('${AppConfig.baseUrl}/health');
online.value = res.statusCode == 200;
} catch (_) {
online.value = false;
} finally {
_checking = false;
}
}
void dispose() => _sub?.cancel();
}
@@ -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<void> connect() async {
if (_connected) return;
await FlutterPoolakey.connect(
AppConfig.bazaarRsaKey,
onDisconnected: () => _connected = false,
);
_connected = true;
}
@override
Future<PurchaseResult> purchase(String productSku, {String payload = ''}) async {
try {
await connect();
final info = await FlutterPoolakey.purchase(
productSku,
payload: payload.isNotEmpty ? 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();
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import 'payment_gateway.dart';
/// درگاهِ توسعه: بدونِ اتصال به فروشگاه، یک توکنِ ساختگی می‌سازد. برای اجرای
/// معمولیِ `flutter run` (بدونِ flavor) استفاده می‌شود؛ بک‌اند در حالتِ
/// DevFallback آن را می‌پذیرد.
class DevPaymentGateway implements PaymentGateway {
@override
String get store => 'dev';
@override
Future<void> connect() async {}
@override
Future<PurchaseResult> purchase(String productSku, {String payload = ''}) async {
final stamp = DateTime.now().millisecondsSinceEpoch;
return PurchaseResult.success(
token: 'dev-$productSku-$stamp',
purchaseData: '{"productId":"$productSku","purchaseToken":"dev-$stamp"}',
signature: 'dev',
);
}
}
@@ -0,0 +1,42 @@
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<void> connect() async {
if (_inited) return;
await MyketIAP.init(rsaKey: AppConfig.myketRsaKey, enableDebugLogging: false);
_inited = true;
}
@override
Future<PurchaseResult> purchase(String productSku, {String payload = ''}) async {
await connect();
final Map<dynamic, dynamic> result = await MyketIAP.launchPurchaseFlow(
sku: productSku,
payload: payload.isNotEmpty ? 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),
);
}
}
+42
View File
@@ -0,0 +1,42 @@
/// نتیجه‌ی یک خریدِ فروشگاهی، مستقل از اینکه کدام SDK آن را تولید کرده.
///
/// بک‌اند برای تأییدِ آفلاینِ RSA به [purchaseData] (JSONِ خام) و [signature]
/// نیاز دارد؛ [token] هم برای تأییدِ سمت‌سرور/سازگاری فرستاده می‌شود.
class PurchaseResult {
final bool success;
final String token;
final String purchaseData;
final String signature;
/// پاک‌سازیِ لازم پس از تأییدِ بک‌اند (مثلاً مایکت باید خرید را consume کند).
final Future<void> 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ِ محصول در پنلِ فروشگاه) را باز می‌کند.
/// [payload] در فروشگاه ذخیره و در پنلِ توسعه‌دهنده نمایش داده می‌شود؛ اینجا
/// شماره‌ی موبایلِ کاربر را می‌فرستیم تا خرید قابلِ ردیابی باشد.
Future<PurchaseResult> purchase(String productSku, {String payload = ''});
/// اتصالِ اولیه (در صورتِ نیاز). پیش از اولین خرید صدا زده می‌شود.
Future<void> connect() async {}
}
+5
View File
@@ -16,6 +16,7 @@ class AppSounds {
static const _button = 'button.mp3'; // فشردنِ دکمه‌ها
static const _selectHokm = 'select_hokm.mp3'; // انتخابِ حکم
static const _heartBeat = 'heart_beat.mp3'; // ثانیه‌های پایانیِ نوبت
static const _turn = 'turn.mp3'; // شروعِ نوبتِ خودِ کاربر
static const _all = [
_shuffle,
@@ -26,6 +27,7 @@ class AppSounds {
_button,
_selectHokm,
_heartBeat,
_turn,
];
static final Map<String, AudioPool> _pools = {};
@@ -59,6 +61,9 @@ class AppSounds {
static void cut() => _play(_cut, 0.9);
static void win() => _play(_win, 0.9);
static void selectHokm() => _play(_selectHokm, 0.9);
static void turn() => _play(_turn, 0.9); // نوبتِ کاربر برای بازی
// شمارشِ معکوسِ شروعِ بازی (۳…۲…۱) — کلیکِ کوتاه برای هر عدد.
static void tick() => _play(_button, 0.7, cutoffMs: 200);
// کنترلِ ضربانِ قلب: همیشه حداکثر یک ضربان هم‌زمان (بدون انباشت/تکرارِ سریع)
// و قابلِ توقف وقتی نوبت تمام می‌شود.
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// تنظیماتِ سراسریِ اپ (فعلاً کیفیتِ گرافیک). با ذخیره‌ی محلی پایدار می‌ماند.
class AppSettings {
AppSettings._();
static final AppSettings I = AppSettings._();
final _storage = const FlutterSecureStorage();
static const _kLowGfx = 'low_graphics';
/// حالتِ کم‌مصرف: افکت‌های اتمسفری (ذرات، شوک‌ویوِ بریدن، پس‌زمینه‌ی تصویری)
/// خاموش می‌شوند تا روی گوشی‌های ضعیف روان بماند. با ValueNotifier تا UI واکنش دهد.
final ValueNotifier<bool> lowGraphics = ValueNotifier(false);
Future<void> load() async {
lowGraphics.value = (await _storage.read(key: _kLowGfx)) == '1';
}
Future<void> setLowGraphics(bool v) async {
lowGraphics.value = v;
await _storage.write(key: _kLowGfx, value: v ? '1' : '0');
}
}
+11 -2
View File
@@ -1,8 +1,9 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// ذخیره‌ی امن توکن JWT.
/// ذخیره‌ی امن توکن JWT و شماره‌ی موبایلِ کاربر.
class TokenStorage {
static const _key = 'jwt_token';
static const _mobileKey = 'user_mobile';
final FlutterSecureStorage _storage;
TokenStorage([FlutterSecureStorage? storage])
@@ -12,5 +13,13 @@ class TokenStorage {
Future<void> write(String token) => _storage.write(key: _key, value: token);
Future<void> clear() => _storage.delete(key: _key);
Future<String?> readMobile() => _storage.read(key: _mobileKey);
Future<void> writeMobile(String mobile) =>
_storage.write(key: _mobileKey, value: mobile);
Future<void> clear() async {
await _storage.delete(key: _key);
await _storage.delete(key: _mobileKey);
}
}
+77 -6
View File
@@ -1,15 +1,50 @@
import 'package:flutter/material.dart';
/// تم بصری حکم‌شو (الهام‌گرفته از اپ مرجع: قرمز تیره و طلایی).
/// همه‌ی رنگ‌های پرتکرار اینجا متمرکزند تا در کد به‌صورت ثابتِ بی‌نام تکرار نشوند.
class AppColors {
static const bg = Color(0xFF3A0A12);
static const bgDark = Color(0xFF240108);
static const panel = Color(0xFF5A0E1A);
// پایه — تمِ شاهانهٔ ایران باستان: لاجوردِ تخت‌جمشید + طلا (با شنگرفِ کنتراست).
static const bg = Color(0xFF0E2347); // لاجوردِ شب
static const bgDark = Color(0xFF081428); // لاجوردِ تیره
static const panel = Color(0xFF17345C); // پنلِ نیمه‌شب آبی
static const gold = Color(0xFFE9B949);
static const goldDark = Color(0xFFB8860B);
static const accent = Color(0xFFB81D2A);
static const goldFaint = Color(0x33E9B949); // جداکننده/خطِ طلاییِ کم‌رنگ
static const accent = Color(0xFFB81D2A); // شنگرفِ ایرانی (کنشِ مهم/خروج)
static const green = Color(0xFF2E7D32);
static const text = Color(0xFFF5E9D0);
// رمپِ لاجوردِ شاهانه (روشن → تیره) برای پس‌زمینه‌ها، پنل‌ها و میز.
static const lapis = Color(0xFF1E5FA8); // فیروزه‌ایِ روشن (تأکید/تب)
static const lapisHi = Color(0xFF1C4E8E); // سطحِ روشن (میز/اوال)
static const lapisMid = Color(0xFF1B4576); // پنلِ گرادیانِ بالا
static const lapisLo = Color(0xFF0C2848); // گرادیانِ پایین
static const lapisNight = Color(0xFF071A33); // لبهٔ میز/پس‌زمینهٔ Flame
static const lapisInk = Color(0xFF04101F); // مرکزِ تیرهٔ میز
// معنایی (دکمه‌ها/وضعیت/خال‌ها)
static const success = Color(0xFF3FA34D); // سبزِ روشنِ دکمه
static const successDark = Color(0xFF1B5E20); // سبزِ تیره (گرادیان/درخشش)
static const online = Color(0xFF4CD964); // نشانگرِ اتصالِ آنلاین
static const suitRed = Color(0xFFD32F2F); // دل/خشت
static const suitDark = Color(0xFF1A1A1A); // پیک/گشنیز (مرکّبِ کارت)
}
/// نمادِ یک‌نواییِ خال و رنگش از روی نامِ کاملِ خال (hearts/diamonds/clubs/spades).
/// منبعِ واحدِ این منطق تا در صفحه‌های مختلف تکرار نشود.
(String, Color) suitGlyph(String? suit) {
switch (suit) {
case 'hearts':
return ('', AppColors.suitRed);
case 'diamonds':
return ('', AppColors.suitRed);
case 'clubs':
return ('', Colors.white);
case 'spades':
return ('', Colors.white);
default:
return ('', Colors.white54);
}
}
class AppTheme {
@@ -18,11 +53,17 @@ class AppTheme {
final base = ThemeData.dark(useMaterial3: true);
return base.copyWith(
scaffoldBackgroundColor: AppColors.bg,
primaryColor: AppColors.accent,
primaryColor: AppColors.lapis,
canvasColor: AppColors.bg,
cardColor: AppColors.panel,
dividerColor: AppColors.goldFaint,
colorScheme: base.colorScheme.copyWith(
primary: AppColors.gold,
secondary: AppColors.accent,
onPrimary: AppColors.lapisInk,
secondary: AppColors.lapis,
tertiary: AppColors.accent,
surface: AppColors.panel,
onSurface: AppColors.text,
),
textTheme: base.textTheme.apply(
fontFamily: fontFamily,
@@ -34,6 +75,36 @@ class AppTheme {
foregroundColor: AppColors.gold,
centerTitle: true,
),
// دیالوگ/منو/اسنک‌بار با همان قابِ لاجورد + طلا.
dialogTheme: DialogThemeData(
backgroundColor: AppColors.panel,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: const BorderSide(color: AppColors.gold, width: 1.5),
),
titleTextStyle: const TextStyle(
fontFamily: fontFamily,
color: AppColors.gold,
fontSize: 19,
fontWeight: FontWeight.bold),
contentTextStyle:
const TextStyle(fontFamily: fontFamily, color: AppColors.text),
),
snackBarTheme: const SnackBarThemeData(
backgroundColor: AppColors.lapisMid,
contentTextStyle: TextStyle(fontFamily: fontFamily, color: AppColors.text),
behavior: SnackBarBehavior.floating,
),
tabBarTheme: const TabBarThemeData(
labelColor: AppColors.gold,
unselectedLabelColor: Colors.white60,
indicatorColor: AppColors.gold,
),
bottomSheetTheme: const BottomSheetThemeData(
backgroundColor: AppColors.panel,
surfaceTintColor: Colors.transparent,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.bgDark.withValues(alpha: 0.6),
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
/// کشِ رنگِ قاب‌ها که از سرور می‌آید (id → گرادیان). با هر بار خواندنِ کاتالوگِ
/// قاب‌ها پر می‌شود؛ هیچ رنگی سمتِ کلاینت hardcode نیست (تا کش پر نشود، null).
class FrameStyles {
FrameStyles._();
static final FrameStyles I = FrameStyles._();
final Map<String, List<Color>> _cache = {};
/// کشِ رنگ‌ها را از کاتالوگِ سرور پر می‌کند. هر آیتم باید id و دو رنگِ hex بدهد.
void setFromCatalog(Iterable<({String id, String c1, String c2})> frames) {
for (final f in frames) {
final g = _parse(f.c1, f.c2);
if (g != null) _cache[f.id] = g;
}
}
List<Color>? gradient(String id) {
if (id.isEmpty || id == 'none') return null;
return _cache[id];
}
static List<Color>? _parse(String c1, String c2) {
final a = _hex(c1), b = _hex(c2);
if (a == null || b == null) return null;
return [a, b];
}
static Color? _hex(String s) {
s = s.trim().replaceAll('#', '');
if (s.length != 6) return null;
final v = int.tryParse(s, radix: 16);
return v == null ? null : Color(0xFF000000 | v);
}
}
/// گرادیانِ قابِ آواتار (از کشِ سرور). null ⇒ قابِ رتبه.
List<Color>? frameGradient(String id) => FrameStyles.I.gradient(id);
+51
View File
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'app_theme.dart';
/// کشِ رنگ و برچسبِ رتبه‌ها که از سرور می‌آید (tier → گرادیان/برچسب). با هر بار
/// خواندنِ کاتالوگِ قاب‌ها پر می‌شود؛ تا آن لحظه از fallbackِ خنثی استفاده می‌شود.
class RankStyles {
RankStyles._();
static final RankStyles I = RankStyles._();
final Map<String, List<Color>> _grad = {};
final Map<String, String> _label = {};
/// کش را از کاتالوگِ سرور پر می‌کند. هر آیتم id/label/c1/c2 دارد.
void setFromCatalog(
Iterable<({String id, String label, String c1, String c2})> tiers,
) {
for (final t in tiers) {
final g = _parse(t.c1, t.c2);
if (g != null) _grad[t.id] = g;
if (t.label.isNotEmpty) _label[t.id] = t.label;
}
}
/// گرادیانِ رتبه (از سرور، وگرنه fallbackِ خنثیِ طلایی).
List<Color> gradient(String tier) => _grad[tier] ?? _fallbackGrad;
/// برچسبِ فارسیِ رتبه (از سرور، وگرنه خودِ شناسه).
String label(String tier) => _label[tier] ?? tier;
static const _fallbackGrad = [AppColors.gold, AppColors.goldDark];
static List<Color>? _parse(String c1, String c2) {
final a = _hex(c1), b = _hex(c2);
if (a == null || b == null) return null;
return [a, b];
}
static Color? _hex(String s) {
s = s.trim().replaceAll('#', '');
if (s.length != 6) return null;
final v = int.tryParse(s, radix: 16);
return v == null ? null : Color(0xFF000000 | v);
}
}
/// گرادیانِ رنگیِ رتبه (از کشِ سرور).
List<Color> rankGradient(String tier) => RankStyles.I.gradient(tier);
/// برچسبِ فارسیِ رتبه (از کشِ سرور).
String rankLabel(String tier) => RankStyles.I.label(tier);
+4
View File
@@ -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 = '',
});
}
+37 -4
View File
@@ -1,8 +1,41 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../service/app_sounds.dart';
import '../theme/app_theme.dart';
/// دکمه‌ی بازگشتِ سراسری: قابِ طلایی هماهنگ با تمِ بازی. اگر [onTap] داده نشود،
/// به‌صورت پیش‌فرض یک صفحه به عقب می‌رود (context.pop()).
class AppBackButton extends StatelessWidget {
final VoidCallback? onTap;
final double size;
const AppBackButton({super.key, this.onTap, this.size = 46});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
AppSounds.button();
if (onTap != null) {
onTap!();
} else {
context.pop();
}
},
child: Container(
width: size,
height: size,
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.gold, width: 1.5),
),
child: const Icon(Icons.arrow_back, color: AppColors.gold),
),
);
}
}
/// مجموعه‌ی ویجت‌های گرافیکیِ مشترک برای ظاهرِ بازیِ (Unity-style):
/// پس‌زمینه‌ی گرادیانی، پنل براق، دکمه‌ی جواهرگون، و عنوانِ درخشان.
@@ -20,7 +53,7 @@ class GameBackground extends StatelessWidget {
gradient: RadialGradient(
center: Alignment(0, -0.4),
radius: 1.3,
colors: [Color(0xFF5A0F1E), Color(0xFF240108)],
colors: [AppColors.lapisMid, AppColors.bgDark],
stops: [0.0, 1.0],
),
),
@@ -33,7 +66,7 @@ class GameBackground extends StatelessWidget {
gradient: RadialGradient(
center: Alignment(0, -1.1),
radius: 1.0,
colors: [Color(0x33E9B949), Color(0x00000000)],
colors: [AppColors.goldFaint, Color(0x00000000)],
),
),
),
@@ -65,7 +98,7 @@ class GamePanel extends StatelessWidget {
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF6E1322), Color(0xFF3A0A12)],
colors: [AppColors.lapisMid, AppColors.bg],
),
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: AppColors.gold, width: 2),
@@ -223,7 +256,7 @@ class StatChip extends StatelessWidget {
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF2A0610), Color(0xFF14040A)],
colors: [AppColors.lapisLo, AppColors.lapisNight],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.goldDark),
+124
View File
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../network/connectivity_service.dart';
import '../theme/app_theme.dart';
/// [child] را می‌پوشاند و هنگامِ قطعِ اینترنت یک اوورلیِ «اتصال برقرار نیست» با
/// دکمه‌ی «تلاش مجدد» نشان می‌دهد. به‌محضِ برقراریِ اتصال، خودش کنار می‌رود.
class OfflineGate extends StatelessWidget {
final Widget child;
const OfflineGate({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
ValueListenableBuilder<bool>(
valueListenable: ConnectivityService.I.online,
builder: (context, online, _) =>
online ? const SizedBox.shrink() : const _OfflineOverlay(),
),
],
);
}
}
class _OfflineOverlay extends StatefulWidget {
const _OfflineOverlay();
@override
State<_OfflineOverlay> createState() => _OfflineOverlayState();
}
class _OfflineOverlayState extends State<_OfflineOverlay> {
bool _busy = false;
Future<void> _retry() async {
setState(() => _busy = true);
await ConnectivityService.I.recheck();
if (mounted) setState(() => _busy = false);
}
@override
Widget build(BuildContext context) {
return Positioned.fill(
child: Material(
color: Colors.black.withValues(alpha: 0.82),
child: Center(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.fromLTRB(24, 28, 24, 22),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.lapisMid, AppColors.lapisLo],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold, width: 2),
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 20),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.bgDark,
border: Border.all(color: AppColors.goldDark, width: 2),
),
child: const Icon(Icons.wifi_off_rounded,
color: AppColors.gold, size: 38),
),
const SizedBox(height: 16),
const Text(
'اتصال به اینترنت برقرار نیست',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.gold,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
const Text(
'اینترنتِ دستگاهت را بررسی کن و دوباره تلاش کن.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(height: 22),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _busy ? null : _retry,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.gold,
foregroundColor: AppColors.bg,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
icon: _busy
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.bg),
)
: const Icon(Icons.refresh, size: 20),
label: Text(_busy ? 'در حال بررسی…' : 'تلاش مجدد'),
),
),
],
),
),
),
),
);
}
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:random_avatar/random_avatar.dart';
/// آواتارِ یک بازیکن در کلِ بازی: اگر شخصیتِ آواتاری خریده و انتخاب کرده باشد
/// ([imageUrl] یک URL کاملِ svg/png از سرور) همان را نشان می‌دهد؛ وگرنه به آواتارِ
/// تصادفیِ پیش‌فرض بر اساسِ [seed] برمی‌گردد.
///
/// URLِ تصویر همیشه از backend می‌آید (URL-safe)؛ کلاینت آن را نمی‌سازد. یک منبعِ
/// واحد برای رندرِ آواتار در سرِ میز، پروفایل، لابی و فروشگاه (svg با flutter_svg،
/// png/jpg با Image.network).
class PlayerAvatar extends StatelessWidget {
final String seed; // seed آواتارِ تصادفی (fallback)
final String imageUrl; // URL کاملِ تصویرِ شخصیت (از سرور) — خالی ⇒ تصادفی
final double? size; // اگر null، فضای موجود را پر می‌کند
const PlayerAvatar({
super.key,
required this.seed,
this.imageUrl = '',
this.size,
});
bool get _hasCharacter => imageUrl.isNotEmpty;
@override
Widget build(BuildContext context) {
final Widget child;
if (_hasCharacter) {
child =
imageUrl.toLowerCase().endsWith('.svg')
? SvgPicture.network(
imageUrl,
fit: BoxFit.cover,
placeholderBuilder: (_) => _fallback(),
)
: Image.network(
imageUrl,
fit: BoxFit.scaleDown,
errorBuilder: (_, __, ___) => _fallback(),
);
} else {
child = _fallback();
}
final clipped = ClipOval(child: SizedBox.expand(child: child));
if (size == null) return clipped;
return SizedBox(width: size, height: size, child: clipped);
}
Widget _fallback() => RandomAvatar(seed.isEmpty ? 'hakem-1' : seed);
}
+5 -12
View File
@@ -1,25 +1,18 @@
import 'package:flutter/material.dart';
/// نشانِ رتبه (برنز/نقره/طلا/الماس/پادشاه) با رنگ و برچسبِ فارسی.
import '../theme/ranks.dart';
/// نشانِ رتبه (برنز/نقره/طلا/الماس/پادشاه) با رنگ و برچسبِ فارسی از سرور.
class RankBadge extends StatelessWidget {
final String tier; // bronze..king
final int? points; // در صورت نمایش امتیاز
final double size;
const RankBadge({super.key, required this.tier, this.points, this.size = 16});
static const _tiers = {
'bronze': ('برنز', [Color(0xFFCD7F32), Color(0xFF8C5A2B)]),
'silver': ('نقره', [Color(0xFFBFC6CC), Color(0xFF8A949B)]),
'gold': ('طلا', [Color(0xFFFFD54F), Color(0xFFB8860B)]),
'diamond': ('الماس', [Color(0xFF5BE0E6), Color(0xFF1E8A99)]),
'king': ('پادشاه', [Color(0xFFB388FF), Color(0xFF6A1B9A)]),
};
@override
Widget build(BuildContext context) {
final t = _tiers[tier] ?? _tiers['bronze']!;
final label = t.$1;
final colors = t.$2;
final label = rankLabel(tier);
final colors = rankGradient(tier);
return Container(
padding: EdgeInsets.symmetric(horizontal: size * 0.6, vertical: size * 0.25),
decoration: BoxDecoration(
@@ -9,6 +9,9 @@ class AuthLocalData {
Future<String?> readToken() => _storage.read();
Future<void> clearToken() => _storage.clear();
Future<void> saveMobile(String mobile) => _storage.writeMobile(mobile);
Future<String?> readMobile() => _storage.readMobile();
Future<bool> hasToken() async {
final t = await _storage.read();
return t != null && t.isNotEmpty;
@@ -32,6 +32,7 @@ class AuthRepositoryImpl extends AuthRepository {
return const DataError('پاسخ نامعتبر از سرور');
}
await local.saveToken(token);
await local.saveMobile(params.mobile); // برای payloadِ خریدِ فروشگاهی
return DataSuccess(UserModel.fromJson(
Map<String, dynamic>.from(res.data['user'] as Map)));
}
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart';
import '../bloc/auth_bloc.dart';
import '../bloc/auth_event.dart';
@@ -52,7 +53,7 @@ class _MobileScreenState extends State<MobileScreen> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const GlowText('سلطان حکم', size: 40),
const GlowText('حاکم ایرانی', size: 40),
const SizedBox(height: 28),
GamePanel(
padding: const EdgeInsets.all(22),
@@ -97,6 +98,27 @@ class _MobileScreenState extends State<MobileScreen> {
LoginOtpEvent(_controller.text.trim()),
),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => context.push('/terms'),
child: const Text.rich(
TextSpan(
text: 'با ورود، ',
style: TextStyle(
color: Colors.white54, fontSize: 12),
children: [
TextSpan(
text: 'شرایط و قوانین',
style: TextStyle(
color: AppColors.gold,
fontSize: 12,
fontWeight: FontWeight.bold),
),
TextSpan(text: ' را می‌پذیرید'),
],
),
),
),
],
),
),
@@ -109,7 +109,7 @@ class _OtpScreenState extends State<OtpScreen> {
GameButton(
label: 'ورود',
width: double.infinity,
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
colors: const [AppColors.success, AppColors.successDark],
onTap: (!_valid || loading)
? null
: () => _submit(context),
@@ -124,7 +124,7 @@ class _ProfileSetupScreenState extends State<ProfileSetupScreen> {
GameButton(
label: saving ? 'در حال ذخیره…' : 'تأیید و ورود',
width: double.infinity,
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
colors: const [AppColors.success, AppColors.successDark],
onTap: (!_valid || saving)
? null
: () => context.read<AuthBloc>().add(
@@ -0,0 +1,33 @@
import '../../../../../core/locator/locator.dart';
import '../../../../../core/network/api_provider_imp.dart';
import '../../../domain/entities/chat_pack.dart';
/// دادهٔ بسته‌های چت از backend (دریافتِ بسته‌ها و خریدِ بسته با سکه).
class ChatApiProvider {
ApiProviderImp get _api => locator<ApiProviderImp>();
Future<List<ChatPack>> getChatPacks() async {
final res = await _api.get('/chat-packs');
if (res.statusCode != 200) {
throw Exception('chat-packs: ${res.statusCode}');
}
final list = (res.data['packs'] as List?) ?? const [];
return list
.map((e) => ChatPack.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
/// خریدِ بسته. در صورتِ موفقیت null، وگرنه پیامِ خطای فارسی برمی‌گرداند.
Future<String?> buyChatPack(String packId) async {
final res = await _api.post('/shop/buy-chat-pack', body: {'pack_id': packId});
if (res.statusCode == 200) return null;
switch (res.statusCode) {
case 402:
return 'سکهٔ کافی ندارید';
case 409:
return 'این بسته را دارید';
default:
return 'خرید ناموفق بود';
}
}
}
@@ -0,0 +1,34 @@
/// یک بسته‌ی پیام/شکلکِ چت (از backend). owned یعنی قابلِ استفاده است.
class ChatPack {
final String id;
final String title;
final String kind; // text | emoji
final int priceCoins;
final bool vip; // برای VIP رایگان است
final bool owned;
final List<String> messages;
const ChatPack({
required this.id,
required this.title,
required this.kind,
required this.priceCoins,
required this.vip,
required this.owned,
required this.messages,
});
bool get isEmoji => kind == 'emoji';
factory ChatPack.fromJson(Map<String, dynamic> j) => ChatPack(
id: (j['id'] ?? '') as String,
title: (j['title'] ?? '') as String,
kind: (j['kind'] ?? 'text') as String,
priceCoins: (j['price_coins'] ?? 0) as int,
vip: (j['vip'] ?? false) as bool,
owned: (j['owned'] ?? false) as bool,
messages: ((j['messages'] as List?) ?? const [])
.map((e) => e as String)
.toList(),
);
}
@@ -0,0 +1,487 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import '../../../../core/locator/locator.dart';
import '../../../../core/theme/app_theme.dart';
import '../../data/data_source/remote/chat_api_provider.dart';
import '../../domain/entities/chat_pack.dart';
/// پنلِ «بسته چت»: قابِ لاجوردیِ طلاکوب، تبِ حبّه‌ایِ هر بسته (با قفلِ بسته‌های
/// نخریده)، و گریدِ پیام/شکلک. بسته‌های قفل با محوکردنِ گرید و کارتِ خرید نمایش
/// داده می‌شوند (سکه/VIP).
class ChatPanel extends StatefulWidget {
final void Function(String text) onText;
final void Function(String emoji) onEmoji;
final VoidCallback? onWalletChanged; // پس از خرید، موجودی را تازه کن
const ChatPanel({
super.key,
required this.onText,
required this.onEmoji,
this.onWalletChanged,
});
static Future<void> show(
BuildContext context, {
required void Function(String text) onText,
required void Function(String emoji) onEmoji,
VoidCallback? onWalletChanged,
}) {
return showDialog(
context: context,
barrierColor: Colors.black.withValues(alpha: 0.6),
builder: (_) => ChatPanel(
onText: onText,
onEmoji: onEmoji,
onWalletChanged: onWalletChanged,
),
);
}
@override
State<ChatPanel> createState() => _ChatPanelState();
}
class _ChatPanelState extends State<ChatPanel> {
final _api = locator<ChatApiProvider>();
late Future<List<ChatPack>> _future;
String? _busyPack; // بسته‌ای که در حالِ خرید است
@override
void initState() {
super.initState();
_future = _api.getChatPacks();
}
void _reload() => setState(() {
_future = _api.getChatPacks();
});
Future<void> _buy(ChatPack p) async {
setState(() => _busyPack = p.id);
final err = await _api.buyChatPack(p.id);
if (!mounted) return;
setState(() => _busyPack = null);
if (err == null) {
widget.onWalletChanged?.call();
_reload();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(err), duration: const Duration(seconds: 2)),
);
}
}
void _pick(ChatPack p, String body) {
if (p.isEmoji) {
widget.onEmoji(body);
} else {
widget.onText(body);
}
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 40),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: FutureBuilder<List<ChatPack>>(
future: _future,
builder: (context, snap) {
if (snap.connectionState != ConnectionState.done) {
return _shell(const SizedBox(
height: 260,
child: Center(
child: CircularProgressIndicator(color: AppColors.gold)),
));
}
final packs = snap.data ?? const <ChatPack>[];
if (packs.isEmpty) {
return _shell(const SizedBox(
height: 200,
child: Center(
child: Text('بسته‌ای موجود نیست',
style: TextStyle(color: Colors.white70))),
));
}
return DefaultTabController(
length: packs.length,
child: _shell(
Column(
mainAxisSize: MainAxisSize.min,
children: [
_tabs(packs),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: TabBarView(
children: [for (final p in packs) _packView(p)],
),
),
],
),
),
);
},
),
),
);
}
// تبِ حبّه‌ای؛ بسته‌های قفل یک قفلِ کوچک کنارِ عنوان دارند.
Widget _tabs(List<ChatPack> packs) {
return SizedBox(
height: 40,
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
padding: const EdgeInsets.symmetric(horizontal: 2),
indicator: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.lapis, AppColors.lapisHi],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold, width: 1.3),
boxShadow: [
BoxShadow(
color: AppColors.gold.withValues(alpha: 0.25),
blurRadius: 8,
),
],
),
indicatorSize: TabBarIndicatorSize.tab,
labelColor: AppColors.gold,
unselectedLabelColor: Colors.white60,
labelStyle: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
unselectedLabelStyle: const TextStyle(fontSize: 13),
dividerColor: Colors.transparent,
overlayColor: WidgetStateProperty.all(Colors.transparent),
tabs: [
for (final p in packs)
Tab(
height: 36,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
p.isEmoji
? Icons.emoji_emotions_outlined
: Icons.chat_bubble_outline,
size: 15,
),
const SizedBox(width: 5),
Text(p.title),
if (!p.owned) ...[
const SizedBox(width: 5),
Icon(
p.vip ? Icons.workspace_premium : Icons.lock,
size: 13,
color: AppColors.gold,
),
],
],
),
),
],
),
);
}
// قابِ لاجوردیِ پنل با ربانِ طلاییِ «بسته چت» و دکمه‌ی بستن.
Widget _shell(Widget child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
margin: const EdgeInsets.only(top: 20),
padding: const EdgeInsets.fromLTRB(14, 34, 14, 16),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.lapisMid, AppColors.lapisLo],
),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.gold, width: 1.6),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 24,
offset: const Offset(0, 12),
),
BoxShadow(
color: AppColors.gold.withValues(alpha: 0.12),
blurRadius: 20,
),
],
),
child: child,
),
// ربانِ عنوان
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 26, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.gold, AppColors.goldDark],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.35)),
boxShadow: [
BoxShadow(
color: AppColors.goldDark.withValues(alpha: 0.5),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_rounded,
color: AppColors.lapisInk, size: 18),
SizedBox(width: 7),
Text('بسته چت',
style: TextStyle(
color: AppColors.lapisInk,
fontSize: 16,
fontWeight: FontWeight.bold)),
],
),
),
),
),
Positioned(
top: 12,
right: 4,
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: AppColors.accent,
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.4)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.4),
blurRadius: 6,
),
],
),
child: const Icon(Icons.close, color: Colors.white, size: 18),
),
),
),
],
);
}
Widget _packView(ChatPack p) {
final grid = p.isEmoji ? _emojiGrid(p) : _textGrid(p);
if (p.owned) return grid;
// بسته‌ی قفل: گریدِ محو (blur) + کارتِ خرید روی آن.
return ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Stack(
fit: StackFit.expand,
children: [
IgnorePointer(child: Opacity(opacity: 0.5, child: grid)),
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3),
child: Container(color: AppColors.lapisLo.withValues(alpha: 0.25)),
),
Center(child: _buyCard(p)),
],
),
);
}
Widget _buyCard(ChatPack p) {
final busy = _busyPack == p.id;
return Container(
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
margin: const EdgeInsets.symmetric(horizontal: 28),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.lapisHi, AppColors.lapisLo],
),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.gold, width: 1.5),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.45),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.gold.withValues(alpha: 0.12),
shape: BoxShape.circle,
border: Border.all(color: AppColors.goldFaint),
),
child: Icon(
p.vip ? Icons.workspace_premium : Icons.lock,
color: AppColors.gold,
size: 28,
),
),
const SizedBox(height: 12),
Text(
p.vip ? 'ویژه‌ی اعضای VIP' : 'این بسته قفل است',
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
if (p.vip)
const Padding(
padding: EdgeInsets.only(top: 4),
child: Text('برای اعضای VIP رایگان است',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.gold, fontSize: 12)),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppColors.gold, width: 1.3),
),
),
onPressed: busy ? null : () => _buy(p),
child: busy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2, color: Colors.white))
: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('خرید',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
const Icon(Icons.monetization_on,
color: AppColors.gold, size: 20),
const SizedBox(width: 3),
Text('${p.priceCoins}',
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.bold)),
],
),
),
),
],
),
);
}
Widget _textGrid(ChatPack p) => GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 4),
childAspectRatio: 2.7,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
physics: const BouncingScrollPhysics(),
children: [
for (final m in p.messages)
Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: p.owned ? () => _pick(p, m) : null,
child: Ink(
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.lapisHi, AppColors.lapisLo],
),
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: AppColors.goldDark, width: 1.1),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 5,
offset: const Offset(0, 3),
),
],
),
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(m,
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 13.5)),
),
),
),
),
),
],
);
Widget _emojiGrid(ChatPack p) => GridView.count(
crossAxisCount: 5,
padding: const EdgeInsets.all(6),
mainAxisSpacing: 8,
crossAxisSpacing: 8,
physics: const BouncingScrollPhysics(),
children: [
for (final e in p.messages)
Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(14),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: p.owned ? () => _pick(p, e) : null,
child: Ink(
decoration: BoxDecoration(
color: AppColors.lapisHi.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.goldFaint),
),
child: Center(
child: Text(e, style: const TextStyle(fontSize: 30))),
),
),
),
],
);
}
@@ -7,6 +7,9 @@ class GamePlayer {
final bool connected;
final int coins;
final String rankTier; // bronze..king (خالی برای بات)
final String avatar; // seed آواتارِ انتخابیِ کاربر (خالی برای بات)
final String frame; // قابِ آواتارِ انتخابی (کازمتیک؛ خالی ⇒ قابِ رتبه)
final String avatarImg; // شخصیتِ آواتارِ انتخابی (<id>.<ext>؛ خالی ⇒ تصادفی)
GamePlayer.fromJson(Map<String, dynamic> j)
: seat = (j['seat'] ?? 0) as int,
@@ -14,7 +17,13 @@ class GamePlayer {
bot = (j['bot'] ?? false) as bool,
connected = (j['connected'] ?? false) as bool,
coins = (j['coins'] ?? 0) as int,
rankTier = (j['rank_tier'] ?? '') as String;
rankTier = (j['rank_tier'] ?? '') as String,
avatar = (j['avatar'] ?? '') as String,
frame = (j['frame'] ?? '') as String,
avatarImg = (j['avatar_img'] ?? '') as String;
/// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام (fallback برای بات).
String get avatarSeed => avatar.isNotEmpty ? avatar : name;
}
class TrickCard {
@@ -7,6 +7,7 @@ class TableTier {
final int prize;
final int xp;
final int trophy;
final int rankReward; // > 0 ⇒ میزِ رتبه‌بندی (به برنده امتیازِ رتبه می‌دهد)
const TableTier({
required this.id,
@@ -16,8 +17,12 @@ class TableTier {
required this.prize,
required this.xp,
required this.trophy,
this.rankReward = 0,
});
/// آیا این میزِ رتبه‌بندی است؟ (برد امتیازِ رتبه می‌دهد و در جدولِ قهرمانان بالا می‌برد)
bool get isRanked => rankReward > 0;
factory TableTier.fromJson(Map<String, dynamic> j) => TableTier(
id: j['id'] as String,
title: j['title'] as String,
@@ -26,6 +31,7 @@ class TableTier {
prize: (j['prize'] ?? 0) as int,
xp: (j['xp'] ?? 0) as int,
trophy: (j['trophy'] ?? 0) as int,
rankReward: (j['rank_reward'] ?? 0) as int,
);
}
@@ -17,6 +17,7 @@ class GameBloc extends Bloc<GameEvent, GameUiState> {
Map<String, dynamic> _joinAction = const {};
bool _joined = false;
int _bubbleSeq = 0; // شناسه‌ی یکتا برای هر حبابِ پیام (انقضای دقیق)
GameBloc(this.repository) : super(const GameUiState()) {
_msgSub =
@@ -44,9 +45,24 @@ class GameBloc extends Bloc<GameEvent, GameUiState> {
(event, emit) => repository.send({'type': 'choose_trump', 'suit': event.suit}));
on<PlayCardEvent>(
(event, emit) => repository.send({'type': 'play_card', 'card': event.card}));
on<ReshuffleEvent>((event, emit) => repository.send({'type': 'reshuffle'}));
on<SendChatEvent>((event, emit) => repository.send({
'type': 'chat',
if (event.text != null) 'text': event.text,
if (event.emoji != null) 'emoji': event.emoji,
}));
on<ExpireChatEvent>((event, emit) {
final cur = state.chatBubbles[event.seat];
if (cur == null || cur.id != event.id) return; // حبابِ جدیدتر است ⇒ نگه‌دار
final next = Map<int, ChatBubble>.from(state.chatBubbles)
..remove(event.seat);
emit(state.copyWith(chatBubbles: next));
});
on<LeaveGameEvent>((event, emit) => repository.send({'type': 'leave'}));
on<StartTableEvent>((event, emit) =>
repository.send({'type': 'start_table', 'hands': event.hands}));
on<StartTableEvent>((event, emit) => repository.send(
{'type': 'start_table', 'hands': event.hands, 'stake': event.stake}));
on<SetStakeEvent>((event, emit) =>
repository.send({'type': 'set_stake', 'stake': event.stake}));
on<RotateTableEvent>((event, emit) =>
repository.send({'type': 'rotate_table', 'dir': event.dir}));
on<LeaveTableEvent>((event, emit) => repository.send({'type': 'leave_table'}));
@@ -74,6 +90,19 @@ class GameBloc extends Bloc<GameEvent, GameUiState> {
emit(state.copyWith(notice: 'یک بازیکن قطع شد'));
case 'player_reconnected':
emit(state.copyWith(notice: 'بازیکن بازگشت'));
case 'chat':
final seat = (msg['seat'] ?? 0) as int;
final id = ++_bubbleSeq;
final bubble = ChatBubble(
text: msg['text'] as String?,
emoji: msg['emoji'] as String?,
id: id,
);
final next = Map<int, ChatBubble>.from(state.chatBubbles)
..[seat] = bubble;
emit(state.copyWith(chatBubbles: next));
// انقضای خودکار پس از ۴ ثانیه (مگر حبابِ جدیدتری روی همان جایگاه بنشیند).
Timer(const Duration(seconds: 4), () => add(ExpireChatEvent(seat, id)));
case 'player_left':
emit(state.copyWith(notice: 'یک بازیکن میز را ترک کرد'));
case 'error':
@@ -83,9 +112,14 @@ class GameBloc extends Bloc<GameEvent, GameUiState> {
// --- متدهای کمکی برای موتور Flame و صفحه‌ها ---
void chooseTrump(String suit) => add(ChooseTrumpEvent(suit));
void reshuffle() => add(ReshuffleEvent());
void sendChatText(String text) => add(SendChatEvent(text: text));
void sendChatEmoji(String emoji) => add(SendChatEvent(emoji: emoji));
void playCard(String card) => add(PlayCardEvent(card));
void leave() => add(LeaveGameEvent());
void startTable(int hands) => add(StartTableEvent(hands));
void startTable(int hands, {int stake = 0}) =>
add(StartTableEvent(hands, stake: stake));
void setStake(int stake) => add(SetStakeEvent(stake));
void rotateTable(int dir) => add(RotateTableEvent(dir));
void leaveTable() => add(LeaveTableEvent());
void clearNotice() => add(ClearNoticeEvent());
@@ -30,11 +30,33 @@ class PlayCardEvent extends GameEvent {
PlayCardEvent(this.card);
}
class ReshuffleEvent extends GameEvent {} // بُرِ مجدد با مصرفِ یک بلیت
/// ارسالِ پیام/شکلک (یکی از text یا emoji پر است).
class SendChatEvent extends GameEvent {
final String? text;
final String? emoji;
SendChatEvent({this.text, this.emoji});
}
/// پاک‌کردنِ حبابِ پیامِ یک جایگاه پس از انقضا (id برای جلوگیری از پاکِ حبابِ جدیدتر).
class ExpireChatEvent extends GameEvent {
final int seat;
final int id;
ExpireChatEvent(this.seat, this.id);
}
class LeaveGameEvent extends GameEvent {}
class StartTableEvent extends GameEvent {
final int hands; // تعداد دستِ انتخابی (۳/۵/۷)
StartTableEvent(this.hands);
final int stake; // شرطِ سکه‌ایِ هر بازیکن
StartTableEvent(this.hands, {this.stake = 0});
}
class SetStakeEvent extends GameEvent {
final int stake; // شرطِ سکه‌ایِ میز (تنظیمِ زنده‌ی میزبان)
SetStakeEvent(this.stake);
}
class RotateTableEvent extends GameEvent {
@@ -6,9 +6,15 @@ import '../../domain/entities/game_entities.dart';
/// یک بازیکن در اتاق انتظارِ میز خصوصی.
class LobbyPlayer {
final String name;
final String avatar; // seed آواتارِ انتخابیِ کاربر
final bool host;
final int seat; // جایگاهِ مطلق (۰..۳)
const LobbyPlayer(this.name, this.host, this.seat);
final String avatarImg; // شخصیتِ آواتارِ انتخابی (<id>.<ext>؛ خالی ⇒ تصادفی)
const LobbyPlayer(this.name, this.avatar, this.host, this.seat,
{this.avatarImg = ''});
/// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام.
String get avatarSeed => avatar.isNotEmpty ? avatar : name;
}
/// وضعیت اتاق انتظارِ میز خصوصی (دورهمی).
@@ -19,6 +25,7 @@ class TableLobby {
final int youSeat; // جایگاهِ مطلقِ خودِ کاربر (برای چیدمانِ صحنه)
final int remaining;
final bool unlimited;
final int stake; // شرطِ سکه‌ایِ هر بازیکن (۰ یعنی رایگان)
const TableLobby({
required this.code,
required this.players,
@@ -26,6 +33,7 @@ class TableLobby {
required this.youSeat,
required this.remaining,
required this.unlimited,
required this.stake,
});
factory TableLobby.fromJson(Map<String, dynamic> j) => TableLobby(
@@ -33,20 +41,32 @@ class TableLobby {
players: ((j['players'] as List?) ?? [])
.map((e) => LobbyPlayer(
(e['name'] ?? '') as String,
(e['avatar'] ?? '') as String,
(e['host'] ?? false) as bool,
(e['seat'] ?? 0) as int,
avatarImg: (e['avatar_img'] ?? '') as String,
))
.toList(),
isHost: (j['host'] ?? false) as bool,
youSeat: (j['you_seat'] ?? 0) as int,
remaining: (j['remaining'] ?? 0) as int,
unlimited: (j['unlimited'] ?? false) as bool,
stake: (j['stake'] ?? 0) as int,
);
String get sig => '$code|$isHost|$youSeat|$remaining|$unlimited|'
String get sig => '$code|$isHost|$youSeat|$remaining|$unlimited|$stake|'
'${players.map((p) => '${p.name}${p.host ? '*' : ''}@${p.seat}').join(',')}';
}
/// حبابِ پیام/شکلکِ یک بازیکن که کنارِ آواتارش نشان داده می‌شود.
/// id برای انقضای دقیق (پاک‌کردنِ همین حباب و نه حبابِ جدیدتر) به‌کار می‌رود.
class ChatBubble {
final String? text;
final String? emoji;
final int id;
const ChatBubble({this.text, this.emoji, required this.id});
}
class GameUiState extends Equatable {
final WsStatus connection;
final GameState? state;
@@ -56,6 +76,7 @@ class GameUiState extends Equatable {
final TableLobby? lobby;
final int? countdown;
final bool tableClosed;
final Map<int, ChatBubble> chatBubbles; // جایگاه ⇒ حبابِ پیامِ فعال
const GameUiState({
this.connection = WsStatus.connecting,
@@ -66,6 +87,7 @@ class GameUiState extends Equatable {
this.lobby,
this.countdown,
this.tableClosed = false,
this.chatBubbles = const {},
});
GameUiState copyWith({
@@ -77,6 +99,7 @@ class GameUiState extends Equatable {
TableLobby? lobby,
int? countdown,
bool? tableClosed,
Map<int, ChatBubble>? chatBubbles,
bool clearHandResult = false,
bool clearNotice = false,
}) =>
@@ -89,6 +112,7 @@ class GameUiState extends Equatable {
lobby: lobby ?? this.lobby,
countdown: countdown ?? this.countdown,
tableClosed: tableClosed ?? this.tableClosed,
chatBubbles: chatBubbles ?? this.chatBubbles,
);
@override
@@ -101,5 +125,8 @@ class GameUiState extends Equatable {
lobby?.sig,
countdown,
tableClosed,
chatBubbles.entries
.map((e) => '${e.key}:${e.value.id}')
.join(','),
];
}
@@ -2,20 +2,32 @@ import 'dart:async';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart';
import '../../../../core/locator/locator.dart';
import '../../../../core/network/ws_client.dart';
import '../../../../core/service/app_sounds.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/player_avatar.dart';
import '../../../shop/data/data_source/remote/frame_api_provider.dart';
import '../../../tournament/data/data_source/remote/tournament_api_provider.dart';
import '../../../tournament/domain/entities/tournament.dart';
import '../../../wallet/presentation/bloc/wallet_bloc.dart';
import '../../../wallet/presentation/bloc/wallet_event.dart';
import '../../../wallet/presentation/bloc/wallet_state.dart';
import '../../../wallet/presentation/bloc/wallet_status.dart';
import '../../domain/entities/game_entities.dart';
import '../bloc/game_bloc.dart';
import '../bloc/game_state.dart';
import '../../../chat/presentation/widgets/chat_panel.dart';
import '../widgets/confetti.dart';
import '../widgets/flame/hokm_game.dart';
import '../widgets/table_hud.dart';
part 'game_screen.parts.dart';
/// صفحه‌ی میز بازی: صحنه‌ی Flame + اوورلی‌های وضعیت.
class GameScreen extends StatefulWidget {
@@ -30,16 +42,35 @@ class _GameScreenState extends State<GameScreen> {
late final HokmGame _game;
Timer? _introTimer;
bool _introHidden = false;
int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱)
Tournament? _tournament; // تورنومنتِ فعالِ ثبت‌نام‌شده (اگر باشد) — برای نشانِ درون‌بازی
@override
void initState() {
super.initState();
final ws = context.read<WalletBloc>().state.walletStatus;
final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple';
// URL کاملِ فرش از سرور می‌آید (خالی ⇒ میزِ کلاسیک).
final carpetUrl = ws is WalletLoaded ? ws.wallet.selectedCarpetImg : '';
_game = HokmGame(
context.read<GameBloc>(),
skin: skin.isEmpty ? 'simple' : skin,
carpetUrl: carpetUrl,
);
_loadTournament();
locator<FrameApiProvider>().warmCache(); // رنگِ قاب‌ها برای HUD (best-effort)
}
/// بررسی می‌کند آیا کاربر در تورنومنتِ فعالی ثبت‌نام کرده تا نشانِ درون‌بازی و
/// امتیازِ پایانِ بازی نمایش داده شود (این بازیِ عمومی به تورنومنت امتیاز می‌دهد).
Future<void> _loadTournament() async {
try {
final list = await locator<TournamentApiProvider>().getTournaments();
final active = list.where((t) => t.joined && t.isActive).toList();
if (mounted && active.isNotEmpty) {
setState(() => _tournament = active.first);
}
} catch (_) {/* بی‌اهمیت */}
}
@override
@@ -64,13 +95,29 @@ class _GameScreenState extends State<GameScreen> {
listenWhen:
(a, b) =>
(a.notice != b.notice && b.notice != null) ||
(a.gameOver == null && b.gameOver != null),
(a.gameOver == null && b.gameOver != null) ||
(a.handResult == null && b.handResult != null),
listener: (context, state) {
// بازخوردِ لمسیِ بُردِ دست (کُت قوی‌تر) تا کاربر بی‌درنگ بفهمد بُرد.
final hr = state.handResult;
if (hr != null) {
final myTeam = (state.state?.yourSeat ?? 0) % 2;
if (hr.winnerTeam == myTeam) {
hr.kot
? HapticFeedback.heavyImpact()
: HapticFeedback.mediumImpact();
} else {
HapticFeedback.lightImpact(); // باختِ دست هم حس شود تا پایانِ دست معلوم باشد
}
}
if (state.gameOver != null) {
final won =
state.gameOver!.winnerTeam ==
(state.state?.yourSeat ?? 0) % 2;
if (won) AppSounds.win();
if (won) {
AppSounds.win();
HapticFeedback.mediumImpact();
}
}
if (state.notice != null) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -89,20 +136,42 @@ class _GameScreenState extends State<GameScreen> {
},
builder: (context, state) {
if (state.state != null && _introTimer == null) {
// کمی «حریفان پیدا شد» نشان بده، بعد اوورلی را کنار بزن و همان
// لحظه انیمیشنِ بُر زدن (با صدا) را اجرا کن.
_introTimer = Timer(const Duration(milliseconds: 1000), () {
if (!mounted) return;
setState(() => _introHidden = true);
_game.endIntro();
// حریفان پیدا شدند ⇒ شمارشِ معکوسِ ۳…۲…۱ با صدا تا کاربر بداند
// بازی در حالِ شروع است، سپس اوورلی کنار می‌رود و بُر زدن اجرا می‌شود.
_countdown = 3; // داخلِ build هستیم؛ همین build مقدار را نشان می‌دهد
AppSounds.tick();
_introTimer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) {
t.cancel();
return;
}
if (_countdown <= 1) {
t.cancel();
setState(() => _introHidden = true);
_game.endIntro();
} else {
setState(() => _countdown--);
AppSounds.tick();
}
});
}
return Stack(
children: [
// فرش به‌عنوانِ سطحِ میز داخلِ صحنه‌ی Flame (Felt) کشیده می‌شود.
GameWidget(game: _game),
_backButton(context),
if (state.connection == WsStatus.disconnected) _connBanner(),
if (_showSearch(state)) _searchPanel(state),
if (!_showSearch(state) && state.state != null)
TableHud(
s: state.state!,
connection: state.connection,
chatBubbles: state.chatBubbles,
onExit: () => _confirmLeave(context),
onChat: () => _openChat(context),
),
if (state.connection == WsStatus.disconnected) const _ConnBanner(),
if (_tournament != null && !_showSearch(state))
_TournamentBadge(title: _tournament!.title),
if (_showSearch(state))
_SearchPanel(state: state, countdown: _countdown),
// دیالوگِ انتخابِ حکم و بنرِ نوبت فقط پس از پایانِ انیمیشنِ
// پخشِ کارت نمایش داده می‌شوند (نه وسطِ بُر زدن/پخش).
if (!_showSearch(state))
@@ -110,15 +179,23 @@ class _GameScreenState extends State<GameScreen> {
valueListenable: _game.animating,
builder: (context, animating, _) {
if (animating) return const SizedBox.shrink();
return Stack(children: [
if (_showTrumpPicker(state)) _trumpPicker(context),
if (_isMyPlayTurn(state)) _turnBanner(),
]);
return Stack(
children: [
if (_showTrumpPicker(state)) const _TrumpPicker(),
if (_isMyPlayTurn(state)) const _TurnBanner(),
],
);
},
),
if (state.handResult != null && state.gameOver == null)
_handResult(state),
if (state.gameOver != null) _gameOver(context, state),
Positioned.fill(child: _HandResult(state: state)),
if (state.gameOver != null)
_GameOver(
state: state,
prize: widget.prize,
tournamentTitle: _tournament?.title,
onExit: () => _exitToLobby(context),
),
],
);
},
@@ -128,6 +205,17 @@ class _GameScreenState extends State<GameScreen> {
);
}
void _openChat(BuildContext context) {
final bloc = context.read<GameBloc>();
final wallet = context.read<WalletBloc>();
ChatPanel.show(
context,
onText: bloc.sendChatText,
onEmoji: bloc.sendChatEmoji,
onWalletChanged: () => wallet.add(LoadWalletEvent()),
);
}
bool _isMyPlayTurn(GameUiState s) =>
s.state != null &&
s.gameOver == null &&
@@ -135,48 +223,12 @@ class _GameScreenState extends State<GameScreen> {
s.state!.isMyTurn &&
!s.state!.trickDone;
// بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد.
Widget _turnBanner() => Align(
alignment: const Alignment(0, 0.80),
child: IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFE9B949), Color(0xFFB8860B)],
),
borderRadius: BorderRadius.circular(20),
boxShadow: const [BoxShadow(color: Colors.black54, blurRadius: 8)],
),
child: const Text(
'نوبت شماست',
style: TextStyle(
color: Color(0xFF3A0A12),
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
);
bool _showTrumpPicker(GameUiState s) =>
s.state != null &&
s.state!.phase == 'choose_trump' &&
s.state!.amHakem &&
s.gameOver == null;
Widget _backButton(BuildContext context) => Positioned(
top: 8,
right: 8,
child: SafeArea(
child: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.gold),
onPressed: () => _confirmLeave(context),
),
),
);
Future<void> _confirmLeave(BuildContext context) async {
if (context.read<GameBloc>().state.gameOver != null) {
_exitToLobby(context);
@@ -215,309 +267,4 @@ class _GameScreenState extends State<GameScreen> {
context.go('/lobby');
}
Widget _connBanner() => Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.orange.shade900,
child: const SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.all(8),
child: Text(
'ارتباط با سرور قطع شد، در حال تلاش برای اتصال مجدد…',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white),
),
),
),
),
);
Widget _searchPanel(GameUiState state) {
final players = state.state?.players ?? const <GamePlayer>[];
final mySeat = state.state?.yourSeat ?? -1;
final searching = state.state == null;
GamePlayer? at(int seat) {
for (final p in players) {
if (p.seat == seat) return p;
}
return null;
}
return Container(
color: Colors.black.withValues(alpha: 0.78),
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 18),
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold, width: 2.5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'جستجوی حریف',
style: TextStyle(
color: AppColors.gold,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var seat = 0; seat < 4; seat++)
_searchSlot(at(seat), seat == mySeat, seat),
],
),
if (searching) ...[
const SizedBox(height: 16),
const Text(
'در حال یافتن حریفان…',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
],
),
),
);
}
Widget _searchSlot(GamePlayer? p, bool isYou, int seat) {
final found = p != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
found ? p.name : '...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isYou ? AppColors.gold : Colors.white70,
fontSize: 12,
),
),
const SizedBox(height: 4),
Container(
width: 58,
height: 58,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: AppColors.bgDark,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isYou ? AppColors.gold : AppColors.goldDark,
width: 1.5,
),
),
child:
!found
? _SearchingAvatar(slot: seat) // آواتارهای متغیر و متفاوت
: p.bot
? const Icon(
Icons.smart_toy,
color: AppColors.gold,
size: 30,
)
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RandomAvatar(p.name),
),
),
const SizedBox(height: 2),
Text(
found ? (p.bot ? 'ربات' : (isYou ? 'شما' : 'حریف')) : '',
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
],
),
);
}
Widget _trumpPicker(BuildContext context) {
const suits = [
('spades', '', 'پیک', Colors.white),
('hearts', '', 'دل', Color(0xFFD32F2F)),
('diamonds', '', 'خشت', Color(0xFFD32F2F)),
('clubs', '', 'گشنیز', Colors.white),
];
return Container(
color: Colors.black54,
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'حکم را انتخاب کن',
style: TextStyle(color: AppColors.gold, fontSize: 20),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
for (final (id, sym, name, color) in suits)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.bgDark,
minimumSize: const Size(120, 64),
),
onPressed: () => context.read<GameBloc>().chooseTrump(id),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(sym, style: TextStyle(fontSize: 26, color: color)),
const SizedBox(width: 8),
Text(
name,
style: const TextStyle(color: AppColors.text),
),
],
),
),
],
),
],
),
),
);
}
Widget _handResult(GameUiState s) {
final r = s.handResult!;
final mySeat = s.state?.yourSeat ?? 0;
final myTeam = mySeat % 2;
final won = r.winnerTeam == myTeam;
return IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 18),
decoration: BoxDecoration(
color: Colors.black87,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
won ? 'این دست را بردید!' : 'این دست را باختید',
style: TextStyle(
color: won ? AppColors.green : Colors.redAccent,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
if (r.kot)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
r.hakemKot
? 'حاکم‌کُت! (${r.points} امتیاز)'
: 'کُت! (${r.points} امتیاز)',
style: const TextStyle(color: AppColors.gold),
),
),
const SizedBox(height: 6),
Text(
'امتیاز: ${r.scores.isNotEmpty ? r.scores[0] : 0} - ${r.scores.length > 1 ? r.scores[1] : 0}',
style: const TextStyle(color: Colors.white70),
),
],
),
),
),
);
}
Widget _gameOver(BuildContext context, GameUiState s) {
final g = s.gameOver!;
final mySeat = s.state?.yourSeat ?? 0;
final won = g.winnerTeam == mySeat % 2;
return Container(
color: Colors.black87,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
won ? 'بردید! 🎉' : 'باختید',
style: TextStyle(
color: won ? AppColors.gold : Colors.redAccent,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}',
style: const TextStyle(color: Colors.white70, fontSize: 18),
),
const SizedBox(height: 28),
SizedBox(
width: 220,
child: ElevatedButton(
onPressed: () => _exitToLobby(context),
child: const Text('بازگشت به لابی'),
),
),
],
),
);
}
}
/// آواتارِ در حالِ تغییر برای جایگاه‌های خالیِ پنل جستجو (حسِ «در حال یافتن»).
class _SearchingAvatar extends StatefulWidget {
final int slot; // هر جایگاه دامنه‌ی seedِ متفاوت دارد تا یکسان نباشند
const _SearchingAvatar({required this.slot});
@override
State<_SearchingAvatar> createState() => _SearchingAvatarState();
}
class _SearchingAvatarState extends State<_SearchingAvatar> {
Timer? _timer;
int _i = 0;
@override
void initState() {
super.initState();
_i = widget.slot * 13; // شروعِ متفاوت برای هر جایگاه
_timer = Timer.periodic(const Duration(milliseconds: 240), (_) {
if (mounted) setState(() => _i++);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// پیشوندِ مخصوصِ جایگاه ⇒ آواتارِ هر جایگاه با بقیه فرق دارد.
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RandomAvatar('s${widget.slot}-$_i'),
);
}
}
@@ -0,0 +1,774 @@
// اوورلی‌های خصوصیِ صفحه‌ی بازی (بنرها، پنلِ جستجو، انتخابِ حکم، نتیجه‌ی دست/بازی).
part of 'game_screen.dart';
/// بنرِ «نوبت شماست» وقتی کاربر باید کارت بیندازد.
class _TurnBanner extends StatelessWidget {
const _TurnBanner();
@override
Widget build(BuildContext context) => Align(
alignment: const Alignment(0, 0.3),
child: IgnorePointer(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.gold, AppColors.goldDark],
),
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(color: Colors.black54, blurRadius: 8)
],
),
child: const Text(
'نوبت شماست',
style: TextStyle(
color: AppColors.bg,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
);
}
/// بنرِ قطعِ ارتباط با سرور (بالای صفحه).
class _ConnBanner extends StatelessWidget {
const _ConnBanner();
@override
Widget build(BuildContext context) => Positioned(
top: 0,
left: 0,
right: 0,
child: Material(
color: Colors.orange.shade900,
child: const SafeArea(
bottom: false,
child: Padding(
padding: EdgeInsets.all(8),
child: Text(
'ارتباط با سرور قطع شد، در حال تلاش برای اتصال مجدد…',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white),
),
),
),
),
);
}
/// نشانِ درون‌بازیِ تورنومنت: می‌فهماند این بازیِ عمومی به تورنومنت امتیاز می‌دهد.
class _TournamentBadge extends StatelessWidget {
final String title;
const _TournamentBadge({required this.title});
@override
Widget build(BuildContext context) => Positioned(
top: 6,
left: 0,
right: 0,
child: IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF3A2A6E), AppColors.lapisInk],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold, width: 1.2),
boxShadow: [
BoxShadow(
color: AppColors.gold.withValues(alpha: 0.3),
blurRadius: 8),
],
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.emoji_events, color: AppColors.gold, size: 15),
const SizedBox(width: 5),
Text(
'تورنومنت: $title',
style: const TextStyle(
color: AppColors.gold,
fontSize: 12,
fontWeight: FontWeight.bold),
),
]),
),
),
),
);
}
/// پنلِ «جستجوی حریف»: چهار جایگاه + وضعیتِ جستجو یا شمارشِ معکوسِ شروع.
class _SearchPanel extends StatelessWidget {
final GameUiState state;
final int countdown;
const _SearchPanel({required this.state, required this.countdown});
@override
Widget build(BuildContext context) {
final players = state.state?.players ?? const <GamePlayer>[];
final mySeat = state.state?.yourSeat ?? -1;
final searching = state.state == null;
GamePlayer? at(int seat) {
for (final p in players) {
if (p.seat == seat) return p;
}
return null;
}
return Container(
color: Colors.black.withValues(alpha: 0.78),
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.fromLTRB(16, 14, 16, 18),
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.gold, width: 2.5),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'جستجوی حریف',
style: TextStyle(
color: AppColors.gold,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var seat = 0; seat < 4; seat++)
_SearchSlot(
player: at(seat),
isYou: seat == mySeat,
seat: seat,
),
],
),
if (searching) ...[
const SizedBox(height: 16),
const Text(
'در حال یافتن حریفان…',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
] else if (countdown > 0) ...[
const SizedBox(height: 14),
const Text(
'شروع بازی',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(height: 6),
TweenAnimationBuilder<double>(
// هر عدد با یک «پاپ» ظاهر می‌شود تا واضح و جلب‌توجه باشد.
key: ValueKey(countdown),
tween: Tween(begin: 1.6, end: 1.0),
duration: const Duration(milliseconds: 400),
curve: Curves.easeOut,
builder: (_, scale, child) =>
Transform.scale(scale: scale, child: child),
child: Container(
width: 54,
height: 54,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [AppColors.gold, AppColors.goldDark],
),
border: Border.all(color: Colors.white, width: 2),
),
child: Text(
'$countdown',
style: const TextStyle(
color: AppColors.bg,
fontSize: 26,
fontWeight: FontWeight.bold,
),
),
),
),
],
],
),
),
);
}
}
/// یک جایگاه در پنلِ جستجو (نام، آواتار/آیکن، برچسبِ نقش).
class _SearchSlot extends StatelessWidget {
final GamePlayer? player;
final bool isYou;
final int seat;
const _SearchSlot({
required this.player,
required this.isYou,
required this.seat,
});
@override
Widget build(BuildContext context) {
final p = player;
final found = p != null;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
found ? p.name : '...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isYou ? AppColors.gold : Colors.white70,
fontSize: 12,
),
),
const SizedBox(height: 4),
Container(
width: 58,
height: 58,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: AppColors.bgDark,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isYou ? AppColors.gold : AppColors.goldDark,
width: 1.5,
),
),
child: !found
? _SearchingAvatar(slot: seat) // آواتارهای متغیر و متفاوت
: p.bot
? const Icon(
Icons.smart_toy,
color: AppColors.gold,
size: 30,
)
: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: PlayerAvatar(
seed: p.avatarSeed,
imageUrl: p.avatarImg,
),
),
),
const SizedBox(height: 2),
Text(
found ? (p.bot ? 'ربات' : (isYou ? 'شما' : 'حریف')) : '',
style: const TextStyle(color: Colors.white38, fontSize: 10),
),
],
),
);
}
}
/// دیالوگِ انتخابِ حکم توسطِ حاکم (+ بُرِ مجدد با مصرفِ یک بلیت).
class _TrumpPicker extends StatelessWidget {
const _TrumpPicker();
@override
Widget build(BuildContext context) {
const suits = [
('spades', '', 'پیک', Colors.white),
('hearts', '', 'دل', AppColors.suitRed),
('diamonds', '', 'خشت', AppColors.suitRed),
('clubs', '', 'گشنیز', Colors.white),
];
return Container(
color: Colors.black54,
alignment: Alignment.center,
child: Container(
margin: const EdgeInsets.all(24),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'حکم را انتخاب کن',
style: TextStyle(color: AppColors.gold, fontSize: 20),
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
for (final (id, sym, name, color) in suits)
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.bgDark,
minimumSize: const Size(120, 64),
),
onPressed: () => context.read<GameBloc>().chooseTrump(id),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(sym, style: TextStyle(fontSize: 26, color: color)),
const SizedBox(width: 8),
Text(
name,
style: const TextStyle(color: AppColors.text),
),
],
),
),
],
),
const SizedBox(height: 16),
const Divider(color: AppColors.goldFaint, height: 1),
const SizedBox(height: 12),
// بُرِ مجدد با مصرفِ یک بلیت (۵ کارتِ تازه، سپس باز هم انتخابِ حکم).
BlocBuilder<WalletBloc, WalletBlocState>(
builder: (context, ws) {
final tickets = ws.walletStatus is WalletLoaded
? (ws.walletStatus as WalletLoaded).wallet.tickets
: 0;
final canReshuffle = tickets > 0;
return Column(
children: [
ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8E1B7A),
disabledBackgroundColor: const Color(0xFF3A2233),
minimumSize: const Size(220, 48),
),
onPressed: canReshuffle
? () {
AppSounds.button();
context.read<GameBloc>().reshuffle();
// پس از مصرفِ بلیت، موجودی را تازه کن.
final wallet = context.read<WalletBloc>();
Future.delayed(const Duration(milliseconds: 500),
() => wallet.add(LoadWalletEvent()));
}
: null,
icon: const Icon(Icons.casino, color: Colors.white),
label: const Text(
'بُر مجدد (۱ بلیت)',
style: TextStyle(
color: Colors.white, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 4),
Text(
canReshuffle ? 'بلیت‌های شما: $tickets' : 'بلیت کافی ندارید',
style:
const TextStyle(color: Colors.white60, fontSize: 12),
),
],
);
},
),
],
),
),
);
}
}
/// نتیجه‌ی یک دست با بازخوردِ فوری: آیکنِ بزرگِ رنگی (جام/ضربدر)، واژه‌ی بزرگِ
/// سبز/قرمز، واشِ رنگیِ لبه‌های صفحه و «پاپِ» ورودی تا کاربر بی‌درنگ بفهمد بُرد یا
/// باخت. کُتِ بُرده جشنِ ویژه (کانفتیِ بیشتر + عنوانِ آتشین) دارد.
class _HandResult extends StatelessWidget {
final GameUiState state;
const _HandResult({required this.state});
// سبزِ بُرد / قرمزِ باخت (منبعِ رنگِ واش، حاشیه و هاله).
static const _winGreen = Color(0xFF3FA34D);
static const _loseRed = Color(0xFFE5484D);
@override
Widget build(BuildContext context) {
final r = state.handResult!;
final myTeam = (state.state?.yourSeat ?? 0) % 2;
final won = r.winnerTeam == myTeam;
final kotWin = won && r.kot; // جشنِ ویژه فقط برای کُتِ بُرده
final accent = won ? _winGreen : _loseRed;
final mine = myTeam < r.scores.length ? r.scores[myTeam] : 0;
final opp = (1 - myTeam) < r.scores.length ? r.scores[1 - myTeam] : 0;
final title = kotWin
? (r.hakemKot ? 'حاکم‌کُت! 🔥' : 'کُت! 🔥')
: (won ? 'بردید!' : 'باختید');
return IgnorePointer(
child: Stack(
children: [
// ۱) واشِ رنگیِ لبه‌ها (سبز=بُرد، قرمز=باخت) که سریع ظاهر می‌شود ⇒ درکِ آنی
// حتی با نگاهِ گوشه‌ی چشم.
Positioned.fill(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 300),
builder: (_, v, __) => DecoratedBox(
decoration: BoxDecoration(
gradient: RadialGradient(
radius: 1.15,
colors: [
Colors.transparent,
accent.withValues(alpha: 0.30 * v),
],
stops: const [0.5, 1.0],
),
),
),
),
),
// ۲) کانفتی برای هر بُرد (کُت پرحجم‌تر).
if (won) Positioned.fill(child: Confetti(count: kotWin ? 48 : 22)),
// ۳) نشانِ مرکزی با «پاپ» ورودی.
Center(
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutBack,
builder: (_, v, child) => Transform.scale(
scale: v.clamp(0.0, 1.12),
child: Opacity(opacity: v.clamp(0.0, 1.0), child: child),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// آیکنِ بزرگِ رنگی: جامِ سبز برای بُرد، ضربدرِ قرمز برای باخت.
Container(
width: 96,
height: 96,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: won
? const [Color(0xFF5FD36B), Color(0xFF1B7A2E)]
: const [Color(0xFFF06A6E), Color(0xFF8E1B1F)],
),
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: accent.withValues(alpha: 0.65),
blurRadius: 26,
spreadRadius: 2),
],
),
child: Icon(
won ? Icons.emoji_events_rounded : Icons.close_rounded,
color: Colors.white,
size: 52,
),
),
const SizedBox(height: 14),
// واژه‌ی بزرگِ نتیجه.
Text(
title,
style: TextStyle(
color: won ? AppColors.gold : const Color(0xFFF06A6E),
fontSize: 40,
fontWeight: FontWeight.bold,
shadows: const [
Shadow(color: Colors.black, blurRadius: 12)
],
),
),
if (kotWin)
const Text(
'این دست را بردید',
style: TextStyle(
color: Color(0xFF7BE58A),
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 10),
// چیپِ امتیاز/نتیجه (از دیدِ کاربر: ما · حریف).
Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: accent.withValues(alpha: 0.7)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (won) ...[
Text(
'+${r.points} امتیاز',
style: const TextStyle(
color: AppColors.gold,
fontWeight: FontWeight.bold),
),
Container(
width: 1,
height: 15,
margin: const EdgeInsets.symmetric(horizontal: 10),
color: Colors.white24,
),
],
Text(
'ما $mine · حریف $opp',
style: const TextStyle(
color: Colors.white70,
fontWeight: FontWeight.bold),
),
],
),
),
],
),
),
),
// ۴) عددِ امتیاز از پنلِ امتیاز (بالا-چپ) با پرواز به نشانِ نتیجه می‌رسد
// تا کاربر پیوندِ امتیازِ میز با نتیجه‌ی دست را ببیند — چه بُرد چه باخت.
_ScoreFly(points: r.points, won: won),
],
),
);
}
}
/// عددِ امتیازِ این دست که از پنلِ امتیاز (بالا-چپِ صفحه) به‌سمتِ نشانِ نتیجه
/// (مرکز) پرواز می‌کند و آن‌جا محو می‌شود — پیوندِ بصریِ امتیازِ میز با نتیجه.
/// بُرد: طلایی از سمتِ «ما»؛ باخت: قرمز از سمتِ «حریف».
class _ScoreFly extends StatelessWidget {
final int points;
final bool won;
const _ScoreFly({required this.points, required this.won});
@override
Widget build(BuildContext context) {
// نقطه‌ی شروع نزدیکِ عددِ مربوطه در پنلِ بالا-چپ («ما» راست‌تر، «حریف» چپ‌تر).
final start =
won ? const Alignment(-0.68, -0.92) : const Alignment(-0.9, -0.92);
final grad = won
? const [Color(0xFFFFE082), AppColors.goldDark]
: const [Color(0xFFF06A6E), Color(0xFF8E1B1F)];
final textColor = won ? AppColors.bg : Colors.white;
final glow = won ? AppColors.gold : const Color(0xFFF06A6E);
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 850),
curve: Curves.easeInOut,
builder: (context, t, child) {
// مسیرِ حرکت: از عددِ پنل تا کمی بالای نشانِ نتیجه.
final align = Alignment.lerp(start, const Alignment(0, -0.22), t)!;
// نزدیکِ مقصد بزرگ‌تر می‌شود و در انتها محو می‌شود (به نشان می‌پیوندد).
final scale = 0.85 + 0.6 * t;
final fade = t < 0.82 ? 1.0 : (1 - (t - 0.82) / 0.18);
return Align(
alignment: align,
child: Opacity(
opacity: fade.clamp(0.0, 1.0),
child: Transform.scale(scale: scale, child: child),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
decoration: BoxDecoration(
gradient: LinearGradient(colors: grad),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: Colors.white, width: 1.5),
boxShadow: [
BoxShadow(color: glow.withValues(alpha: 0.75), blurRadius: 16),
],
),
child: Text(
'+$points',
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
fontSize: 21,
),
),
),
);
}
}
/// اوورلیِ پایانِ بازی (مدال، نتیجه، پاداش‌ها، بازگشت به لابی).
class _GameOver extends StatelessWidget {
final GameUiState state;
final int prize;
final String? tournamentTitle;
final VoidCallback onExit;
const _GameOver({
required this.state,
required this.prize,
required this.tournamentTitle,
required this.onExit,
});
@override
Widget build(BuildContext context) {
final g = state.gameOver!;
final mySeat = state.state?.yourSeat ?? 0;
final won = g.winnerTeam == mySeat % 2;
return Stack(
children: [
const Positioned.fill(child: ColoredBox(color: Colors.black87)),
if (won) const Positioned.fill(child: Confetti()),
Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// مدالِ متحرک (تاجِ طلایی برای برد، نشانِ ملایم برای باخت).
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 650),
curve: Curves.easeOutBack,
builder: (_, v, __) => Transform.scale(
scale: v.clamp(0.0, 1.2),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: won
? const [AppColors.gold, AppColors.goldDark]
: [Colors.grey.shade700, Colors.grey.shade900],
),
border: Border.all(
color: won ? Colors.white70 : Colors.white24,
width: 2),
boxShadow: won
? [
BoxShadow(
color:
AppColors.gold.withValues(alpha: 0.55),
blurRadius: 28),
]
: null,
),
child: Icon(
won
? Icons.emoji_events
: Icons.sentiment_dissatisfied_outlined,
color: won ? AppColors.lapisInk : Colors.white54,
size: 54,
),
),
),
),
const SizedBox(height: 16),
Text(
won ? 'بردید! 🎉' : 'باختید',
style: TextStyle(
color: won ? AppColors.gold : Colors.redAccent,
fontSize: 34,
fontWeight: FontWeight.bold,
shadows: const [Shadow(color: Colors.black, blurRadius: 8)],
),
),
const SizedBox(height: 10),
Text(
'نتیجه نهایی: ${g.scores.isNotEmpty ? g.scores[0] : 0} - ${g.scores.length > 1 ? g.scores[1] : 0}',
style: const TextStyle(color: Colors.white70, fontSize: 18),
),
// پاداشِ سکه‌ی برد (جایزه‌ی میز).
if (won && prize > 0) ...[
const SizedBox(height: 16),
_RewardChip(
icon: Icons.monetization_on, text: '+$prize سکه'),
],
// امتیازِ کسب‌شده‌ی تورنومنت (برد ۱۰۰، شرکت ۲۵).
if (tournamentTitle != null) ...[
const SizedBox(height: 12),
_RewardChip(
icon: Icons.emoji_events,
text:
'+${won ? 100 : 25} امتیاز تورنومنت «$tournamentTitle»'),
],
const SizedBox(height: 30),
SizedBox(
width: 220,
child: ElevatedButton(
onPressed: onExit,
child: const Text('بازگشت به لابی'),
),
),
],
),
),
),
],
);
}
}
/// چیپِ پاداش (آیکن + متن) در اوورلیِ پایانِ بازی.
class _RewardChip extends StatelessWidget {
final IconData icon;
final String text;
const _RewardChip({required this.icon, required this.text});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppColors.gold.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.gold),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, color: AppColors.gold, size: 20),
const SizedBox(width: 8),
Text(text,
style: const TextStyle(
color: AppColors.gold,
fontSize: 14,
fontWeight: FontWeight.bold)),
]),
);
}
/// آواتارِ در حالِ تغییر برای جایگاه‌های خالیِ پنل جستجو (حسِ «در حال یافتن»).
class _SearchingAvatar extends StatefulWidget {
final int slot; // هر جایگاه دامنه‌ی seedِ متفاوت دارد تا یکسان نباشند
const _SearchingAvatar({required this.slot});
@override
State<_SearchingAvatar> createState() => _SearchingAvatarState();
}
class _SearchingAvatarState extends State<_SearchingAvatar> {
Timer? _timer;
int _i = 0;
@override
void initState() {
super.initState();
_i = widget.slot * 13; // شروعِ متفاوت برای هر جایگاه
_timer = Timer.periodic(const Duration(milliseconds: 240), (_) {
if (mounted) setState(() => _i++);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
// پیشوندِ مخصوصِ جایگاه ⇒ آواتارِ هر جایگاه با بقیه فرق دارد.
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: RandomAvatar('s${widget.slot}-$_i'),
);
}
}
@@ -65,25 +65,11 @@ class _PrivateEntryScreenState extends State<PrivateEntryScreen> {
final canCreate = unlimited || remaining > 0;
return Column(
children: [
Align(
const Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.all(10),
child: GestureDetector(
onTap: () => context.pop(),
child: Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(12),
border:
Border.all(color: AppColors.gold, width: 1.5),
),
child: const Icon(Icons.arrow_back,
color: AppColors.gold),
),
),
padding: EdgeInsets.all(10),
child: AppBackButton(),
),
),
Expanded(
@@ -4,15 +4,17 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:random_avatar/random_avatar.dart';
import '../../../../core/network/ws_client.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/widgets/game_ui.dart';
import '../../../../core/widgets/player_avatar.dart';
import '../bloc/game_bloc.dart';
import '../bloc/game_state.dart';
import 'game_screen.dart';
part 'private_table_screen.parts.dart';
/// میز خصوصی: اتاق انتظار (کد، بازیکنان، شروع) سپس صحنه‌ی بازی (روی همان اتصال).
class PrivateTableScreen extends StatelessWidget {
const PrivateTableScreen({super.key});
@@ -20,18 +22,22 @@ class PrivateTableScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocConsumer<GameBloc, GameUiState>(
listenWhen: (a, b) =>
(a.notice != b.notice && b.notice != null) ||
(!a.tableClosed && b.tableClosed),
listenWhen:
(a, b) =>
(a.notice != b.notice && b.notice != null) ||
(!a.tableClosed && b.tableClosed),
listener: (context, state) {
if (state.tableClosed) {
if (context.canPop()) context.pop();
return;
}
if (state.notice != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.notice!),
duration: const Duration(seconds: 2)));
duration: const Duration(seconds: 2),
),
);
context.read<GameBloc>().clearNotice();
}
},
@@ -44,343 +50,3 @@ class PrivateTableScreen extends StatelessWidget {
);
}
}
class _LobbyView extends StatefulWidget {
final GameUiState state;
const _LobbyView({required this.state});
@override
State<_LobbyView> createState() => _LobbyViewState();
}
class _LobbyViewState extends State<_LobbyView> {
int _hands = 3; // تعداد دستِ انتخابیِ میزبان
void _leave() {
context.read<GameBloc>().leaveTable();
if (context.canPop()) context.pop();
}
@override
Widget build(BuildContext context) {
final state = widget.state;
final lobby = state.lobby;
final connecting = state.connection != WsStatus.connected || lobby == null;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _leave();
},
child: Scaffold(
body: GameBackground(
child: SafeArea(
child: Stack(
children: [
Column(
children: [
_topBar(context, lobby),
Expanded(
child: connecting
? const Center(
child: CircularProgressIndicator(
color: AppColors.gold))
: _content(context, lobby),
),
],
),
if (state.countdown != null)
_CountdownOverlay(seconds: state.countdown!),
],
),
),
),
),
);
}
// نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست).
Widget _topBar(BuildContext context, TableLobby? lobby) {
Widget btn(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.gold, width: 1.5),
),
child: Icon(icon, color: AppColors.gold),
),
);
return Padding(
padding: const EdgeInsets.all(10),
child: Directionality(
textDirection: TextDirection.ltr,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
btn(Icons.arrow_back, _leave),
if (lobby != null)
btn(Icons.share, () {
Clipboard.setData(ClipboardData(text: lobby.code));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('کد میز کپی شد؛ برای دوستانت بفرست')));
}),
],
),
),
);
}
Widget _content(BuildContext context, TableLobby lobby) {
final youSeat = lobby.youSeat;
// نگاشتِ جایگاهِ مطلق → بازیکن (خالی = بات هنگام شروع).
final bySeat = <int, LobbyPlayer>{for (final p in lobby.players) p.seat: p};
// چیدمان از نگاهِ خودِ کاربر: «شما» پایین، هم‌تیمی (روبه‌رو) بالا، بقیه کنار.
LobbyPlayer? seatAt(int rel) => bySeat[(youSeat + rel) % 4];
final me = seatAt(0);
final top = seatAt(2); // هم‌تیمی (روبه‌رو)
final left = seatAt(1);
final right = seatAt(3);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
const Text('شماره میز', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 4),
GlowText(lobby.code, size: 36),
const SizedBox(height: 8),
// میز با چهار جایگاه.
SizedBox(
height: 340,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 28, vertical: 36),
child: Container(
decoration: BoxDecoration(
gradient: const RadialGradient(
colors: [Color(0xFF1E7E3A), Color(0xFF0C5022)],
radius: 0.9,
),
borderRadius: BorderRadius.circular(160),
border: Border.all(color: const Color(0xFF8A5A12), width: 6),
),
child: const Center(
child: GlowText('سلطان حکم', size: 18),
),
),
),
Align(alignment: Alignment.topCenter, child: _seat(top, false)),
Align(alignment: Alignment.centerLeft, child: _seat(left, false)),
Align(
alignment: Alignment.centerRight, child: _seat(right, false)),
Align(alignment: Alignment.bottomCenter, child: _seat(me, true)),
// چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی.
if (lobby.isHost) ...[
Align(
alignment: const Alignment(-1, -0.35),
child: _rotateBtn(Icons.rotate_left,
() => context.read<GameBloc>().rotateTable(-1)),
),
Align(
alignment: const Alignment(1, -0.35),
child: _rotateBtn(Icons.rotate_right,
() => context.read<GameBloc>().rotateTable(1)),
),
],
],
),
),
const SizedBox(height: 8),
const Text('تعداد دست', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final h in [3, 5, 7]) _handChip(h, lobby.isHost),
],
),
const SizedBox(height: 20),
if (lobby.isHost)
GameButton(
label: 'شروع بازی',
icon: Icons.play_arrow,
width: double.infinity,
colors: const [Color(0xFF3FA34D), Color(0xFF1B5E20)],
onTap: () => context.read<GameBloc>().startTable(_hands),
)
else
const Text('در انتظار شروع توسط میزبان…',
style: TextStyle(color: AppColors.gold, fontSize: 15)),
const SizedBox(height: 6),
if (lobby.isHost)
const Text('جای‌های خالی با ربات پر می‌شوند',
style: TextStyle(color: Colors.white54, fontSize: 12)),
const SizedBox(height: 20),
],
),
);
}
// یک جایگاهِ بازیکن دورِ میز (آواتار + ستاره + نام).
Widget _seat(LobbyPlayer? p, bool isYou) {
final empty = p == null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 62,
height: 62,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: AppColors.bgDark,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isYou ? AppColors.gold : AppColors.goldDark,
width: 2),
),
child: empty
? const Icon(Icons.person_add_alt_1, color: Colors.white24)
: ClipRRect(
borderRadius: BorderRadius.circular(9),
child: RandomAvatar(p.name),
),
),
if (!empty)
const Positioned(
bottom: -6,
left: -4,
child: Icon(Icons.star, color: AppColors.gold, size: 22),
),
],
),
const SizedBox(height: 6),
Text(
empty ? 'در انتظار' : (isYou ? 'شما' : p.name),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: empty ? Colors.white38 : Colors.white,
fontSize: 12,
fontWeight: empty ? FontWeight.normal : FontWeight.bold,
),
),
if (p?.host == true)
const Text('میزبان',
style: TextStyle(color: AppColors.gold, fontSize: 10)),
],
);
}
// دکمه‌ی چرخشِ جایگاه‌ها (کنارِ میز).
Widget _rotateBtn(IconData icon, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF8E1B2A), Color(0xFF5A0E18)],
),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.gold, width: 1.5),
),
child: Icon(icon, color: AppColors.gold, size: 26),
),
);
}
Widget _handChip(int h, bool editable) {
const fa = {3: '۳', 5: '۵', 7: '۷'};
final selected = _hands == h;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: GestureDetector(
onTap: editable ? () => setState(() => _hands = h) : null,
child: Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: selected
? const LinearGradient(
colors: [Color(0xFFE9B949), Color(0xFFB8860B)])
: null,
color: selected ? null : AppColors.panel,
border: Border.all(
color: selected ? AppColors.gold : AppColors.goldDark,
width: 2),
),
child: Text(
fa[h]!,
style: TextStyle(
color: selected ? const Color(0xFF3A0A12) : Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
}
/// اوورلی شمارش معکوس ۳، ۲، ۱ پیش از شروع بازی.
class _CountdownOverlay extends StatefulWidget {
final int seconds;
const _CountdownOverlay({required this.seconds});
@override
State<_CountdownOverlay> createState() => _CountdownOverlayState();
}
class _CountdownOverlayState extends State<_CountdownOverlay> {
late int _n;
Timer? _timer;
@override
void initState() {
super.initState();
_n = widget.seconds;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _n--);
if (_n <= 0) _timer?.cancel();
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withValues(alpha: 0.7),
alignment: Alignment.center,
child: TweenAnimationBuilder<double>(
key: ValueKey(_n),
tween: Tween(begin: 0.4, end: 1.2),
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
builder: (context, scale, child) =>
Transform.scale(scale: scale, child: child),
child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96),
),
);
}
}
@@ -0,0 +1,436 @@
// نمای لابیِ میزِ خصوصی و اوورلیِ شمارشِ معکوس.
part of 'private_table_screen.dart';
class _LobbyView extends StatefulWidget {
final GameUiState state;
const _LobbyView({required this.state});
@override
State<_LobbyView> createState() => _LobbyViewState();
}
class _LobbyViewState extends State<_LobbyView> {
int _hands = 3; // تعداد دستِ انتخابیِ میزبان
void _leave() {
context.read<GameBloc>().leaveTable();
if (context.canPop()) context.pop();
}
@override
Widget build(BuildContext context) {
final state = widget.state;
final lobby = state.lobby;
final connecting = state.connection != WsStatus.connected || lobby == null;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _leave();
},
child: Scaffold(
body: GameBackground(
child: SafeArea(
child: Stack(
children: [
Column(
children: [
_topBar(context, lobby),
Expanded(
child:
connecting
? const Center(
child: CircularProgressIndicator(
color: AppColors.gold,
),
)
: _content(context, lobby),
),
],
),
if (state.countdown != null)
_CountdownOverlay(seconds: state.countdown!),
],
),
),
),
),
);
}
// نوار بالا: بازگشت (چپ) و اشتراک‌گذاریِ کد (راست).
Widget _topBar(BuildContext context, TableLobby? lobby) {
Widget btn(IconData icon, VoidCallback onTap) => GestureDetector(
onTap: onTap,
child: Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.gold, width: 1.5),
),
child: Icon(icon, color: AppColors.gold),
),
);
return Padding(
padding: const EdgeInsets.all(10),
child: Directionality(
textDirection: TextDirection.ltr,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (lobby != null)
btn(Icons.share, () {
Clipboard.setData(ClipboardData(text: lobby.code));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('کد میز کپی شد؛ برای دوستانت بفرست'),
),
);
}),
btn(Icons.arrow_forward, _leave),
],
),
),
);
}
Widget _content(BuildContext context, TableLobby lobby) {
final youSeat = lobby.youSeat;
// نگاشتِ جایگاهِ مطلق → بازیکن (خالی = بات هنگام شروع).
final bySeat = <int, LobbyPlayer>{for (final p in lobby.players) p.seat: p};
// چیدمان از نگاهِ خودِ کاربر: «شما» پایین، هم‌تیمی (روبه‌رو) بالا، بقیه کنار.
LobbyPlayer? seatAt(int rel) => bySeat[(youSeat + rel) % 4];
final me = seatAt(0);
final top = seatAt(2); // هم‌تیمی (روبه‌رو)
final left = seatAt(1);
final right = seatAt(3);
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
const Text('شماره میز', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 4),
GlowText(lobby.code, size: 36),
const SizedBox(height: 8),
// میز با چهار جایگاه.
SizedBox(
height: 340,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 36,
),
child: Container(
decoration: BoxDecoration(
gradient: const RadialGradient(
colors: [AppColors.lapisHi, AppColors.lapisLo],
radius: 0.9,
),
borderRadius: BorderRadius.circular(160),
border: Border.all(
color: const Color(0xFF8A5A12),
width: 6,
),
),
child: const Center(child: GlowText('سلطان حکم', size: 18)),
),
),
Align(alignment: Alignment.topCenter, child: _seat(top, false)),
Align(
alignment: Alignment.centerLeft,
child: _seat(left, false),
),
Align(
alignment: Alignment.centerRight,
child: _seat(right, false),
),
Align(
alignment: Alignment.bottomCenter,
child: _seat(me, true),
),
// چرخشِ جایگاه‌ها (فقط میزبان) — تغییرِ تیم‌بندی.
if (lobby.isHost) ...[
Align(
alignment: const Alignment(-1, -0.35),
child: _rotateBtn(
Icons.rotate_left,
() => context.read<GameBloc>().rotateTable(-1),
),
),
Align(
alignment: const Alignment(1, -0.35),
child: _rotateBtn(
Icons.rotate_right,
() => context.read<GameBloc>().rotateTable(1),
),
),
],
],
),
),
const SizedBox(height: 8),
const Text('تعداد دست', style: TextStyle(color: Colors.white70)),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (final h in [3, 5, 7]) _handChip(h, lobby.isHost),
],
),
const SizedBox(height: 14),
const Text('ورودی (سکه) — برنده کل را می‌برد',
style: TextStyle(color: Colors.white70)),
const SizedBox(height: 10),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
for (final v in const [0, 500, 1000, 5000, 20000])
_stakeChip(v, lobby.isHost, lobby.stake),
],
),
const SizedBox(height: 20),
if (lobby.isHost)
GameButton(
label: lobby.stake > 0
? 'شروع بازی (${lobby.stake} سکه)'
: 'شروع بازی',
icon: Icons.play_arrow,
width: double.infinity,
colors: const [AppColors.success, AppColors.successDark],
onTap: () =>
context.read<GameBloc>().startTable(_hands, stake: lobby.stake),
)
else
const Text(
'در انتظار شروع توسط میزبان…',
style: TextStyle(color: AppColors.gold, fontSize: 15),
),
const SizedBox(height: 6),
if (lobby.isHost)
const Text(
'جای‌های خالی با ربات پر می‌شوند',
style: TextStyle(color: Colors.white54, fontSize: 12),
),
const SizedBox(height: 20),
],
),
);
}
// یک جایگاهِ بازیکن دورِ میز (آواتار + ستاره + نام).
Widget _seat(LobbyPlayer? p, bool isYou) {
final empty = p == null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 62,
height: 62,
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: AppColors.bgDark,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isYou ? AppColors.gold : AppColors.goldDark,
width: 2,
),
),
child:
empty
? const Icon(
Icons.person_add_alt_1,
color: Colors.white24,
)
: ClipRRect(
borderRadius: BorderRadius.circular(9),
child: PlayerAvatar(
seed: p.avatarSeed,
imageUrl: p.avatarImg,
),
),
),
if (!empty)
const Positioned(
bottom: -6,
left: -4,
child: Icon(Icons.star, color: AppColors.gold, size: 22),
),
],
),
const SizedBox(height: 6),
Text(
empty ? 'در انتظار' : (isYou ? 'شما' : p.name),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: empty ? Colors.white38 : Colors.white,
fontSize: 12,
fontWeight: empty ? FontWeight.normal : FontWeight.bold,
),
),
if (p?.host == true)
const Text(
'میزبان',
style: TextStyle(color: AppColors.gold, fontSize: 10),
),
],
);
}
// دکمه‌ی چرخشِ جایگاه‌ها (کنارِ میز).
Widget _rotateBtn(IconData icon, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.lapisMid, AppColors.lapisLo],
),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.gold, width: 1.5),
),
child: Icon(icon, color: AppColors.gold, size: 26),
),
);
}
Widget _handChip(int h, bool editable) {
const fa = {3: '۳', 5: '۵', 7: '۷'};
final selected = _hands == h;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: GestureDetector(
onTap: editable ? () => setState(() => _hands = h) : null,
child: Container(
width: 52,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient:
selected
? const LinearGradient(
colors: [AppColors.gold, AppColors.goldDark],
)
: null,
color: selected ? null : AppColors.panel,
border: Border.all(
color: selected ? AppColors.gold : AppColors.goldDark,
width: 2,
),
),
child: Text(
fa[h]!,
style: TextStyle(
color: selected ? AppColors.bg : Colors.white,
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
// چیپِ شرطِ سکه؛ میزبان با انتخاب، setStake را زنده برای همه پخش می‌کند.
Widget _stakeChip(int value, bool editable, int current) {
final selected = current == value;
final label = value == 0 ? 'رایگان' : '$value';
return GestureDetector(
onTap: editable ? () => context.read<GameBloc>().setStake(value) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: selected
? const LinearGradient(colors: [AppColors.gold, AppColors.goldDark])
: null,
color: selected ? null : AppColors.panel,
border: Border.all(
color: selected ? AppColors.gold : AppColors.goldDark,
width: 1.6,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (value > 0)
Icon(Icons.monetization_on,
size: 16, color: selected ? AppColors.bg : AppColors.gold),
if (value > 0) const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: selected ? AppColors.bg : Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}
}
/// اوورلی شمارش معکوس ۳، ۲، ۱ پیش از شروع بازی.
class _CountdownOverlay extends StatefulWidget {
final int seconds;
const _CountdownOverlay({required this.seconds});
@override
State<_CountdownOverlay> createState() => _CountdownOverlayState();
}
class _CountdownOverlayState extends State<_CountdownOverlay> {
late int _n;
Timer? _timer;
@override
void initState() {
super.initState();
_n = widget.seconds;
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() => _n--);
if (_n <= 0) _timer?.cancel();
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black.withValues(alpha: 0.7),
alignment: Alignment.center,
child: TweenAnimationBuilder<double>(
key: ValueKey(_n),
tween: Tween(begin: 0.4, end: 1.2),
duration: const Duration(milliseconds: 700),
curve: Curves.easeOut,
builder:
(context, scale, child) =>
Transform.scale(scale: scale, child: child),
child: GlowText(_n > 0 ? '$_n' : 'شروع!', size: 96),
),
);
}
}
@@ -20,7 +20,7 @@ class TierListScreen extends StatelessWidget {
Padding(
padding: const EdgeInsets.all(12),
child: Row(children: [
_BackButton(onTap: () => context.pop()),
const AppBackButton(),
const Spacer(),
const GlowText('انتخاب میز', size: 26),
const Spacer(),
@@ -50,9 +50,36 @@ class TierListScreen extends StatelessWidget {
final tiers = state.tiers;
return ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
itemCount: tiers.length,
itemCount: tiers.length + 1,
separatorBuilder: (_, __) => const SizedBox(height: 14),
itemBuilder: (_, i) => _TierCard(tier: tiers[i], index: i),
itemBuilder: (_, i) {
if (i == 0) {
return Container(
margin: const EdgeInsets.only(bottom: 2),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: AppColors.gold.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.goldFaint),
),
child: const Row(children: [
Icon(Icons.military_tech,
color: AppColors.gold, size: 18),
SizedBox(width: 8),
Expanded(
child: Text(
'با بردن در هر میز، امتیازِ رتبه می‌گیرید و در جدولِ قهرمانان بالا می‌روید.',
style: TextStyle(
color: Colors.white, fontSize: 12.5),
),
),
]),
);
}
final t = tiers[i - 1];
return _TierCard(tier: t, index: i - 1);
},
);
},
),
@@ -64,32 +91,13 @@ class TierListScreen extends StatelessWidget {
}
}
class _BackButton extends StatelessWidget {
final VoidCallback onTap;
const _BackButton({required this.onTap});
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.panel,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.gold, width: 1.5),
),
child: const Icon(Icons.arrow_back, color: AppColors.gold),
),
);
}
class _TierCard extends StatelessWidget {
final TableTier tier;
final int index;
const _TierCard({required this.tier, required this.index});
static const _palettes = [
[Color(0xFF43A047), Color(0xFF1B5E20)],
[Color(0xFF43A047), AppColors.successDark],
[Color(0xFFE53935), Color(0xFF8E0E1B)],
[Color(0xFF1E88E5), Color(0xFF0D3C73)],
[Color(0xFF8E24AA), Color(0xFF4A0D5E)],
@@ -155,6 +163,10 @@ class _TierCard extends StatelessWidget {
_badge(Icons.star, 'XP ${tier.xp}'),
const SizedBox(height: 6),
_badge(Icons.emoji_events, '${tier.trophy}'),
if (tier.rankReward > 0) ...[
const SizedBox(height: 6),
_badge(Icons.military_tech, '+${tier.rankReward} رتبه'),
],
]),
],
),
@@ -0,0 +1,119 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// بارشِ کاغذرنگی (کانفتی) سبک و بدونِ وابستگی — برای صفحه‌ی بردِ بازی.
class Confetti extends StatefulWidget {
final int count;
const Confetti({super.key, this.count = 60});
@override
State<Confetti> createState() => _ConfettiState();
}
class _ConfettiState extends State<Confetti> with SingleTickerProviderStateMixin {
late final AnimationController _c;
late final List<_Piece> _pieces;
static const _colors = [
Color(0xFFE9B949), // طلایی
Color(0xFF1E5FA8), // لاجوردی
Color(0xFFB81D2A), // شنگرف
Color(0xFF3FA34D), // سبز
Color(0xFFFFFFFF),
];
@override
void initState() {
super.initState();
final r = math.Random();
_pieces = List.generate(widget.count, (_) {
return _Piece(
x: r.nextDouble(),
delay: r.nextDouble() * 0.5,
speed: 0.7 + r.nextDouble() * 0.6,
drift: (r.nextDouble() - 0.5) * 0.4,
size: 6 + r.nextDouble() * 7,
color: _colors[r.nextInt(_colors.length)],
rot: r.nextDouble() * math.pi,
rotSpeed: (r.nextDouble() - 0.5) * 12,
round: r.nextBool(),
);
});
_c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2600),
)..forward();
}
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: AnimatedBuilder(
animation: _c,
builder: (_, __) => CustomPaint(
size: Size.infinite,
painter: _ConfettiPainter(_pieces, _c.value),
),
),
);
}
}
class _Piece {
final double x, delay, speed, drift, size, rot, rotSpeed;
final Color color;
final bool round;
_Piece({
required this.x,
required this.delay,
required this.speed,
required this.drift,
required this.size,
required this.color,
required this.rot,
required this.rotSpeed,
required this.round,
});
}
class _ConfettiPainter extends CustomPainter {
final List<_Piece> pieces;
final double t; // ۰..۱
_ConfettiPainter(this.pieces, this.t);
@override
void paint(Canvas canvas, Size size) {
for (final p in pieces) {
final local = ((t - p.delay) * p.speed).clamp(0.0, 1.0);
if (local <= 0) continue;
final y = -0.1 + local * 1.25;
final x = (p.x + p.drift * local) * size.width;
final py = y * size.height;
final opacity = (1.0 - local).clamp(0.0, 1.0);
final paint = Paint()..color = p.color.withValues(alpha: opacity);
canvas.save();
canvas.translate(x, py);
canvas.rotate(p.rot + p.rotSpeed * local);
if (p.round) {
canvas.drawCircle(Offset.zero, p.size / 2, paint);
} else {
canvas.drawRect(
Rect.fromCenter(center: Offset.zero, width: p.size, height: p.size * 0.6),
paint,
);
}
canvas.restore();
}
}
@override
bool shouldRepaint(covariant _ConfettiPainter old) => old.t != t;
}
@@ -0,0 +1,57 @@
import 'package:flutter/foundation.dart';
/// دروازه‌ی انیمیشنِ صحنه: نگه‌دارِ سه فازِ «مشغولِ انیمیشن» که در آن‌ها بازیِ کارت
/// قفل است و کارت‌ها/پشت‌کارت‌ها ممکن است پنهان شوند — اینترو («جستجوی حریف»)،
/// بُر زدن، و پخشِ بقیه‌ی کارت‌ها. یک [animating] هم برای HUD/دیالوگِ حکم می‌دهد.
class AnimGate {
bool _intro = true;
bool _pendingShuffle = false; // بُرِ دستِ اول که تا پایانِ اینترو معوق مانده
bool _shuffling = false;
bool _dealing = false;
/// آیا انیمیشنی در جریان است (صفحه از این برای پنهان‌کردنِ دیالوگِ حکم استفاده می‌کند).
final ValueNotifier<bool> animating = ValueNotifier(true);
bool get introActive => _intro;
bool get shuffling => _shuffling;
bool get dealing => _dealing;
/// در جریانِ هر انیمیشنِ قفل‌کننده (بازیِ کارت ممنوع، صدا خاموش).
bool get busy => _intro || _shuffling || _dealing;
/// در اینترو یا بُر زدن — کارت‌های دست/پشت‌کارت‌ها پنهان‌اند (انیمیشن خودش پخش می‌کند).
bool get cardsHidden => _intro || _shuffling;
void _refresh() => animating.value = busy;
void markPendingShuffle() => _pendingShuffle = true;
/// پایانِ اینترو؛ true اگر بُرِ معوقِ دستِ اول باید اکنون اجرا شود.
bool endIntro() {
_intro = false;
final run = _pendingShuffle;
_pendingShuffle = false;
_refresh();
return run;
}
void beginShuffle() {
_shuffling = true;
_refresh();
}
void endShuffle() {
_shuffling = false;
_refresh();
}
void beginDeal() {
_dealing = true;
_refresh();
}
void endDeal() {
_dealing = false;
_refresh();
}
}
@@ -4,8 +4,10 @@ import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
const _gold = Color(0xFFE9B949);
const _goldDark = Color(0xFF8A5A00);
import '../../../../../core/theme/app_theme.dart';
const _gold = AppColors.gold;
const _goldDark = Color(0xFF8A5A00); // طلاییِ تیره‌ترِ مخصوصِ نشان‌ها
/// ستاره‌ی طلایی روی بازیکنِ نوبت‌دار (مطابقِ تصویرِ مرجع).
class StarBadge extends PositionComponent {
@@ -2,8 +2,10 @@ import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import '../../../../../core/network/card_images.dart';
import '../../../../../core/theme/app_theme.dart';
/// یک کارت روی میز؛ اگر تصویر `assets/images/cards/<code>.png` موجود باشد از آن
/// استفاده می‌کند، وگرنه نسخه‌ی برداری می‌کشد. برای پشت کارت `back.jpg`.
@@ -16,6 +18,8 @@ class CardComponent extends PositionComponent
VoidCallback? onPlay; // در صورت مجاز بودن، بازیِ این کارت
bool dimmed; // کارت غیرمجاز/غیرفعال
Vector2? home; // موقعیت اصلی در دست (برای برگشت پس از کشیدنِ ناقص)
double homeAngle = 0; // زاویه‌ی بادبزنیِ کارت در دست (برای برگشت پس از کشیدن)
bool highlight = false; // کارتِ برنده‌ی دست ⇒ درخششِ طلایی پیش از جمع‌شدن
int restPriority = 0; // ترتیب لایه‌ی اصلی در دست (برای بازگردانی پس از کشیدن)
Rect? dropZone; // ناحیه‌ی وسط میز؛ رهاکردن کارت در آن یعنی بازی
Sprite? _front;
@@ -40,10 +44,27 @@ class CardComponent extends PositionComponent
@override
Future<void> onLoad() async {
// اگر تصویر از قبل در حافظه آماده باشد (از کشِ دیسک/preload)، همان لحظه ست می‌شود
// ⇒ کارت با تصویرِ درست و بدونِ پرش رندر می‌شود. وگرنه بدونِ مسدودکردنِ رندر در
// پس‌زمینه بارگذاری و به‌محضِ آماده‌شدن جای‌گزین می‌شود (نسخه‌ی برداری تا آن لحظه).
if (code.isNotEmpty) {
_front = await CardImages.front(skin, code);
final cached = CardImages.cachedFront(skin, code);
if (cached != null) {
_front = cached;
} else {
CardImages.front(skin, code).then((s) {
if (isMounted) _front = s;
});
}
}
final cb = CardImages.cachedBack(skin);
if (cb != null) {
_back = cb;
} else {
CardImages.back(skin).then((s) {
if (isMounted) _back = s;
});
}
_back = await CardImages.back(skin);
}
/// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخش‌شدن از روی دسته).
@@ -69,12 +90,28 @@ class CardComponent extends PositionComponent
Vector2.all(s), EffectController(duration: 0.12, curve: Curves.easeOut)));
}
// چرخاندنِ نرمِ کارت به زاویه‌ی مشخص (صاف‌کردن هنگام کشیدن، برگشت هنگام رها).
void _rotateTo(double a) {
children.whereType<RotateEffect>().toList().forEach((e) => e.removeFromParent());
add(RotateEffect.to(
a, EffectController(duration: 0.12, curve: Curves.easeOut)));
}
/// بازنشانیِ فوریِ مقیاس به اندازه‌ی عادی (هنگام بازی روی میز).
void resetScale() {
children.whereType<ScaleEffect>().toList().forEach((e) => e.removeFromParent());
scale = Vector2.all(1);
}
/// یک «پاپِ» کوتاه (بزرگ‌نماییِ رفت‌وبرگشتی) برای کارتِ برنده‌ی دست.
void pulse() {
children.whereType<ScaleEffect>().toList().forEach((e) => e.removeFromParent());
add(ScaleEffect.to(
Vector2.all(1.16),
EffectController(duration: 0.18, alternate: true, curve: Curves.easeOut),
));
}
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
@@ -82,12 +119,15 @@ class CardComponent extends PositionComponent
_dragging = true;
priority = 1000; // روی همه‌ی کارت‌ها
_scaleTo(_dragScale); // بزرگ‌نماییِ نرم هنگام انتخاب
_rotateTo(0); // صاف‌شدن: کارت دیگر با زاویه‌ی بادبزنیِ دست کشیده نمی‌شود
}
@override
void onDragUpdate(DragUpdateEvent event) {
if (!_dragging) return;
position += event.localDelta;
// canvasDelta (فضای صفحه) نه localDelta: مستقل از چرخش/مقیاسِ خودِ کارت است،
// پس کارت دقیقاً به‌دنبالِ انگشت می‌آید نه روی مسیرِ منحنیِ قوسِ دست.
position += event.canvasDelta;
_overZone = _inDropZone();
}
@@ -100,6 +140,7 @@ class CardComponent extends PositionComponent
// وگرنه برگشت به جای مرتبِ خود.
if (_inDropZone()) {
resetScale();
HapticFeedback.selectionClick(); // بازخوردِ لمسیِ بازیِ کارت
onPlay?.call();
} else {
_scaleTo(1);
@@ -126,6 +167,7 @@ class CardComponent extends PositionComponent
void _returnHome() {
priority = restPriority; // بازگردانی ترتیب لایه تا روی کارت‌های دیگر نیفتد
_rotateTo(homeAngle); // بازگشت به زاویه‌ی بادبزنیِ دست
if (home == null) return;
add(MoveToEffect(home!, EffectController(duration: 0.2, curve: Curves.easeOut)));
}
@@ -180,9 +222,21 @@ class CardComponent extends PositionComponent
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..color = const Color(0xFF1A1A1A),
..color = AppColors.suitDark,
);
// درخششِ طلاییِ کارتِ برنده‌ی دست (فقط یک کارت هم‌زمان ⇒ blur ارزان).
if (highlight) {
canvas.drawRRect(
rrect,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.x * 0.07
..color = AppColors.gold
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6),
);
}
// هایلایت طلایی: هنگام کشیدن همیشه (انتخاب)، و پررنگ‌تر روی ناحیه‌ی انداختن.
if (_dragging) {
canvas.drawRRect(
@@ -190,7 +244,7 @@ class CardComponent extends PositionComponent
Paint()
..style = PaintingStyle.stroke
..strokeWidth = size.x * (_overZone ? 0.06 : 0.035)
..color = const Color(0xFFE9B949)
..color = AppColors.gold
..maskFilter = MaskFilter.blur(BlurStyle.normal, _overZone ? 6 : 3),
);
}
@@ -204,7 +258,7 @@ class CardComponent extends PositionComponent
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2
..color = const Color(0xFFE9B949),
..color = AppColors.gold,
);
}
@@ -224,13 +278,13 @@ class CardComponent extends PositionComponent
(String, Color) _suit(String s) {
switch (s) {
case 'H':
return ('', const Color(0xFFD32F2F));
return ('', AppColors.suitRed);
case 'D':
return ('', const Color(0xFFD32F2F));
return ('', AppColors.suitRed);
case 'C':
return ('', const Color(0xFF1A1A1A));
return ('', AppColors.suitDark);
default:
return ('', const Color(0xFF1A1A1A));
return ('', AppColors.suitDark);
}
}
@@ -0,0 +1,45 @@
import '../../../domain/entities/game_entities.dart';
/// وضعیتِ افکتِ «بریدن با حکم»: بالاترین حکمِ دستِ جاری، کمبوِ روبُریدن‌های سریع،
/// و «مکثِ آتش» (فریزِ کوتاهِ صحنه پس از آتشِ کمبو). فقط وضعیت و تصمیم را نگه
/// می‌دارد؛ افزودنِ کامپوننت‌های افکت و بافرِ وضعیت بر عهده‌ی خودِ صحنه است.
class CutFx {
static const double comboWindowSec = 6.0; // پنجره‌ی شمارشِ بریدنِ پیاپی
static const double holdSec = 1.0; // مدتِ مکثِ آتش
int _bestTrumpRank = 0; // بالاترین حکمِ بازی‌شده در دستِ جاری
int combo = 0; // شمارِ بریدنِ پیاپیِ سریع (برای شدتِ آتش)
double _lastCutAt = -999;
bool holding = false; // آیا در «مکثِ آتش» هستیم
double _holdUntil = 0;
GameState? pending; // آخرین وضعیتِ بافرشده در حینِ مکث
/// دستِ جدید (زمین خالی/تک‌کارت) ⇒ بالاترین حکم ریست می‌شود.
void resetTrick() => _bestTrumpRank = 0;
/// ثبتِ یک بریدن؛ true اگر این حکم «دست را می‌بَرد» (اولین بریدن یا روبُریدنِ
/// بالاتر) — در این صورت کمبو هم به‌روزرسانی می‌شود. حکمِ بازنده false می‌دهد.
bool registerWinningCut(int rank, double now) {
if (rank <= _bestTrumpRank) return false;
_bestTrumpRank = rank;
combo = (now - _lastCutAt < comboWindowSec) ? combo + 1 : 1;
_lastCutAt = now;
return true;
}
/// شروعِ مکثِ آتش (پس از آتشِ کمبو).
void startHold(double now) {
holding = true;
_holdUntil = now + holdSec;
}
/// آیا مکث به پایان رسیده و باید وضعیتِ بافرشده اعمال شود.
bool holdExpired(double now) => holding && now >= _holdUntil;
/// لغوِ مکث و پاک‌کردنِ بافر (هنگام خروج از میز).
void clearHold() {
holding = false;
pending = null;
}
}

Some files were not shown because too many files have changed in this diff Show More