From 97d156c4926d3c6fc1b0af52ac919527a8629c17 Mon Sep 17 00:00:00 2001 From: Amirmahdi Nourkazemi Date: Fri, 10 Jul 2026 00:56:58 +0330 Subject: [PATCH] feat: add characters --- lib/core/locator/locator.dart | 2 + lib/core/network/card_images.dart | 12 +- lib/core/widgets/player_avatar.dart | 51 +++ .../game/domain/entities/game_entities.dart | 4 +- .../game/presentation/bloc/game_state.dart | 5 +- .../game/presentation/screen/game_screen.dart | 7 +- .../screen/game_screen.parts.dart | 5 +- .../screen/private_table_screen.dart | 2 +- .../screen/private_table_screen.parts.dart | 5 +- .../presentation/widgets/flame/hokm_game.dart | 10 +- .../widgets/flame/table_pieces.dart | 10 +- .../game/presentation/widgets/table_hud.dart | 2 +- .../presentation/widgets/table_hud.parts.dart | 8 +- .../profile/data/model/profile_model.dart | 1 + .../domain/entities/profile_entity.dart | 2 + .../presentation/screen/profile_screen.dart | 1 + .../screen/profile_screen.parts.dart | 6 +- .../remote/avatar_api_provider.dart | 44 +++ .../domain/entities/avatar_character.dart | 34 ++ lib/feature/shop/domain/entities/carpet.dart | 9 +- .../shop/presentation/screen/shop_screen.dart | 6 +- .../screen/shop_screen.parts.dart | 339 +++++++++++++++--- .../wallet/data/model/wallet_model.dart | 4 + .../wallet/domain/entities/wallet_entity.dart | 4 + .../presentation/screen/lobby_screen.dart | 2 +- .../screen/lobby_screen.parts.dart | 2 +- pubspec.lock | 2 +- pubspec.yaml | 1 + 28 files changed, 486 insertions(+), 94 deletions(-) create mode 100644 lib/core/widgets/player_avatar.dart create mode 100644 lib/feature/shop/data/data_source/remote/avatar_api_provider.dart create mode 100644 lib/feature/shop/domain/entities/avatar_character.dart diff --git a/lib/core/locator/locator.dart b/lib/core/locator/locator.dart index 3d5ed7b..21a038e 100644 --- a/lib/core/locator/locator.dart +++ b/lib/core/locator/locator.dart @@ -31,6 +31,7 @@ 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'; @@ -71,6 +72,7 @@ Future setupLocator() async { locator.registerSingleton(ShopApiProvider()); locator.registerSingleton(CarpetApiProvider()); locator.registerSingleton(FrameApiProvider()); + locator.registerSingleton(AvatarApiProvider()); locator.registerSingleton(ProfileApiProvider()); locator.registerSingleton(RankedApiProvider()); locator.registerSingleton(ChatApiProvider()); diff --git a/lib/core/network/card_images.dart b/lib/core/network/card_images.dart index 6e64609..fbfd897 100644 --- a/lib/core/network/card_images.dart +++ b/lib/core/network/card_images.dart @@ -57,17 +57,17 @@ class CardImages { } } - /// تصویرِ فرش (برای سطحِ میز) از backend: `/carpets/.jpg`. کش‌شده. - static Future carpet(String id) { - return _cache.putIfAbsent('carpet/$id', () async { + /// تصویرِ فرش (برای سطحِ میز) از URL کاملِ backend. کش‌شده با کلیدِ URL. + static Future carpetByUrl(String url) { + if (url.isEmpty) return Future.value(null); + return _cache.putIfAbsent('carpet/$url', () async { try { - final res = await _dio - .get>('${AppConfig.baseUrl}/carpets/$id.jpg'); + final res = await _dio.get>(url); final data = res.data; if (data == null || data.isEmpty) return null; return Sprite(await _decode(Uint8List.fromList(data))); } catch (e) { - debugPrint('CardImages: carpet/$id failed: $e'); + debugPrint('CardImages: carpet $url failed: $e'); return null; } }); diff --git a/lib/core/widgets/player_avatar.dart b/lib/core/widgets/player_avatar.dart new file mode 100644 index 0000000..a421e49 --- /dev/null +++ b/lib/core/widgets/player_avatar.dart @@ -0,0 +1,51 @@ +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.cover, + 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); +} diff --git a/lib/feature/game/domain/entities/game_entities.dart b/lib/feature/game/domain/entities/game_entities.dart index eb37cbb..8aea43e 100644 --- a/lib/feature/game/domain/entities/game_entities.dart +++ b/lib/feature/game/domain/entities/game_entities.dart @@ -9,6 +9,7 @@ class GamePlayer { final String rankTier; // bronze..king (خالی برای بات) final String avatar; // seed آواتارِ انتخابیِ کاربر (خالی برای بات) final String frame; // قابِ آواتارِ انتخابی (کازمتیک؛ خالی ⇒ قابِ رتبه) + final String avatarImg; // شخصیتِ آواتارِ انتخابی (.؛ خالی ⇒ تصادفی) GamePlayer.fromJson(Map j) : seat = (j['seat'] ?? 0) as int, @@ -18,7 +19,8 @@ class GamePlayer { coins = (j['coins'] ?? 0) as int, rankTier = (j['rank_tier'] ?? '') as String, avatar = (j['avatar'] ?? '') as String, - frame = (j['frame'] ?? '') as String; + frame = (j['frame'] ?? '') as String, + avatarImg = (j['avatar_img'] ?? '') as String; /// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام (fallback برای بات). String get avatarSeed => avatar.isNotEmpty ? avatar : name; diff --git a/lib/feature/game/presentation/bloc/game_state.dart b/lib/feature/game/presentation/bloc/game_state.dart index 3b6bd76..d745dca 100644 --- a/lib/feature/game/presentation/bloc/game_state.dart +++ b/lib/feature/game/presentation/bloc/game_state.dart @@ -9,7 +9,9 @@ class LobbyPlayer { final String avatar; // seed آواتارِ انتخابیِ کاربر final bool host; final int seat; // جایگاهِ مطلق (۰..۳) - const LobbyPlayer(this.name, this.avatar, this.host, this.seat); + final String avatarImg; // شخصیتِ آواتارِ انتخابی (.؛ خالی ⇒ تصادفی) + const LobbyPlayer(this.name, this.avatar, this.host, this.seat, + {this.avatarImg = ''}); /// seedِ آواتار برای نمایش: آواتارِ انتخابی، وگرنه نام. String get avatarSeed => avatar.isNotEmpty ? avatar : name; @@ -42,6 +44,7 @@ class TableLobby { (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, diff --git a/lib/feature/game/presentation/screen/game_screen.dart b/lib/feature/game/presentation/screen/game_screen.dart index 9ff281b..0c22ab5 100644 --- a/lib/feature/game/presentation/screen/game_screen.dart +++ b/lib/feature/game/presentation/screen/game_screen.dart @@ -11,6 +11,7 @@ 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'; @@ -39,7 +40,6 @@ class GameScreen extends StatefulWidget { class _GameScreenState extends State { late final HokmGame _game; - String _carpet = 'classic'; // فرشِ انتخابی (برای پس‌زمینه‌ی پشتِ صحنه) Timer? _introTimer; bool _introHidden = false; int _countdown = 0; // شمارشِ معکوسِ شروعِ بازی پس از یافتنِ حریفان (۳…۲…۱) @@ -50,11 +50,12 @@ class _GameScreenState extends State { super.initState(); final ws = context.read().state.walletStatus; final skin = ws is WalletLoaded ? ws.wallet.selectedCard : 'simple'; - _carpet = ws is WalletLoaded ? ws.wallet.selectedCarpet : 'classic'; + // URL کاملِ فرش از سرور می‌آید (خالی ⇒ میزِ کلاسیک). + final carpetUrl = ws is WalletLoaded ? ws.wallet.selectedCarpetImg : ''; _game = HokmGame( context.read(), skin: skin.isEmpty ? 'simple' : skin, - carpet: _carpet.isEmpty ? 'classic' : _carpet, + carpetUrl: carpetUrl, ); _loadTournament(); locator().warmCache(); // رنگِ قاب‌ها برای HUD (best-effort) diff --git a/lib/feature/game/presentation/screen/game_screen.parts.dart b/lib/feature/game/presentation/screen/game_screen.parts.dart index a1b881a..65bb447 100644 --- a/lib/feature/game/presentation/screen/game_screen.parts.dart +++ b/lib/feature/game/presentation/screen/game_screen.parts.dart @@ -253,7 +253,10 @@ class _SearchSlot extends StatelessWidget { ) : ClipRRect( borderRadius: BorderRadius.circular(8), - child: RandomAvatar(p.avatarSeed), + child: PlayerAvatar( + seed: p.avatarSeed, + imageUrl: p.avatarImg, + ), ), ), const SizedBox(height: 2), diff --git a/lib/feature/game/presentation/screen/private_table_screen.dart b/lib/feature/game/presentation/screen/private_table_screen.dart index 6ad93f7..2863492 100644 --- a/lib/feature/game/presentation/screen/private_table_screen.dart +++ b/lib/feature/game/presentation/screen/private_table_screen.dart @@ -4,11 +4,11 @@ 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'; diff --git a/lib/feature/game/presentation/screen/private_table_screen.parts.dart b/lib/feature/game/presentation/screen/private_table_screen.parts.dart index 6839c70..53aaa2c 100644 --- a/lib/feature/game/presentation/screen/private_table_screen.parts.dart +++ b/lib/feature/game/presentation/screen/private_table_screen.parts.dart @@ -253,7 +253,10 @@ class _LobbyViewState extends State<_LobbyView> { ) : ClipRRect( borderRadius: BorderRadius.circular(9), - child: RandomAvatar(p.avatarSeed), + child: PlayerAvatar( + seed: p.avatarSeed, + imageUrl: p.avatarImg, + ), ), ), if (!empty) diff --git a/lib/feature/game/presentation/widgets/flame/hokm_game.dart b/lib/feature/game/presentation/widgets/flame/hokm_game.dart index d4ea9da..9e786c7 100644 --- a/lib/feature/game/presentation/widgets/flame/hokm_game.dart +++ b/lib/feature/game/presentation/widgets/flame/hokm_game.dart @@ -68,18 +68,18 @@ class HokmGame extends FlameGame { // ضربانِ قلبِ نوبتِ کاربر (منطق در TurnHeartbeat جدا شده است). final TurnHeartbeat _heart = TurnHeartbeat(); - /// فرشِ انتخابی؛ 'classic' یعنی سطحِ میزِ ترسیمی. فرشِ تصویری به‌عنوانِ سطحِ - /// میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده می‌شود. - final String carpet; + /// URL کاملِ تصویرِ فرش (از سرور)؛ خالی یعنی میزِ ترسیمیِ کلاسیک. فرشِ تصویری + /// به‌عنوانِ سطحِ میز (بریده در شکلِ میز) روی زمینِ لاجوردی کشیده می‌شود. + final String carpetUrl; - HokmGame(this.cubit, {this.skin = 'simple', this.carpet = 'classic'}); + HokmGame(this.cubit, {this.skin = 'simple', this.carpetUrl = ''}); @override Color backgroundColor() => AppColors.lapisNight; @override Future onLoad() async { - add(Felt(carpet: carpet)); + add(Felt(carpetUrl: carpetUrl)); if (!AppSettings.I.lowGraphics.value) { add(Motes()); // ذراتِ طلاییِ معلق برای اتمسفر (در حالتِ کم‌مصرف خاموش) } diff --git a/lib/feature/game/presentation/widgets/flame/table_pieces.dart b/lib/feature/game/presentation/widgets/flame/table_pieces.dart index d73f3b9..8046fa6 100644 --- a/lib/feature/game/presentation/widgets/flame/table_pieces.dart +++ b/lib/feature/game/presentation/widgets/flame/table_pieces.dart @@ -16,19 +16,19 @@ const _goldDark = AppColors.goldDark; /// اگر فرشی انتخاب شده باشد، سطحِ میز تصویرِ فرش است (بریده‌شده در شکلِ میز)؛ /// وگرنه سطحِ سرمه‌ایِ ترسیمی. کارت‌ها داخلِ همین ناحیه انداخته می‌شوند. class Felt extends PositionComponent with HasGameReference { - /// شناسه‌ی فرشِ انتخابی؛ 'classic' یعنی سطحِ ترسیمی (بدون تصویر). - final String carpet; - Felt({this.carpet = 'classic'}); + /// URL کاملِ تصویرِ فرش (از سرور)؛ خالی یعنی سطحِ ترسیمیِ کلاسیک (بدون تصویر). + final String carpetUrl; + Felt({this.carpetUrl = ''}); Sprite? _carpetSprite; Sprite? _bgSprite; // پس‌زمینه‌ی نقاشی‌شده (اختیاری، قابلِ تعویض) - bool get _hasCarpet => carpet != 'classic'; + bool get _hasCarpet => carpetUrl.isNotEmpty; @override Future onLoad() async { if (_hasCarpet) { - _carpetSprite = await CardImages.carpet(carpet); + _carpetSprite = await CardImages.carpetByUrl(carpetUrl); } // پس‌زمینه‌ی سفارشی (تالار/کاخ): فایلِ assets/images/game_bg.jpg را بگذارید. // اگر نبود یا حالتِ کم‌مصرف باشد، به گرادیانِ لاجوردیِ پیش‌فرض برمی‌گردد. diff --git a/lib/feature/game/presentation/widgets/table_hud.dart b/lib/feature/game/presentation/widgets/table_hud.dart index 6b4f3a8..2e5f844 100644 --- a/lib/feature/game/presentation/widgets/table_hud.dart +++ b/lib/feature/game/presentation/widgets/table_hud.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:random_avatar/random_avatar.dart'; import '../../../../core/network/ws_client.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/frames.dart'; import '../../../../core/theme/ranks.dart'; +import '../../../../core/widgets/player_avatar.dart'; import '../../domain/entities/game_entities.dart'; import '../bloc/game_state.dart'; diff --git a/lib/feature/game/presentation/widgets/table_hud.parts.dart b/lib/feature/game/presentation/widgets/table_hud.parts.dart index 3e78878..c923b73 100644 --- a/lib/feature/game/presentation/widgets/table_hud.parts.dart +++ b/lib/feature/game/presentation/widgets/table_hud.parts.dart @@ -91,8 +91,12 @@ class _PlayerSeat extends StatelessWidget { color: Colors.white70, size: diameter * 0.5, ) - : RandomAvatar( - player.avatarSeed.isEmpty ? '?' : player.avatarSeed), + : PlayerAvatar( + seed: player.avatarSeed.isEmpty + ? '?' + : player.avatarSeed, + imageUrl: player.avatarImg, + ), ), ), ), diff --git a/lib/feature/profile/data/model/profile_model.dart b/lib/feature/profile/data/model/profile_model.dart index 3bafa0c..9a6bb11 100644 --- a/lib/feature/profile/data/model/profile_model.dart +++ b/lib/feature/profile/data/model/profile_model.dart @@ -13,6 +13,7 @@ class ProfileModel { return ProfileEntity( name: (name == null || name.isEmpty) ? 'بازیکن' : name, avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, + avatarImg: (wallet['selected_avatar_img'] ?? '') as String, mobile: (user['mobile'] as String?) ?? '', level: (wallet['level'] ?? 1) as int, trophies: (wallet['trophies'] ?? 0) as int, diff --git a/lib/feature/profile/domain/entities/profile_entity.dart b/lib/feature/profile/domain/entities/profile_entity.dart index c24a7b9..f47f4c2 100644 --- a/lib/feature/profile/domain/entities/profile_entity.dart +++ b/lib/feature/profile/domain/entities/profile_entity.dart @@ -22,6 +22,7 @@ class ProfileStats { class ProfileEntity { final String name; final String avatar; + final String avatarImg; // شخصیتِ آواتارِ انتخابی (.؛ خالی ⇒ تصادفی) final String mobile; final int level; final int trophies; @@ -35,6 +36,7 @@ class ProfileEntity { const ProfileEntity({ required this.name, required this.avatar, + this.avatarImg = '', required this.mobile, required this.level, required this.trophies, diff --git a/lib/feature/profile/presentation/screen/profile_screen.dart b/lib/feature/profile/presentation/screen/profile_screen.dart index 9c92919..0459282 100644 --- a/lib/feature/profile/presentation/screen/profile_screen.dart +++ b/lib/feature/profile/presentation/screen/profile_screen.dart @@ -8,6 +8,7 @@ import '../../../../core/settings/app_settings.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/ranks.dart'; import '../../../../core/widgets/game_ui.dart'; +import '../../../../core/widgets/player_avatar.dart'; import '../../../../core/widgets/rank_badge.dart'; import '../../../wallet/presentation/bloc/wallet_bloc.dart'; import '../../../wallet/presentation/bloc/wallet_event.dart'; diff --git a/lib/feature/profile/presentation/screen/profile_screen.parts.dart b/lib/feature/profile/presentation/screen/profile_screen.parts.dart index e24728b..b1b8701 100644 --- a/lib/feature/profile/presentation/screen/profile_screen.parts.dart +++ b/lib/feature/profile/presentation/screen/profile_screen.parts.dart @@ -40,7 +40,11 @@ class _HeroCard extends StatelessWidget { shape: BoxShape.circle, color: AppColors.bgDark, ), - child: RandomAvatar(d.avatar, height: 90, width: 90), + child: PlayerAvatar( + seed: d.avatar, + imageUrl: d.avatarImg, + size: 90, + ), ), ), Positioned( diff --git a/lib/feature/shop/data/data_source/remote/avatar_api_provider.dart b/lib/feature/shop/data/data_source/remote/avatar_api_provider.dart new file mode 100644 index 0000000..c4e7a89 --- /dev/null +++ b/lib/feature/shop/data/data_source/remote/avatar_api_provider.dart @@ -0,0 +1,44 @@ +import '../../../../../core/locator/locator.dart'; +import '../../../../../core/network/api_provider_imp.dart'; +import '../../../domain/entities/avatar_character.dart'; + +/// دادهٔ شخصیت‌های آواتار از backend (دریافت، خرید، انتخاب). +class AvatarApiProvider { + ApiProviderImp get _api => locator(); + + Future getAvatars() async { + final res = await _api.get('/avatars'); + if (res.statusCode != 200) { + throw Exception('avatars: ${res.statusCode}'); + } + final list = (res.data['avatars'] as List?) ?? const []; + final avatars = list + .map((e) => AvatarCharacter.fromJson(Map.from(e as Map))) + .toList(); + return AvatarCatalog( + avatars: avatars, + selected: (res.data['selected'] ?? '') as String, + ); + } + + /// خرید؛ null یعنی موفق، وگرنه پیامِ خطای فارسی. + Future buyAvatar(String id) => _post('/shop/buy-avatar', id); + + /// انتخاب؛ id خالی ⇒ بازگشت به آواتارِ تصادفی. null یعنی موفق. + Future selectAvatar(String id) => _post('/shop/select-avatar', id); + + Future _post(String path, String id) async { + final res = await _api.post(path, body: {'avatar_id': id}); + if (res.statusCode == 200) return null; + switch (res.statusCode) { + case 402: + return 'سکهٔ کافی ندارید'; + case 403: + return 'این شخصیت را ندارید'; + case 409: + return 'این شخصیت را دارید'; + default: + return 'عملیات ناموفق بود'; + } + } +} diff --git a/lib/feature/shop/domain/entities/avatar_character.dart b/lib/feature/shop/domain/entities/avatar_character.dart new file mode 100644 index 0000000..0c31c9d --- /dev/null +++ b/lib/feature/shop/domain/entities/avatar_character.dart @@ -0,0 +1,34 @@ +/// یک شخصیتِ آواتار (کازمتیک تصویری). owned یعنی قابلِ انتخاب. +class AvatarCharacter { + final String id; + final String title; + final int priceCoins; + final bool vip; + final String img; // نامِ فایل: . (برای ساختِ URL و انتخابِ رندرر) + final bool owned; + + const AvatarCharacter({ + required this.id, + required this.title, + required this.priceCoins, + required this.vip, + required this.img, + required this.owned, + }); + + factory AvatarCharacter.fromJson(Map j) => AvatarCharacter( + id: (j['id'] ?? '') as String, + title: (j['title'] ?? '') as String, + priceCoins: (j['price_coins'] ?? 0) as int, + vip: (j['vip'] ?? false) as bool, + img: (j['img'] ?? '') as String, + owned: (j['owned'] ?? false) as bool, + ); +} + +/// کاتالوگِ شخصیت‌ها + شناسه‌ی شخصیتِ انتخابیِ کاربر (خالی ⇒ آواتارِ تصادفی). +class AvatarCatalog { + final List avatars; + final String selected; + const AvatarCatalog({required this.avatars, required this.selected}); +} diff --git a/lib/feature/shop/domain/entities/carpet.dart b/lib/feature/shop/domain/entities/carpet.dart index 2e956da..e9f9f81 100644 --- a/lib/feature/shop/domain/entities/carpet.dart +++ b/lib/feature/shop/domain/entities/carpet.dart @@ -1,11 +1,10 @@ -import '../../../../core/config.dart'; - -/// یک فرشِ ایرانی که به‌جای میزِ بازی استفاده می‌شود. تصویر از backend می‌آید. +/// یک فرشِ ایرانی که به‌جای میزِ بازی استفاده می‌شود. تصویر (URL کامل) از backend می‌آید. class Carpet { final String id; final String title; final int priceCoins; final bool vip; // برای VIP رایگان + final String img; // URL کاملِ تصویر (از سرور)؛ خالی برای classic final bool owned; const Carpet({ @@ -13,19 +12,19 @@ class Carpet { required this.title, required this.priceCoins, required this.vip, + required this.img, required this.owned, }); /// 'classic' یعنی میزِ ترسیمیِ پیش‌فرض (بدون تصویر). bool get isClassic => id == 'classic'; - String get imageUrl => '${AppConfig.baseUrl}/carpets/$id.jpg'; - factory Carpet.fromJson(Map j) => Carpet( id: (j['id'] ?? '') as String, title: (j['title'] ?? '') as String, priceCoins: (j['price_coins'] ?? 0) as int, vip: (j['vip'] ?? false) as bool, + img: (j['img'] ?? '') as String, owned: (j['owned'] ?? false) as bool, ); } diff --git a/lib/feature/shop/presentation/screen/shop_screen.dart b/lib/feature/shop/presentation/screen/shop_screen.dart index a63f006..aeb1748 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.dart @@ -7,12 +7,15 @@ import '../../../../core/locator/locator.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/theme/frames.dart'; import '../../../../core/widgets/game_ui.dart'; +import '../../../../core/widgets/player_avatar.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 '../../data/data_source/remote/avatar_api_provider.dart'; import '../../data/data_source/remote/carpet_api_provider.dart'; import '../../data/data_source/remote/frame_api_provider.dart'; +import '../../domain/entities/avatar_character.dart'; import '../../domain/entities/carpet.dart'; import '../../domain/entities/frame.dart'; import '../../domain/entities/shop_entities.dart'; @@ -30,7 +33,7 @@ class ShopScreen extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 7, + length: 8, child: Scaffold( backgroundColor: Colors.transparent, body: GameBackground( @@ -95,6 +98,7 @@ class ShopScreen extends StatelessWidget { _CardsTab(data: d), const _CarpetsTab(), const _FramesTab(), + const _AvatarsTab(), _BoostersTab(boosters: d.boosters), _VipTab(packages: d.vipPackages), ], diff --git a/lib/feature/shop/presentation/screen/shop_screen.parts.dart b/lib/feature/shop/presentation/screen/shop_screen.parts.dart index 46b0ae0..351cf66 100644 --- a/lib/feature/shop/presentation/screen/shop_screen.parts.dart +++ b/lib/feature/shop/presentation/screen/shop_screen.parts.dart @@ -10,6 +10,7 @@ class _ShopTabs extends StatelessWidget { (Icons.style, 'کارت'), (Icons.casino, 'فرش'), (Icons.filter_center_focus, 'قاب'), + (Icons.face, 'شخصیت'), (Icons.bolt, 'تجهیزات'), (Icons.workspace_premium, 'VIP'), ]; @@ -352,7 +353,7 @@ class _CarpetCard extends StatelessWidget { ), ) : Image.network( - c.imageUrl, + c.img, fit: BoxFit.cover, errorBuilder: (_, __, ___) => Container( @@ -430,8 +431,8 @@ class _FramesTabState extends State<_FramesTab> { } void _reload() => setState(() { - _future = _api.getFrames(); - }); + _future = _api.getFrames(); + }); Future _act(AvatarFrame f, {required bool buy}) async { setState(() => _busy = f.id); @@ -453,12 +454,15 @@ class _FramesTabState extends State<_FramesTab> { builder: (context, snap) { if (snap.connectionState != ConnectionState.done) { return const Center( - child: CircularProgressIndicator(color: AppColors.gold)); + child: CircularProgressIndicator(color: AppColors.gold), + ); } if (!snap.hasData) { return Center( child: TextButton( - onPressed: _reload, child: const Text('تلاش مجدد')), + onPressed: _reload, + child: const Text('تلاش مجدد'), + ), ); } final cat = snap.data!; @@ -471,7 +475,8 @@ class _FramesTabState extends State<_FramesTab> { _FrameCard( frame: f, avatarSeed: seed.isEmpty ? 'hakem-1' : seed, - selected: cat.selected == f.id || + selected: + cat.selected == f.id || (cat.selected.isEmpty && f.id == 'none'), busy: _busy == f.id, onSelect: () => _act(f, buy: false), @@ -503,19 +508,23 @@ class _FrameCard extends StatelessWidget { @override Widget build(BuildContext context) { final f = frame; - final grad = frameGradient(f.id) ?? const [Color(0xFFF3D27A), AppColors.goldDark]; + final grad = + frameGradient(f.id) ?? const [Color(0xFFF3D27A), AppColors.goldDark]; return Container( padding: const EdgeInsets.all(10), decoration: _shopCardDeco(highlight: selected), child: Column( children: [ - Text(f.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.gold, - fontWeight: FontWeight.bold, - fontSize: 14)), + Text( + f.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), const SizedBox(height: 8), // پیش‌نمایش: آواتار درونِ قاب با گرادیانِ همان قاب. Expanded( @@ -536,9 +545,7 @@ class _FrameCard extends StatelessWidget { ), ), child: ClipOval( - child: SizedBox.expand( - child: RandomAvatar(avatarSeed), - ), + child: SizedBox.expand(child: RandomAvatar(avatarSeed)), ), ); }, @@ -549,8 +556,10 @@ class _FrameCard extends StatelessWidget { if (f.vip && !f.owned) const Padding( padding: EdgeInsets.only(bottom: 4), - child: Text('ویژه‌ی VIP', - style: TextStyle(color: AppColors.gold, fontSize: 11)), + child: Text( + 'ویژه‌ی VIP', + style: TextStyle(color: AppColors.gold, fontSize: 11), + ), ), SizedBox(width: double.infinity, child: _action(f)), ], @@ -560,8 +569,12 @@ class _FrameCard extends StatelessWidget { Widget _action(AvatarFrame f) { if (busy) return const _PriceButton(label: '...', disabled: true); - if (selected) return const _PriceButton(label: 'انتخاب شده', disabled: true); - if (f.owned) return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect); + if (selected) { + return const _PriceButton(label: 'انتخاب شده', disabled: true); + } + if (f.owned) { + return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect); + } return _PriceButton( label: '${f.priceCoins} سکه', green: true, @@ -571,6 +584,213 @@ class _FrameCard extends StatelessWidget { } } +// ===== تبِ شخصیت‌های آواتار ===== + +class _AvatarsTab extends StatefulWidget { + const _AvatarsTab(); + @override + State<_AvatarsTab> createState() => _AvatarsTabState(); +} + +class _AvatarsTabState extends State<_AvatarsTab> { + final _api = locator(); + late Future _future; + String? _busy; + + @override + void initState() { + super.initState(); + _future = _api.getAvatars(); + } + + void _reload() => setState(() { + _future = _api.getAvatars(); + }); + + Future _act(AvatarCharacter a, {required bool buy}) async { + setState(() => _busy = a.id); + final err = + buy ? await _api.buyAvatar(a.id) : await _api.selectAvatar(a.id); + if (!mounted) return; + setState(() => _busy = null); + if (err == null) { + context.read().add(LoadWalletEvent()); + _reload(); + } else { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(err))); + } + } + + Future _clear() async { + setState(() => _busy = '__none__'); + await _api.selectAvatar(''); // بازگشت به آواتارِ تصادفی + if (!mounted) return; + setState(() => _busy = null); + context.read().add(LoadWalletEvent()); + _reload(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator(color: AppColors.gold), + ); + } + if (!snap.hasData) { + return Center( + child: TextButton( + onPressed: _reload, + child: const Text('تلاش مجدد'), + ), + ); + } + final cat = snap.data!; + final ws = context.watch().state.walletStatus; + final seed = ws is WalletLoaded ? ws.wallet.avatar : 'hakem-1'; + if (cat.avatars.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'به‌زودی شخصیت‌های تازه اضافه می‌شوند.', + style: TextStyle(color: AppColors.gold), + ), + ), + ); + } + return _grid( + note: + 'شخصیتِ آواتارت را انتخاب کن؛ در کلِ بازی (سرِ میز، پروفایل و لابی) دیده می‌شود.', + children: [ + // «پیش‌فرض»: بازگشت به آواتارِ تصادفی. + _AvatarCard.defaultRandom( + avatarSeed: seed.isEmpty ? 'hakem-1' : seed, + selected: cat.selected.isEmpty, + busy: _busy == '__none__', + onSelect: _clear, + ), + for (final a in cat.avatars) + _AvatarCard( + avatar: a, + avatarSeed: seed.isEmpty ? 'hakem-1' : seed, + selected: cat.selected == a.id, + busy: _busy == a.id, + onSelect: () => _act(a, buy: false), + onBuy: () => _act(a, buy: true), + ), + ], + ); + }, + ); + } +} + +class _AvatarCard extends StatelessWidget { + final AvatarCharacter? avatar; // null ⇒ کارتِ «پیش‌فرض» (آواتارِ تصادفی) + final String avatarSeed; + final bool selected; + final bool busy; + final VoidCallback onSelect; + final VoidCallback? onBuy; + const _AvatarCard({ + required this.avatar, + required this.avatarSeed, + required this.selected, + required this.busy, + required this.onSelect, + required this.onBuy, + }); + + const _AvatarCard.defaultRandom({ + required this.avatarSeed, + required this.selected, + required this.busy, + required this.onSelect, + }) : avatar = null, + onBuy = null; + + @override + Widget build(BuildContext context) { + final a = avatar; + final title = a?.title ?? 'پیش‌فرض'; + return Container( + padding: const EdgeInsets.all(10), + decoration: _shopCardDeco(highlight: selected), + child: Column( + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.gold, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + const SizedBox(height: 8), + Expanded( + child: Center( + child: LayoutBuilder( + builder: (_, box) { + final d = box.maxHeight.clamp(40.0, 96.0); + return Container( + width: d, + height: d, + padding: EdgeInsets.all(d * 0.06), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.bgDark, + border: Border.all( + color: selected ? AppColors.gold : AppColors.goldDark, + width: selected ? 2 : 1.4, + ), + ), + child: PlayerAvatar( + seed: avatarSeed, + imageUrl: a?.img ?? '', + ), + ); + }, + ), + ), + ), + const SizedBox(height: 6), + if (a != null && a.vip && !a.owned) + const Padding( + padding: EdgeInsets.only(bottom: 4), + child: Text( + 'ویژه‌ی VIP', + style: TextStyle(color: AppColors.gold, fontSize: 11), + ), + ), + SizedBox(width: double.infinity, child: _action(a)), + ], + ), + ); + } + + Widget _action(AvatarCharacter? a) { + if (busy) return const _PriceButton(label: '...', disabled: true); + if (selected) { + return const _PriceButton(label: 'انتخاب شده', disabled: true); + } + if (a == null || a.owned) { + return _PriceButton(label: 'انتخاب', green: true, onTap: onSelect); + } + return _PriceButton( + label: '${a.priceCoins} سکه', + green: true, + coin: true, + onTap: onBuy, + ); + } +} + Widget _grid({required String note, required List children}) { return Column( children: [ @@ -618,24 +838,26 @@ Widget _grid({required String note, required List children}) { /// قابِ مشترکِ کارت‌های فروشگاه (سکه/کارت/فرش/…): گرادیانِ لاجوردی، حاشیه‌ی طلایی /// و عمقِ سایه. [highlight] برای آیتمِ انتخاب‌شده هاله‌ی طلایی اضافه می‌کند. BoxDecoration _shopCardDeco({bool highlight = false}) => BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF1C4A80), Color(0xFF0B2646)], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: highlight ? AppColors.gold : AppColors.goldDark, - width: highlight ? 2 : 1.3, - ), - boxShadow: [ - const BoxShadow( - color: Colors.black54, blurRadius: 10, offset: Offset(0, 5)), - if (highlight) - BoxShadow( - color: AppColors.gold.withValues(alpha: 0.3), blurRadius: 14), - ], - ); + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF1C4A80), Color(0xFF0B2646)], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: highlight ? AppColors.gold : AppColors.goldDark, + width: highlight ? 2 : 1.3, + ), + boxShadow: [ + const BoxShadow( + color: Colors.black54, + blurRadius: 10, + offset: Offset(0, 5), + ), + if (highlight) + BoxShadow(color: AppColors.gold.withValues(alpha: 0.3), blurRadius: 14), + ], +); /// عددِ موجودی با جداکننده‌ی هزارگان (مثلِ ۱۲٬۵۰۰) برای خواناییِ بهتر. String _fmt(int n) { @@ -761,9 +983,10 @@ class _ItemCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 4), decoration: BoxDecoration( gradient: LinearGradient( - colors: isBonus - ? const [AppColors.success, AppColors.successDark] - : const [Color(0xFFFFD54F), AppColors.goldDark], + colors: + isBonus + ? const [AppColors.success, AppColors.successDark] + : const [Color(0xFFFFD54F), AppColors.goldDark], ), boxShadow: const [ BoxShadow(color: Colors.black54, blurRadius: 4), @@ -915,14 +1138,15 @@ class _PriceButtonState extends State<_PriceButton> { Widget build(BuildContext context) { final busy = context.select((ShopBloc c) => c.state.busy); final enabled = !(widget.disabled || busy) && widget.onTap != null; - final colors = widget.disabled - ? const [Color(0xFF555555), Color(0xFF333333)] - : widget.green + final colors = + widget.disabled + ? const [Color(0xFF555555), Color(0xFF333333)] + : widget.green ? const [AppColors.success, AppColors.successDark] : const [ - AppColors.accent, - Color(0xFF7A1019), - ]; // شنگرفِ ایرانی (هم‌رنگِ تم) + AppColors.accent, + Color(0xFF7A1019), + ]; // شنگرفِ ایرانی (هم‌رنگِ تم) return GestureDetector( onTapDown: enabled ? (_) => setState(() => _down = true) : null, onTapUp: enabled ? (_) => setState(() => _down = false) : null, @@ -945,15 +1169,16 @@ class _PriceButtonState extends State<_PriceButton> { ), borderRadius: BorderRadius.circular(11), border: Border.all(color: AppColors.gold, width: 1.2), - boxShadow: enabled - ? [ - BoxShadow( - color: colors.last.withValues(alpha: 0.55), - blurRadius: 8, - offset: const Offset(0, 3), - ), - ] - : null, + boxShadow: + enabled + ? [ + BoxShadow( + color: colors.last.withValues(alpha: 0.55), + blurRadius: 8, + offset: const Offset(0, 3), + ), + ] + : null, ), child: Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/feature/wallet/data/model/wallet_model.dart b/lib/feature/wallet/data/model/wallet_model.dart index cf3141f..524905d 100644 --- a/lib/feature/wallet/data/model/wallet_model.dart +++ b/lib/feature/wallet/data/model/wallet_model.dart @@ -13,8 +13,10 @@ class WalletModel extends WalletEntity { required super.vip, required super.selectedCard, required super.selectedCarpet, + super.selectedCarpetImg, required super.name, required super.avatar, + super.avatarImg, super.dailyStreak, super.bestStreak, super.dailyReady, @@ -38,8 +40,10 @@ class WalletModel extends WalletEntity { vip: (wallet['vip'] ?? false) as bool, selectedCard: (wallet['selected_card'] ?? 'simple') as String, selectedCarpet: (wallet['selected_carpet'] ?? 'classic') as String, + selectedCarpetImg: (wallet['selected_carpet_img'] ?? '') as String, name: (name == null || name.isEmpty) ? 'بازیکن' : name, avatar: (avatar == null || avatar.isEmpty) ? 'hakem-1' : avatar, + avatarImg: (wallet['selected_avatar_img'] ?? '') as String, dailyStreak: (wallet['daily_streak'] ?? 0) as int, bestStreak: (wallet['best_streak'] ?? 0) as int, dailyReady: (wallet['daily_ready'] ?? true) as bool, diff --git a/lib/feature/wallet/domain/entities/wallet_entity.dart b/lib/feature/wallet/domain/entities/wallet_entity.dart index 50e28a8..46df76a 100644 --- a/lib/feature/wallet/domain/entities/wallet_entity.dart +++ b/lib/feature/wallet/domain/entities/wallet_entity.dart @@ -10,8 +10,10 @@ class WalletEntity { final bool vip; final String selectedCard; final String selectedCarpet; + final String selectedCarpetImg; // URL کاملِ تصویرِ فرش (خالی ⇒ میزِ کلاسیک) final String name; final String avatar; + final String avatarImg; // شخصیتِ آواتارِ انتخابی (.؛ خالی ⇒ تصادفی) final int dailyStreak; // روزهای پیاپیِ دریافتِ سکه‌ی روزانه final int bestStreak; final bool dailyReady; // آیا امروز سکه‌ی روزانه قابلِ دریافت است @@ -28,8 +30,10 @@ class WalletEntity { required this.vip, required this.selectedCard, required this.selectedCarpet, + this.selectedCarpetImg = '', required this.name, required this.avatar, + this.avatarImg = '', this.dailyStreak = 0, this.bestStreak = 0, this.dailyReady = true, diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.dart b/lib/feature/wallet/presentation/screen/lobby_screen.dart index e9234dd..22a8f09 100644 --- a/lib/feature/wallet/presentation/screen/lobby_screen.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; -import 'package:random_avatar/random_avatar.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/widgets/game_ui.dart'; +import '../../../../core/widgets/player_avatar.dart'; import '../../../auth/presentation/bloc/auth_bloc.dart'; import '../../../auth/presentation/bloc/auth_event.dart'; import '../../domain/entities/wallet_entity.dart'; diff --git a/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart b/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart index a02dbff..095e9ae 100644 --- a/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart +++ b/lib/feature/wallet/presentation/screen/lobby_screen.parts.dart @@ -350,7 +350,7 @@ class _TopBar extends StatelessWidget { child: (w == null || w.avatar.isEmpty) ? const Icon(Icons.person, color: AppColors.gold) - : ClipOval(child: RandomAvatar(w.avatar)), + : PlayerAvatar(seed: w.avatar, imageUrl: w.avatarImg), ), ), const SizedBox(width: 10), diff --git a/pubspec.lock b/pubspec.lock index c9518d6..18b892d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -279,7 +279,7 @@ packages: source: hosted version: "4.1.0" flutter_svg: - dependency: transitive + dependency: "direct main" description: name: flutter_svg sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" diff --git a/pubspec.yaml b/pubspec.yaml index b4bdbaf..68d1de6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: flame: ^1.30.1 flame_audio: ^2.11.14 random_avatar: ^0.0.8 + flutter_svg: ^2.3.0 # نمایشِ شخصیتِ آواتارِ svg (و png با Image.network) # خرید درون‌برنامه‌ای: هر flavor یکی را استفاده می‌کند (مایکت/کافه‌بازار). myket_iap: ^1.1.11 flutter_poolakey: ^2.2.0