Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b266e732d | ||
|
|
5a3dbb0466 | ||
|
|
2ada58d7e6 | ||
|
|
8cd90f4a5f | ||
|
|
f13b196971 | ||
|
|
a7fb3ed8f9 | ||
|
|
b008ec6056 | ||
|
|
f5e51ed44d | ||
|
|
7f420e6ae1 | ||
|
|
97d156c492 | ||
|
|
7c067d36f2 | ||
|
|
b9d21c505e | ||
|
|
adc2d1fc47 | ||
|
|
2496580e86 | ||
|
|
6b9b9a0f3a | ||
|
|
df2ae2a215 | ||
|
|
46a649166b | ||
|
|
ffd2646eea | ||
|
|
0bd2b4a18a | ||
|
|
5424695f25 | ||
|
|
5b0a66bb54 | ||
|
|
3e7e069487 | ||
|
|
df5cc699b6 | ||
|
|
e94f03c499 | ||
|
|
5017e9a613 | ||
|
|
c2356b3242 | ||
|
|
7acd72c286 | ||
|
|
5160deb973 | ||
|
|
153b4caa3d | ||
|
|
fa45ef0737 | ||
|
|
e8cde8e1f7 | ||
|
|
e73333344f | ||
|
|
500c61a409 | ||
|
|
e0d6763c00 | ||
|
|
08937a66cb |
@@ -43,3 +43,6 @@ app.*.map.json
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
|
||||
# FVM Version Cache
|
||||
.fvm/
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"dart.flutterSdkPath": ".fvm/versions/3.38.5"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
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>
|
||||
|
||||
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 384 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 591 KiB |
|
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>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 51 KiB |
|
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
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 604 KiB |
|
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
|
||||
}
|
||||
}
|
||||
|
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
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 68 B After Width: | Height: | Size: 384 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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!),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
// کنترلِ ضربانِ قلب: همیشه حداکثر یک ضربان همزمان (بدون انباشت/تکرارِ سریع)
|
||||
// و قابلِ توقف وقتی نوبت تمام میشود.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 = '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 ? 'در حال بررسی…' : 'تلاش مجدد'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||