diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html
index 1e9d4b7..2482603 100644
--- a/android/build/reports/problems/problems-report.html
+++ b/android/build/reports/problems/problems-report.html
@@ -650,7 +650,7 @@ code + .copy-button {
diff --git a/lib/core/network/card_images.dart b/lib/core/network/card_images.dart
index fbfd897..84c0408 100644
--- a/lib/core/network/card_images.dart
+++ b/lib/core/network/card_images.dart
@@ -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//.png` و `/cards//back.jpg`.
-/// تصاویرِ دانلودشده در حافظه کش میشوند تا هر کارت فقط یکبار از شبکه بیاید.
+/// تصاویرِ کارت از backend میآیند (نه bundle داخل اپ) تا حجمِ اپ کم بماند، اما هر
+/// تصویر فقط **یکبار برای همیشه** دانلود میشود: بایتها روی دیسکِ ماندگار ذخیره و
+/// در جلسههای بعد از دیسک خوانده میشوند (بدونِ شبکه). یک کشِ درونحافظهایِ
+/// همگام ([_ready]) هم نگه میداریم تا کارتها بدونِ پرش/انتظار رندر شوند.
+///
+/// مسیرِ سرور: `/cards//.png` و `/cards//back.jpg`.
class CardImages {
CardImages._();
@@ -19,53 +25,97 @@ class CardImages {
receiveTimeout: const Duration(seconds: 12),
validateStatus: (s) => s != null && s >= 200 && s < 300,
));
- static final Map> _cache = {};
+
+ // اسپرایتِ آماده و decodeشده (کلید: "/") — دسترسیِ همگام برای رندرِ فوری.
+ static final Map _ready = {};
+ // Futureهای در حالِ اجرا/تمامشده برای جلوگیری از بارگذاریِ تکراری.
+ static final Map> _inflight = {};
+ static Future? _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 front(String skin, String code) =>
_load(skin, '$code.png');
- /// اسپرایتِ پشتِ کارت برای اسکین دادهشده.
+ /// اسپرایتِ پشتِ کارت برای اسکین.
static Future 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 _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 _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 _fetchFrom(String skin, String file) async {
+ static Future _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>(_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;
}
}
- /// تصویرِ فرش (برای سطحِ میز) از URL کاملِ backend. کششده با کلیدِ URL.
+ /// تصویرِ فرش از URL کاملِ backend (کششده در حافظه با کلیدِ URL).
static Future carpetByUrl(String url) {
if (url.isEmpty) return Future.value(null);
- return _cache.putIfAbsent('carpet/$url', () async {
+ 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>(url);
final data = res.data;
if (data == null || data.isEmpty) return null;
- return Sprite(await _decode(Uint8List.fromList(data)));
+ 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;
@@ -73,9 +123,34 @@ class CardImages {
});
}
- static Future _decode(Uint8List bytes) async {
- final codec = await ui.instantiateImageCodec(bytes);
- final frame = await codec.getNextFrame();
- return frame.image;
+ // ===== دیسک =====
+
+ static Future _cacheDir() =>
+ _dir ??= getApplicationSupportDirectory().then((d) => '${d.path}/hakem_cards');
+
+ static Future _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 _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 _toSprite(Uint8List bytes) async {
+ try {
+ final codec = await ui.instantiateImageCodec(bytes);
+ final frame = await codec.getNextFrame();
+ return Sprite(frame.image);
+ } catch (_) {
+ return null;
+ }
}
}
diff --git a/lib/feature/game/presentation/widgets/flame/card_component.dart b/lib/feature/game/presentation/widgets/flame/card_component.dart
index f6b38f6..39eb781 100644
--- a/lib/feature/game/presentation/widgets/flame/card_component.dart
+++ b/lib/feature/game/presentation/widgets/flame/card_component.dart
@@ -44,10 +44,27 @@ class CardComponent extends PositionComponent
@override
Future 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);
}
/// برگرداندنِ کارت از پشت به رو با انیمیشن (هنگام پخششدن از روی دسته).
diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart
index 9e786c7..2399c00 100644
--- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart
+++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart
@@ -7,6 +7,7 @@ import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
+import '../../../../../core/network/card_images.dart';
import '../../../../../core/service/app_sounds.dart';
import '../../../../../core/settings/app_settings.dart';
import '../../../../../core/theme/app_theme.dart';
@@ -79,6 +80,7 @@ class HokmGame extends FlameGame {
@override
Future onLoad() async {
+ CardImages.preloadDeck(skin); // گرمکردنِ کشِ تصاویرِ کارت (پسزمینه، fire-and-forget)
add(Felt(carpetUrl: carpetUrl));
if (!AppSettings.I.lowGraphics.value) {
add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کممصرف خاموش)
diff --git a/pubspec.lock b/pubspec.lock
index 146c0d1..37902fb 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -569,7 +569,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
- dependency: transitive
+ dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
diff --git a/pubspec.yaml b/pubspec.yaml
index fb1426c..6976bd1 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -48,6 +48,7 @@ dependencies:
flutter_svg: ^2.3.0 # نمایشِ شخصیتِ آواتارِ svg (و png با Image.network)
connectivity_plus: ^6.1.0 # تشخیصِ قطعِ اینترنت برای نمایشِ اوورلیِ «اتصال نیست»
package_info_plus: ^8.1.1 # نمایشِ نسخهی اپ در لابی
+ path_provider: 2.1.5 # کشِ دیسکیِ ماندگارِ تصاویرِ کارت (دانلودِ یکبار برای همیشه)
# خرید درونبرنامهای: هر flavor یکی را استفاده میکند (مایکت/کافهبازار).
myket_iap: ^1.1.11
flutter_poolakey: ^2.2.0